summaryrefslogtreecommitdiffstats
path: root/roles/reverseproxy/files/conversejs/src/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins')
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js190
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js43
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js17
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js47
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js565
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js51
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js48
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js37
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js24
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js35
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js49
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js38
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js20
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js18
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss32
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js144
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js493
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js52
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js64
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss42
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js44
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js5
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js98
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js59
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js125
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js65
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js238
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss73
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss95
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss225
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss240
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js30
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js34
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js28
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js34
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js1056
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js354
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js210
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js477
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js114
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js56
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js24
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js239
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js98
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js1331
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js168
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js151
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js238
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js517
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js156
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js254
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js65
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js37
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js42
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js79
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js78
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js98
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js52
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js21
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss583
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss24
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js51
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js163
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js6
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js8
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js107
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js70
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js27
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js102
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js13
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js97
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js114
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js8
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js117
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js27
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss5
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js37
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js59
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js34
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss53
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss51
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js21
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js33
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js18
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js169
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js55
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js18
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js33
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss31
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js10
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js1215
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js217
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js44
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js40
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js118
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss111
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js18
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js365
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js9
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js211
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/api.js188
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/base.js92
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js59
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/index.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js71
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss119
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js8
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js9
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js3
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js30
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js69
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js53
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js157
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js65
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js9
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js38
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js38
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js190
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js97
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js72
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js92
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js24
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js29
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js44
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js177
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js17
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js67
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js57
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js78
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js35
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js87
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js200
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js53
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js48
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js39
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js68
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js58
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss14
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss28
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss158
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss5
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss49
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss29
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss28
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss39
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss86
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss122
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss119
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss3
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js36
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js33
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js35
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js218
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js33
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js51
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js41
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js10
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js62
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js36
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js21
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js22
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js80
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js37
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js367
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js96
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js427
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js66
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js226
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js74
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js152
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js72
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js193
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js68
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js56
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js301
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js548
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js247
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js481
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js124
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js262
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js141
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js86
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js354
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js59
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js3878
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js475
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js228
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js221
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js1084
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js58
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js18
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js489
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js341
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js53
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js330
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js334
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js83
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js10
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js69
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js134
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js11
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js4
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js7
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js34
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js119
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js22
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js78
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js305
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js46
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js81
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js464
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js155
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js478
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js1130
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js866
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/index.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js49
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js92
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss38
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js81
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js31
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js83
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js33
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js52
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js49
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js57
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js120
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js158
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js17
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js69
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js28
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/push/index.js31
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js181
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/push/utils.js94
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/index.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/panel.js434
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss61
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js86
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js40
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js12
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js553
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/register/utils.js7
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js27
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js117
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js400
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js83
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js26
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js40
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss16
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js13
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js16
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js25
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js10
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js91
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js92
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js46
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js155
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js48
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js75
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss191
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js63
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js19
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js57
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js195
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js537
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js1365
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js114
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js41
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss49
287 files changed, 40935 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js
new file mode 100644
index 0000000..72b52e9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js
@@ -0,0 +1,190 @@
+import 'shared/autocomplete/index.js';
+import log from '@converse/headless/log';
+import tplAdhoc from './templates/ad-hoc.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core.js';
+import { getNameAndValue } from 'utils/html.js';
+
+const { Strophe, sizzle } = converse.env;
+
+
+export default class AdHocCommands extends CustomElement {
+ static get properties () {
+ return {
+ 'alert': { type: String },
+ 'alert_type': { type: String },
+ 'commands': { type: Array },
+ 'fetching': { type: Boolean },
+ 'showform': { type: String },
+ 'view': { type: String },
+ };
+ }
+
+ constructor () {
+ super();
+ this.view = 'choose-service';
+ this.fetching = false;
+ this.showform = '';
+ this.commands = [];
+ }
+
+ render () {
+ return tplAdhoc(this)
+ }
+
+ async fetchCommands (ev) {
+ ev.preventDefault();
+ delete this.alert_type;
+ delete this.alert;
+
+ this.fetching = true;
+
+ const form_data = new FormData(ev.target);
+ const jid = form_data.get('jid').trim();
+ let supported;
+ try {
+ supported = await api.disco.supports(Strophe.NS.ADHOC, jid);
+ } catch (e) {
+ log.error(e);
+ } finally {
+ this.fetching = false;
+ }
+
+ if (supported) {
+ try {
+ this.commands = await api.adhoc.getCommands(jid);
+ this.view = 'list-commands';
+ } catch (e) {
+ log.error(e);
+ this.alert_type = 'danger';
+ this.alert = __('Sorry, an error occurred while looking for commands on that entity.');
+ this.commands = [];
+ log.error(e);
+ return;
+ }
+ } else {
+ this.alert_type = 'danger';
+ this.alert = __("The specified entity doesn't support ad-hoc commands");
+ }
+ }
+
+ async toggleCommandForm (ev) {
+ ev.preventDefault();
+ const node = ev.target.getAttribute('data-command-node');
+ const cmd = this.commands.filter(c => c.node === node)[0];
+ if (this.showform === node) {
+ this.showform = '';
+ this.requestUpdate();
+ } else {
+ const form = await api.adhoc.fetchCommandForm(cmd);
+ cmd.sessionid = form.sessionid;
+ cmd.instructions = form.instructions;
+ cmd.fields = form.fields;
+ cmd.actions = form.actions;
+ this.showform = node;
+ }
+ }
+
+ executeAction (ev) {
+ ev.preventDefault();
+
+ const action = ev.target.getAttribute('data-action');
+
+ if (['execute', 'next', 'prev', 'complete'].includes(action)) {
+ this.runCommand(ev.target.form, action);
+ } else {
+ log.error(`Unknown action: ${action}`);
+ }
+ }
+
+ clearCommand (cmd) {
+ delete cmd.alert;
+ delete cmd.instructions;
+ delete cmd.sessionid;
+ delete cmd.alert_type;
+ cmd.fields = [];
+ cmd.acions = [];
+ this.showform = '';
+ }
+
+ async runCommand (form, action) {
+ const form_data = new FormData(form);
+ const jid = form_data.get('command_jid').trim();
+ const node = form_data.get('command_node').trim();
+
+ const cmd = this.commands.filter(c => c.node === node)[0];
+ delete cmd.alert;
+ this.requestUpdate();
+
+ const inputs = action === 'prev' ? [] :
+ sizzle(':input:not([type=button]):not([type=submit])', form)
+ .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
+ .map(getNameAndValue)
+ .filter(n => n);
+
+ const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs);
+
+ const { fields, status, note, instructions, actions } = response;
+
+ if (status === 'error') {
+ cmd.alert_type = 'danger';
+ cmd.alert = __(
+ 'Sorry, an error occurred while trying to execute the command. See the developer console for details'
+ );
+ return this.requestUpdate();
+ }
+
+ if (status === 'executing') {
+ cmd.alert = __('Executing');
+ cmd.fields = fields;
+ cmd.instructions = instructions;
+ cmd.alert_type = 'primary';
+ cmd.actions = actions;
+ } else if (status === 'completed') {
+ this.alert_type = 'primary';
+ this.alert = __('Completed');
+ this.note = note;
+ this.clearCommand(cmd);
+ } else {
+ log.error(`Unexpected status for ad-hoc command: ${status}`);
+ cmd.alert = __('Completed');
+ cmd.alert_type = 'primary';
+ }
+ this.requestUpdate();
+ }
+
+ async cancel (ev) {
+ ev.preventDefault();
+ this.showform = '';
+ this.requestUpdate();
+
+ const form_data = new FormData(ev.target.form);
+ const jid = form_data.get('command_jid').trim();
+ const node = form_data.get('command_node').trim();
+
+ const cmd = this.commands.filter(c => c.node === node)[0];
+ delete cmd.alert;
+ this.requestUpdate();
+
+ const { status } = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, 'cancel', []);
+
+ if (status === 'error') {
+ cmd.alert_type = 'danger';
+ cmd.alert = __(
+ 'An error occurred while trying to cancel the command. See the developer console for details'
+ );
+ } else if (status === 'canceled') {
+ this.alert_type = '';
+ this.alert = '';
+ this.clearCommand(cmd);
+ } else {
+ log.error(`Unexpected status for ad-hoc command: ${status}`);
+ cmd.alert = __('Error: unexpected result');
+ cmd.alert_type = 'danger';
+ }
+ this.requestUpdate();
+ }
+}
+
+api.elements.define('converse-adhoc-commands', AdHocCommands);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js
new file mode 100644
index 0000000..edb7491
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js
@@ -0,0 +1,23 @@
+/**
+ * @description
+ * Converse.js plugin which provides the UI for XEP-0050 Ad-Hoc commands
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './adhoc-commands.js';
+import { api, converse } from "@converse/headless/core.js";
+
+
+converse.plugins.add('converse-adhoc-views', {
+
+ dependencies: [
+ "converse-controlbox",
+ "converse-muc",
+ ],
+
+ initialize () {
+ api.settings.extend({
+ 'allow_adhoc_commands': true,
+ });
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js
new file mode 100644
index 0000000..4b4de2a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js
@@ -0,0 +1,43 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+
+
+const action_map = {
+ execute: __('Execute'),
+ prev: __('Previous'),
+ next: __('Next'),
+ complete: __('Complete'),
+}
+
+export default (el, command) => {
+ const i18n_cancel = __('Cancel');
+
+ return html`
+ <span> <!-- Don't remove this <span>,
+ this is a workaround for a lit bug where a <form> cannot be removed
+ if it contains an <input> with name "remove" -->
+ <form>
+ ${ command.alert ? html`<div class="alert alert-${command.alert_type}" role="alert">${command.alert}</div>` : '' }
+ <fieldset class="form-group">
+ <input type="hidden" name="command_node" value="${command.node}"/>
+ <input type="hidden" name="command_jid" value="${command.jid}"/>
+
+ <p class="form-instructions">${command.instructions}</p>
+ ${ command.fields }
+ </fieldset>
+ <fieldset>
+ ${ command.actions.map((action) =>
+ html`<input data-action="${action}"
+ @click=${(ev) => el.executeAction(ev)}
+ type="button"
+ class="btn btn-primary"
+ value="${action_map[action]}">`)
+ }<input type="button"
+ class="btn btn-secondary button-cancel"
+ value="${i18n_cancel}"
+ @click=${(ev) => el.cancel(ev)}>
+ </fieldset>
+ </form>
+ </span>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js
new file mode 100644
index 0000000..78f0b2b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js
@@ -0,0 +1,17 @@
+import { html } from "lit";
+import tplCommandForm from './ad-hoc-command-form.js';
+
+export default (el, command) => html`
+ <li class="room-item list-group-item">
+ <div class="available-chatroom d-flex flex-row">
+ <a class="open-room available-room w-100"
+ @click=${(ev) => el.toggleCommandForm(ev)}
+ data-command-node="${command.node}"
+ data-command-jid="${command.jid}"
+ data-command-name="${command.name}"
+ title="${command.name}"
+ href="#">${command.name || command.jid}</a>
+ </div>
+ ${ command.node === el.showform ? tplCommandForm(el, command) : '' }
+ </li>
+`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js
new file mode 100644
index 0000000..b85d214
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js
@@ -0,0 +1,47 @@
+import tplCommand from './ad-hoc-command.js';
+import tplSpinner from 'templates/spinner.js';
+import { __ } from 'i18n';
+import { getAutoCompleteList } from 'plugins/muc-views/utils.js';
+import { html } from "lit";
+
+
+export default (el) => {
+ const i18n_choose_service = __('On which entity do you want to run commands?');
+ const i18n_choose_service_instructions = __(
+ 'Certain XMPP services and entities allow privileged users to execute ad-hoc commands on them.');
+ const i18n_commands_found = __('Commands found');
+ const i18n_fetch_commands = __('List available commands');
+ const i18n_jid_placeholder = __('XMPP Address');
+ const i18n_no_commands_found = __('No commands found');
+ return html`
+ ${ el.alert ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert}</div>` : '' }
+ ${ el.note ? html`<p class="form-help">${el.note}</p>` : '' }
+
+ <form class="converse-form" @submit=${el.fetchCommands}>
+ <fieldset class="form-group">
+ <label>
+ ${i18n_choose_service}
+ <p class="form-help">${i18n_choose_service_instructions}</p>
+ <converse-autocomplete
+ .getAutoCompleteList="${getAutoCompleteList}"
+ required
+ placeholder="${i18n_jid_placeholder}"
+ name="jid">
+ </converse-autocomplete>
+ </label>
+ </fieldset>
+ <fieldset class="form-group">
+ ${ el.fetching ? tplSpinner() : html`<input type="submit" class="btn btn-primary" value="${i18n_fetch_commands}">` }
+ </fieldset>
+ ${ el.view === 'list-commands' ? html`
+ <fieldset class="form-group">
+ <ul class="list-group">
+ <li class="list-group-item active">${ el.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li>
+ ${ el.commands.map(cmd => tplCommand(el, cmd)) }
+ </ul>
+ </fieldset>`
+ : '' }
+
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js
new file mode 100644
index 0000000..9e39fda
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js
@@ -0,0 +1,565 @@
+/*global mock, converse */
+
+const { Strophe, sizzle, u, stx } = converse.env;
+
+describe("Ad-hoc commands", function () {
+
+ it("can be queried for via a modal", mock.initConverse([], {}, async (_converse) => {
+ const { api } = _converse;
+ const entity_jid = 'muc.montague.lit';
+ const { IQ_stanzas } = _converse.connection;
+
+ const modal = await api.modal.show('converse-user-settings-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+ modal.querySelector('#commands-tab').click();
+
+ const adhoc_form = modal.querySelector('converse-adhoc-commands');
+ await u.waitUntil(() => u.isVisible(adhoc_form));
+
+ adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+ adhoc_form.querySelector('input[type="submit"]').click();
+
+ await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+ let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+ let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq type="result"
+ id="${iq.getAttribute("id")}"
+ to="${_converse.jid}"
+ from="${entity_jid}"
+ xmlns="jabber:client">
+ <query xmlns="http://jabber.org/protocol/disco#items"
+ node="http://jabber.org/protocol/commands">
+ <item jid="${entity_jid}"
+ node="list"
+ name="List Service Configurations"/>
+ <item jid="${entity_jid}"
+ node="config"
+ name="Configure Service"/>
+ <item jid="${entity_jid}"
+ node="reset"
+ name="Reset Service Configuration"/>
+ <item jid="${entity_jid}"
+ node="start"
+ name="Start Service"/>
+ <item jid="${entity_jid}"
+ node="stop"
+ name="Stop Service"/>
+ <item jid="${entity_jid}"
+ node="restart"
+ name="Restart Service"/>
+ <item jid="${entity_jid}"
+ node="adduser"
+ name="Add User"/>
+ </query>
+ </iq>`));
+
+ const heading = await u.waitUntil(() => adhoc_form.querySelector('.list-group-item.active'));
+ expect(heading.textContent).toBe('Commands found:');
+
+ const items = adhoc_form.querySelectorAll('.list-group-item:not(.active)');
+ expect(items.length).toBe(7);
+ expect(items[0].textContent.trim()).toBe('List Service Configurations');
+ expect(items[1].textContent.trim()).toBe('Configure Service');
+ expect(items[2].textContent.trim()).toBe('Reset Service Configuration');
+ expect(items[3].textContent.trim()).toBe('Start Service');
+ expect(items[4].textContent.trim()).toBe('Stop Service');
+ expect(items[5].textContent.trim()).toBe('Restart Service');
+ expect(items[6].textContent.trim()).toBe('Add User');
+
+ items[6].querySelector('a').click();
+
+ sel = `iq[to="${entity_jid}"][type="set"] command`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq id="${iq.getAttribute("id")}" to="${entity_jid}" type="set" xmlns="jabber:client">`+
+ `<command action="execute" node="adduser" xmlns="http://jabber.org/protocol/commands"/>`+
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq to="${_converse.jid}" xmlns="jabber:client" type="result" xml:lang="en" id="${iq.getAttribute('id')}" from="${entity_jid}">
+ <command status="executing" node="adduser" sessionid="1653988890.6236324-886f3dc54ce443c6b4a1805877bf7faa" xmlns="http://jabber.org/protocol/commands">
+ <actions>
+ <complete />
+ </actions>
+ <x type="form" xmlns="jabber:x:data">
+ <title>Title</title>
+ <instructions>Instructions</instructions>
+ <field type="boolean" label="Remove my registration" var="remove">
+ <value>0</value>
+ <required />
+ </field>
+ <field type="text-single" label="User name" var="username">
+ <value>romeo</value>
+ <required />
+ </field>
+ <field type="text-single" label="Password" var="password">
+ <value>secret</value>
+ <required />
+ </field>
+ </x>
+ </command>
+ </iq>`));
+
+ const form = await u.waitUntil(() => adhoc_form.querySelector('form form'));
+ expect(u.isVisible(form)).toBe(true);
+ const inputs = form.querySelectorAll('input');
+ expect(inputs.length).toBe(7);
+ expect(inputs[0].getAttribute('name')).toBe('command_node');
+ expect(inputs[0].getAttribute('type')).toBe('hidden');
+ expect(inputs[0].getAttribute('value')).toBe('adduser');
+ expect(inputs[1].getAttribute('name')).toBe('command_jid');
+ expect(inputs[0].getAttribute('type')).toBe('hidden');
+ expect(inputs[1].getAttribute('value')).toBe('muc.montague.lit');
+ expect(inputs[2].getAttribute('name')).toBe('remove');
+ expect(inputs[2].getAttribute('type')).toBe('checkbox');
+ expect(inputs[3].getAttribute('name')).toBe('username');
+ expect(inputs[3].getAttribute('type')).toBe('text');
+ expect(inputs[3].getAttribute('value')).toBe('romeo');
+ expect(inputs[4].getAttribute('name')).toBe('password');
+ expect(inputs[4].getAttribute('type')).toBe('password');
+ expect(inputs[4].getAttribute('value')).toBe('secret');
+ expect(inputs[5].getAttribute('type')).toBe('button');
+ expect(inputs[5].getAttribute('value')).toBe('Complete');
+ expect(inputs[6].getAttribute('type')).toBe('button');
+ expect(inputs[6].getAttribute('value')).toBe('Cancel');
+
+ inputs[6].click();
+ await u.waitUntil(() => !u.isVisible(form));
+ }));
+});
+
+describe("Ad-hoc commands consisting of multiple steps", function () {
+
+ beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+ it("can be queried and executed via a modal", mock.initConverse([], {}, async (_converse) => {
+ const { api } = _converse;
+ const entity_jid = 'montague.lit';
+ const { IQ_stanzas } = _converse.connection;
+
+ const modal = await api.modal.show('converse-user-settings-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+ modal.querySelector('#commands-tab').click();
+
+ const adhoc_form = modal.querySelector('converse-adhoc-commands');
+ await u.waitUntil(() => u.isVisible(adhoc_form));
+
+ adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+ adhoc_form.querySelector('input[type="submit"]').click();
+
+ await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+ let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+ let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq from="${_converse.jid}" id="${iq.getAttribute('id')}" to="${entity_jid}" type="get" xmlns="jabber:client">
+ <query node="http://jabber.org/protocol/commands" xmlns="http://jabber.org/protocol/disco#items"/>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+ <item node="uptime" name="Get uptime" jid="${entity_jid}"/>
+ <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/>
+ <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/>
+ <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/>
+ </query>
+ </iq>
+ `));
+
+ const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+ item.click();
+
+ sel = `iq[to="${entity_jid}"] command`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client">
+ <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/>
+ </iq>`
+ );
+
+ const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad";
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <next/>
+ <complete/>
+ </actions>
+
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 1</title>
+ <instructions>Here's a form.</instructions>
+ <field label="text-private-label" type="text-private" var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+ <value>jid@multi/value#1</value>
+ <value>jid@multi/value#2</value>
+ </field>
+ <field label="text-multi-label" type="text-multi" var="text-multi-field">
+ <value>text</value>
+ <value>multi</value>
+ <value>value</value>
+ </field>
+ <field label="jid-single-label" type="jid-single" var="jid-single-field">
+ <value>jid@single/value</value>
+ </field>
+ <field label="list-single-label" type="list-single" var="list-single-field">
+ <option label="list-single-value"><value>list-single-value</value></option>
+ <option label="list-single-value#2"><value>list-single-value#2</value></option>
+ <option label="list-single-value#3"><value>list-single-value#3</value></option>
+ <value>list-single-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+
+ let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]'));
+ button.click();
+
+ sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+ <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands">
+ <x type="submit" xmlns="jabber:x:data">
+ <field var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ <field var="jid-multi-field">
+ <value>jid@multi/value#1</value>
+ </field>
+ <field var="text-multi-field">
+ <value>text</value>
+ </field>
+ <field var="jid-single-field">
+ <value>jid@single/value</value>
+ </field>
+ <field var="list-single-field">
+ <value>list-single-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <prev/>
+ <next/>
+ <complete/>
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 2</title>
+ <instructions>Here's another form.</instructions>
+ <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+ <value>jid@multi/value#1</value>
+ <value>jid@multi/value#2</value>
+ </field>
+ <field label="boolean-label" type="boolean" var="boolean-field">
+ <value>1</value>
+ </field>
+ <field label="fixed-label" type="fixed" var="fixed-field#1">
+ <value>fixed-value</value>
+ </field>
+ <field label="list-single-label" type="list-single" var="list-single-field">
+ <option label="list-single-value">
+ <value>list-single-value</value>
+ </option>
+ <option label="list-single-value#2">
+ <value>list-single-value#2</value>
+ </option>
+ <option label="list-single-value#3">
+ <value>list-single-value#3</value>
+ </option>
+ <value>list-single-value</value>
+ </field>
+ <field label="text-single-label" type="text-single" var="text-single-field">
+ <value>text-single-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+
+ button = await u.waitUntil(() => modal.querySelector('input[data-action="complete"]'));
+ button.click();
+
+ sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="complete"]`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq xmlns="jabber:client"
+ type="set"
+ to="${entity_jid}"
+ id="${iq.getAttribute('id')}">
+
+ <command xmlns="http://jabber.org/protocol/commands"
+ sessionid="${sessionid}"
+ node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+ action="complete">
+ <x xmlns="jabber:x:data"
+ type="submit">
+ <field var="text-private-field">
+ <value>text-private-value</value></field>
+ <field var="jid-multi-field"><value>jid@multi/value#1</value></field>
+ <field var="text-multi-field"><value>text</value></field>
+ <field var="jid-single-field"><value>jid@single/value</value></field>
+ <field var="list-single-field"><value>list-single-value</value></field>
+ </x>
+ </command>
+ </iq>`
+ );
+
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:server" type="result" from="${entity_jid}" to="${_converse.jid}" id="${iq.getAttribute("id")}">
+ <command xmlns="http://jabber.org/protocol/commands"
+ sessionid="${sessionid}"
+ node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+ status="completed">
+ <note type="info">Service has been configured.</note>
+ </command>
+ </iq>`)
+ );
+ }));
+
+ it("can be canceled", mock.initConverse([], {}, async (_converse) => {
+ const { api } = _converse;
+ const entity_jid = 'montague.lit';
+ const { IQ_stanzas } = _converse.connection;
+
+ const modal = await api.modal.show('converse-user-settings-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+ modal.querySelector('#commands-tab').click();
+
+ const adhoc_form = modal.querySelector('converse-adhoc-commands');
+ await u.waitUntil(() => u.isVisible(adhoc_form));
+
+ adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+ adhoc_form.querySelector('input[type="submit"]').click();
+
+ await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+ let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+ let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+ <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command" jid="${entity_jid}"/>
+ </query>
+ </iq>
+ `));
+
+ const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+ item.click();
+
+ sel = `iq[to="${entity_jid}"] command`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99cc";
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <next/>
+ <complete/>
+ </actions>
+
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 1</title>
+ <instructions>Here's a form.</instructions>
+ <field label="text-private-label" type="text-private" var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+
+ const button = await u.waitUntil(() => modal.querySelector('input.button-cancel'));
+ button.click();
+
+ sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+ <command sessionid="${sessionid}"
+ node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+ action="cancel"
+ xmlns="http://jabber.org/protocol/commands">
+ </command>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands"
+ sessionid="${sessionid}"
+ status="canceled"
+ node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ </command>
+ </iq>
+ `));
+ }));
+
+ it("can be navigated backwards", mock.initConverse([], {}, async (_converse) => {
+ const { api } = _converse;
+ const entity_jid = 'montague.lit';
+ const { IQ_stanzas } = _converse.connection;
+
+ const modal = await api.modal.show('converse-user-settings-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+ modal.querySelector('#commands-tab').click();
+
+ const adhoc_form = modal.querySelector('converse-adhoc-commands');
+ await u.waitUntil(() => u.isVisible(adhoc_form));
+
+ adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+ adhoc_form.querySelector('input[type="submit"]').click();
+
+ await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+ let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+ let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq from="${_converse.jid}" to="${entity_jid}" type="get" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+ <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"/>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+ <item node="uptime" name="Get uptime" jid="${entity_jid}"/>
+ <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/>
+ <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/>
+ <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/>
+ </query>
+ </iq>
+ `));
+
+ const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+ item.click();
+
+ sel = `iq[to="${entity_jid}"] command`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client">
+ <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/>
+ </iq>`);
+
+ const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad";
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <next/>
+ <complete/>
+ </actions>
+
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 1</title>
+ <instructions>Here's a form.</instructions>
+ <field label="text-private-label" type="text-private" var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+
+ let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]'));
+ button.click();
+
+ sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+ <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands">
+ <x type="submit" xmlns="jabber:x:data">
+ <field var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <prev/>
+ <next/>
+ <complete/>
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 2</title>
+ <instructions>Here's another form.</instructions>
+ <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+ <value>jid@multi/value#1</value>
+ <value>jid@multi/value#2</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+
+ button = await u.waitUntil(() => modal.querySelector('input[data-action="prev"]'));
+ button.click();
+
+ sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="prev"]`;
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+ expect(iq).toEqualStanza(stx`
+ <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+ <command sessionid="${sessionid}"
+ node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+ action="prev"
+ xmlns="http://jabber.org/protocol/commands">
+ </command>
+ </iq>`
+ );
+
+ _converse.connection._dataRecv(mock.createRequest(stx`
+ <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+ <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+ <actions>
+ <next/>
+ <complete/>
+ </actions>
+
+ <x xmlns="jabber:x:data" type="form">
+ <title>Step 1</title>
+ <instructions>Here's a form.</instructions>
+ <field label="text-private-label" type="text-private" var="text-private-field">
+ <value>text-private-value</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ `));
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js
new file mode 100644
index 0000000..a550aa1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js
@@ -0,0 +1,51 @@
+import tplMUCBookmarkForm from './templates/form.js';
+import { CustomElement } from 'shared/components/element';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCBookmarkForm extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ willUpdate (changed_properties) {
+ if (changed_properties.has('jid')) {
+ this.model = _converse.chatboxes.get(this.jid);
+ this.bookmark = _converse.bookmarks.get(this.jid);
+ }
+ }
+
+ render () {
+ return tplMUCBookmarkForm(this)
+ }
+
+ onBookmarkFormSubmitted (ev) {
+ ev.preventDefault();
+ _converse.bookmarks.createBookmark({
+ 'jid': this.jid,
+ 'autojoin': ev.target.querySelector('input[name="autojoin"]')?.checked || false,
+ 'name': ev.target.querySelector('input[name=name]')?.value,
+ 'nick': ev.target.querySelector('input[name=nick]')?.value
+ });
+ this.closeBookmarkForm(ev);
+ }
+
+ removeBookmark (ev) {
+ this.bookmark?.destroy();
+ this.closeBookmarkForm(ev);
+ }
+
+ closeBookmarkForm (ev) {
+ ev.preventDefault();
+ const evt = document.createEvent('Event');
+ evt.initEvent('hide.bs.modal', true, true);
+ this.dispatchEvent(evt);
+ }
+}
+
+api.elements.define('converse-muc-bookmark-form', MUCBookmarkForm);
+
+export default MUCBookmarkForm;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js
new file mode 100644
index 0000000..8c981d9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js
@@ -0,0 +1,48 @@
+import debounce from "lodash-es/debounce";
+import tplBookmarksList from './templates/list.js';
+import tplSpinner from "templates/spinner.js";
+import { CustomElement } from 'shared/components/element.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api } from '@converse/headless/core.js';
+import { initStorage } from '@converse/headless/utils/storage.js';
+
+import '../styles/bookmarks.scss';
+
+
+export default class BookmarksView extends CustomElement {
+
+ async initialize () {
+ await api.waitUntil('bookmarksInitialized');
+ const { bookmarks, chatboxes } = _converse;
+
+ this.liveFilter = debounce((ev) => this.model.set({'filter_text': ev.target.value}), 100);
+
+ this.listenTo(bookmarks, 'add', () => this.requestUpdate());
+ this.listenTo(bookmarks, 'remove', () => this.requestUpdate());
+
+ this.listenTo(chatboxes, 'add', () => this.requestUpdate());
+ this.listenTo(chatboxes, 'remove', () => this.requestUpdate());
+
+ const id = `converse.bookmarks-list-model-${_converse.bare_jid}`;
+ this.model = new Model({ id });
+ initStorage(this.model, id);
+
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+
+ this.model.fetch({
+ 'success': () => this.requestUpdate(),
+ 'error': () => this.requestUpdate(),
+ });
+ }
+
+ render () {
+ return _converse.bookmarks && this.model ? tplBookmarksList(this) : tplSpinner();
+ }
+
+ clearFilter (ev) {
+ ev?.stopPropagation?.();
+ this.model.set('filter_text', '');
+ }
+}
+
+api.elements.define('converse-bookmarks', BookmarksView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js
new file mode 100644
index 0000000..39f2066
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js
@@ -0,0 +1,37 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (el) => {
+ const name = el.model.getDisplayName();
+ const nick = el.bookmark?.get('nick') ?? el.model.get('nick');
+
+ const i18n_heading = __('Bookmark for "%1$s"', name);
+ const i18n_autojoin = __('Would you like this groupchat to be automatically joined upon startup?');
+ const i18n_remove = __('Remove');
+ const i18n_name = __('The name for this bookmark:');
+ const i18n_nick = __('What should your nickname for this groupchat be?');
+ const i18n_submit = el.bookmark ? __('Update') : __('Save');
+
+ return html`
+ <form class="converse-form chatroom-form" @submit=${(ev) => el.onBookmarkFormSubmitted(ev)}>
+ <legend>${i18n_heading}</legend>
+ <fieldset class="form-group">
+ <label for="converse_muc_bookmark_name">${i18n_name}</label>
+ <input class="form-control" type="text" value="${name}" name="name" required="required" id="converse_muc_bookmark_name"/>
+ </fieldset>
+ <fieldset class="form-group">
+ <label for="converse_muc_bookmark_nick">${i18n_nick}</label>
+ <input class="form-control" type="text" name="nick" value="${nick || ''}" id="converse_muc_bookmark_nick"/>
+ </fieldset>
+ <fieldset class="form-group form-check">
+ <input class="form-check-input" id="converse_muc_bookmark_autojoin" type="checkbox" ?checked=${el.bookmark?.get('autojoin')} name="autojoin"/>
+ <label class="form-check-label" for="converse_muc_bookmark_autojoin">${i18n_autojoin}</label>
+ </fieldset>
+ <fieldset class="form-group">
+ <input class="btn btn-primary" type="submit" value="${i18n_submit}">
+ ${el.bookmark ? html`<input class="btn btn-secondary button-remove" type="button" value="${i18n_remove}" @click=${(ev) => el.removeBookmark(ev)}>` : '' }
+ </fieldset>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js
new file mode 100644
index 0000000..35cb566
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js
@@ -0,0 +1,24 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { openRoomViaEvent, removeBookmarkViaEvent } from '../../utils.js';
+
+export default (bm) => {
+ const jid = bm.get('jid');
+ const info_remove_bookmark = __('Unbookmark this groupchat');
+ const open_title = __('Click to open this groupchat');
+ return html`
+ <div class="list-item room-item available-chatroom d-flex flex-row" data-room-jid="${jid}">
+ <a class="list-item-link open-room w-100" data-room-jid="${jid}"
+ title="${open_title}"
+ @click=${openRoomViaEvent}>${bm.getDisplayName()}</a>
+
+ <a class="list-item-action remove-bookmark align-self-center ${ bm.get('bookmarked') ? 'button-on' : '' }"
+ data-room-jid="${jid}"
+ data-bookmark-name="${bm.getDisplayName()}"
+ title="${info_remove_bookmark}"
+ @click=${removeBookmarkViaEvent}>
+ <converse-icon class="fa fa-bookmark" size="1em"></converse-icon>
+ </a>
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js
new file mode 100644
index 0000000..94e42db
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js
@@ -0,0 +1,35 @@
+import bookmark_item from './item.js';
+import { __ } from 'i18n';
+import { _converse } from '@converse/headless/core.js';
+import { html } from "lit";
+
+const filterBookmark = (b, text) => b.get('name')?.includes(text) || b.get('jid')?.includes(text);
+
+export default (el) => {
+ const i18n_placeholder = __('Filter');
+ const filter_text = el.model.get('filter_text');
+ const { bookmarks } = _converse;
+ const shown_bookmarks = filter_text ? bookmarks.filter(b => filterBookmark(b, filter_text)) : bookmarks;
+
+ return html`
+ <form class="converse-form bookmarks-filter">
+ <div class="btn-group w-100">
+ <input
+ .value=${filter_text ?? ''}
+ @keydown="${ev => el.liveFilter(ev)}"
+ class="form-control"
+ placeholder="${i18n_placeholder}"/>
+
+ <converse-icon size="1em" class="fa fa-times clear-input ${ !filter_text ? 'hidden' : '' }"
+ @click=${el.clearFilter}>
+ </converse-icon>
+ </div>
+ </form>
+
+ <div class="list-container list-container--bookmarks">
+ <div class="items-list bookmarks rooms-list">
+ ${ shown_bookmarks.map(bm => bookmark_item(bm)) }
+ </div>
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js
new file mode 100644
index 0000000..b731c9f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js
@@ -0,0 +1,49 @@
+/**
+ * @description Converse.js plugin which adds views for XEP-0048 bookmarks
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './modals/bookmark-list.js';
+import './modals/bookmark-form.js';
+import '@converse/headless/plugins/muc/index.js';
+import BookmarkForm from './components/bookmark-form.js';
+import BookmarksView from './components/bookmarks-list.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { bookmarkableChatRoomView } from './mixins.js';
+import { getHeadingButtons, removeBookmarkViaEvent, addBookmarkViaEvent } from './utils.js';
+
+import './styles/bookmarks.scss';
+
+
+converse.plugins.add('converse-bookmark-views', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ */
+ dependencies: ['converse-chatboxes', 'converse-muc', 'converse-muc-views'],
+
+ initialize () {
+ // Configuration values for this plugin
+ // ====================================
+ // Refer to docs/source/configuration.rst for explanations of these
+ // configuration settings.
+ api.settings.extend({
+ hide_open_bookmarks: true
+ });
+
+ _converse.removeBookmarkViaEvent = removeBookmarkViaEvent;
+ _converse.addBookmarkViaEvent = addBookmarkViaEvent;
+
+ Object.assign(_converse.ChatRoomView.prototype, bookmarkableChatRoomView);
+
+ _converse.MUCBookmarkForm = BookmarkForm;
+ _converse.BookmarksView = BookmarksView;
+
+ api.listen.on('getHeadingButtons', getHeadingButtons);
+ api.listen.on('chatRoomViewInitialized', view => view.setBookmarkState());
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js
new file mode 100644
index 0000000..693b98b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js
@@ -0,0 +1,38 @@
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { u } = converse.env;
+
+export const bookmarkableChatRoomView = {
+ /**
+ * Set whether the groupchat is bookmarked or not.
+ * @private
+ */
+ setBookmarkState () {
+ if (_converse.bookmarks !== undefined) {
+ const models = _converse.bookmarks.where({ 'jid': this.model.get('jid') });
+ if (!models.length) {
+ this.model.save('bookmarked', false);
+ } else {
+ this.model.save('bookmarked', true);
+ }
+ }
+ },
+
+ renderBookmarkForm () {
+ if (!this.bookmark_form) {
+ this.bookmark_form = new _converse.MUCBookmarkForm({
+ 'model': this.model,
+ 'chatroomview': this
+ });
+ const container_el = this.querySelector('.chatroom-body');
+ container_el.insertAdjacentElement('beforeend', this.bookmark_form.el);
+ }
+ u.showElement(this.bookmark_form.el);
+ },
+
+ showBookmarkModal(ev) {
+ ev?.preventDefault();
+ const jid = this.model.get('jid');
+ api.modal.show('converse-bookmark-form-modal', { jid }, ev);
+ }
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js
new file mode 100644
index 0000000..8505228
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js
@@ -0,0 +1,20 @@
+import '../components/bookmark-form.js';
+import BaseModal from "plugins/modal/modal.js";
+import { html } from "lit";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+
+export default class BookmarkFormModal extends BaseModal {
+
+ renderModal () {
+ return html`
+ <converse-muc-bookmark-form class="muc-form-container" jid="${this.jid}">
+ </converse-muc-bookmark-form>`;
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Bookmark');
+ }
+}
+
+api.elements.define('converse-bookmark-form-modal', BookmarkFormModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js
new file mode 100644
index 0000000..ad72a63
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js
@@ -0,0 +1,18 @@
+import '../components/bookmarks-list.js';
+import BaseModal from "plugins/modal/modal.js";
+import { html } from "lit";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+
+export default class BookmarkListModal extends BaseModal {
+
+ renderModal () { // eslint-disable-line class-methods-use-this
+ return html`<converse-bookmarks></converse-bookmarks>`;
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Bookmarks');
+ }
+}
+
+api.elements.define('converse-bookmark-list-modal', BookmarkListModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss
new file mode 100644
index 0000000..bf701f3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss
@@ -0,0 +1,32 @@
+.conversejs {
+ #controlbox {
+ .bookmarks-toggle, .bookmarks-toggle .fa {
+ color: var(--groupchats-header-color) !important;
+ &:hover {
+ color: var(--chatroom-head-bg-color-dark) !important;
+ }
+ }
+ }
+}
+
+.conversejs.fullscreen {
+ #controlbox {
+ #chatrooms {
+ .bookmarks-list {
+ dl.rooms-list.bookmarks {
+ dd.available-chatroom {
+ a.open-room {
+ width: 80%;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+converse-bookmarks {
+ .list-item-link {
+ padding: 0 1em;
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js
new file mode 100644
index 0000000..afa3a5f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js
@@ -0,0 +1,144 @@
+/* global mock, converse */
+
+const { Strophe, u, sizzle, $iq } = converse.env;
+
+describe("The bookmarks list modal", function () {
+
+ it("shows a list of bookmarks", mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+ mock.openControlBox(_converse);
+
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ controlbox.querySelector('.show-bookmark-list-modal').click();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+ '<items node="storage:bookmarks"/>'+
+ '</pubsub>'+
+ '</iq>'
+ );
+
+ const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+ .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'The Play&apos;s the Thing',
+ 'autojoin': 'false',
+ 'jid': 'theplay@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': '1st Bookmark',
+ 'autojoin': 'false',
+ 'jid': 'first@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'autojoin': 'false',
+ 'jid': 'noname@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Bookmark with a very very long name that will be shortened',
+ 'autojoin': 'false',
+ 'jid': 'longname@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Another room',
+ 'autojoin': 'false',
+ 'jid': 'another@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const modal = _converse.api.modal.get('converse-bookmark-list-modal');
+ await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length);
+ expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(5);
+ let els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link');
+ expect(els[0].textContent).toBe("1st Bookmark");
+ expect(els[1].textContent).toBe("Another room");
+ expect(els[2].textContent).toBe("Bookmark with a very very long name that will be shortened");
+ expect(els[3].textContent).toBe("noname@conference.shakespeare.lit");
+ expect(els[4].textContent).toBe("The Play's the Thing");
+
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ modal.querySelector('.bookmarks.rooms-list .room-item:nth-child(2) a:nth-child(2)').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length === 4)
+ els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link');
+ expect(els[0].textContent).toBe("1st Bookmark");
+ expect(els[1].textContent).toBe("Bookmark with a very very long name that will be shortened");
+ expect(els[2].textContent).toBe("noname@conference.shakespeare.lit");
+ expect(els[3].textContent).toBe("The Play's the Thing");
+ }));
+
+ it("can be used to open a MUC from a bookmark", mock.initConverse(
+ ['chatBoxesFetched'], {'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ const api = _converse.api;
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+ mock.openControlBox(_converse);
+
+ const controlbox = await _converse.chatboxviews.get('controlbox');
+ controlbox.querySelector('.show-bookmark-list-modal').click();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+ const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+ .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'The Play&apos;s the Thing',
+ 'autojoin': 'false',
+ 'jid': 'theplay@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': '1st Bookmark',
+ 'autojoin': 'false',
+ 'jid': 'first@conference.shakespeare.lit'
+ }).c('nick').t('JC');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const modal = api.modal.get('converse-bookmark-list-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length);
+ expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(2);
+ modal.querySelector('.bookmarks.rooms-list .open-room').click();
+ await u.waitUntil(() => _converse.chatboxes.length === 2);
+ expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false);
+
+ await u.waitUntil(() => modal.querySelectorAll('.list-container--bookmarks .available-chatroom').length);
+ modal.querySelector('.list-container--bookmarks .available-chatroom:last-child .open-room').click();
+ await u.waitUntil(() => _converse.chatboxes.length === 3);
+
+ expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(true);
+ expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(false);
+
+ controlbox.querySelector('.list-container--openrooms .open-room:first-child').click();
+ await u.waitUntil(() => controlbox.querySelector('.list-item.open').getAttribute('data-room-jid') === 'first@conference.shakespeare.lit');
+ expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false);
+ expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(true);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js
new file mode 100644
index 0000000..8c2824b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js
@@ -0,0 +1,493 @@
+/* global mock, converse */
+
+const { Strophe, sizzle } = converse.env;
+
+
+describe("A chat room", function () {
+
+ it("can be bookmarked", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ const { u, $iq } = converse.env;
+ const nick = 'JC';
+ const muc_jid = 'theplay@conference.shakespeare.lit';
+ await mock.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC');
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']);
+
+ await u.waitUntil(() => view.querySelector('.toggle-bookmark') !== null);
+
+ const toggle = view.querySelector('.toggle-bookmark');
+ expect(toggle.title).toBe('Bookmark this groupchat');
+ toggle.click();
+
+ const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ /* Client uploads data:
+ * --------------------
+ * <iq from='juliet@capulet.lit/balcony' type='set' id='pip1'>
+ * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+ * <publish node='storage:bookmarks'>
+ * <item id='current'>
+ * <storage xmlns='storage:bookmarks'>
+ * <conference name='The Play&apos;s the Thing'
+ * autojoin='true'
+ * jid='theplay@conference.shakespeare.lit'>
+ * <nick>JC</nick>
+ * </conference>
+ * </storage>
+ * </item>
+ * </publish>
+ * <publish-options>
+ * <x xmlns='jabber:x:data' type='submit'>
+ * <field var='FORM_TYPE' type='hidden'>
+ * <value>http://jabber.org/protocol/pubsub#publish-options</value>
+ * </field>
+ * <field var='pubsub#persist_items'>
+ * <value>true</value>
+ * </field>
+ * <field var='pubsub#access_model'>
+ * <value>whitelist</value>
+ * </field>
+ * </x>
+ * </publish-options>
+ * </pubsub>
+ * </iq>
+ */
+ expect(view.model.get('bookmarked')).toBeFalsy();
+ const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
+ form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
+ form.querySelector('input[name="autojoin"]').checked = 'checked';
+ form.querySelector('input[name="nick"]').value = 'JC';
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ modal.querySelector('converse-muc-bookmark-form .btn-primary').click();
+
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="storage:bookmarks">`+
+ `<item id="current">`+
+ `<storage xmlns="storage:bookmarks">`+
+ `<conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play&amp;apos;s the Thing">`+
+ `<nick>JC</nick>`+
+ `</conference>`+
+ `</storage>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#persist_items">`+
+ `<value>true</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>whitelist</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`
+ );
+ /* Server acknowledges successful storage
+ *
+ * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/>
+ */
+ const stanza = $iq({
+ 'to':_converse.connection.jid,
+ 'type':'result',
+ 'id': sent_stanza.getAttribute('id')
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.get('bookmarked'));
+ expect(view.model.get('bookmarked')).toBeTruthy();
+ expect(u.hasClass('on-button', view.querySelector('.toggle-bookmark')), true);
+ // We ignore this IQ stanza... (unless it's an error stanza), so
+ // nothing to test for here.
+ }));
+
+
+ it("will be automatically opened if 'autojoin' is set on the bookmark", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { u } = converse.env;
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+ await u.waitUntil(() => _converse.bookmarks);
+ let jid = 'lounge@montague.lit';
+ _converse.bookmarks.create({
+ 'jid': jid,
+ 'autojoin': false,
+ 'name': 'The Lounge',
+ 'nick': ' Othello'
+ });
+ expect(_converse.chatboxviews.get(jid) === undefined).toBeTruthy();
+
+ jid = 'theplay@conference.shakespeare.lit';
+ _converse.bookmarks.create({
+ 'jid': jid,
+ 'autojoin': true,
+ 'name': 'The Play',
+ 'nick': ' Othello'
+ });
+ await new Promise(resolve => _converse.api.listen.once('chatRoomViewInitialized', resolve));
+ expect(!!_converse.chatboxviews.get(jid)).toBe(true);
+
+ // Check that we don't auto-join if muc_respect_autojoin is false
+ api.settings.set('muc_respect_autojoin', false);
+ jid = 'balcony@conference.shakespeare.lit';
+ _converse.bookmarks.create({
+ 'jid': jid,
+ 'autojoin': true,
+ 'name': 'Balcony',
+ 'nick': ' Othello'
+ });
+ expect(_converse.chatboxviews.get(jid) === undefined).toBe(true);
+ }));
+
+
+ describe("when bookmarked", function () {
+
+ it("will use the nickname from the bookmark", mock.initConverse([], {}, async function (_converse) {
+ const { u } = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilBookmarksReturned(_converse);
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ _converse.bookmarks.create({
+ 'jid': muc_jid,
+ 'autojoin': false,
+ 'name': 'The Play',
+ 'nick': 'Othello'
+ });
+ spyOn(_converse.ChatRoom.prototype, 'getAndPersistNickname').and.callThrough();
+ const room_creation_promise = _converse.api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ const room = await room_creation_promise;
+ await u.waitUntil(() => room.getAndPersistNickname.calls.count());
+ expect(room.get('nick')).toBe('Othello');
+ }));
+
+ it("displays that it's bookmarked through its bookmark icon", mock.initConverse([], {}, async function (_converse) {
+
+ const { u } = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await _converse.api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
+ _converse.bookmarks.create({
+ 'jid': view.model.get('jid'),
+ 'autojoin': false,
+ 'name': 'The lounge',
+ 'nick': ' some1'
+ });
+ view.model.set('bookmarked', true);
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') !== null);
+ view.model.set('bookmarked', false);
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null);
+ }));
+
+ it("can be unbookmarked", mock.initConverse([], {}, async function (_converse) {
+ const { u, Strophe } = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilBookmarksReturned(_converse);
+ const nick = 'romeo';
+ const muc_jid = 'theplay@conference.shakespeare.lit';
+ await _converse.api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('.toggle-bookmark'));
+
+ spyOn(view, 'showBookmarkModal').and.callThrough();
+ spyOn(_converse.bookmarks, 'sendBookmarkStanza').and.callThrough();
+
+ _converse.bookmarks.create({
+ 'jid': view.model.get('jid'),
+ 'autojoin': false,
+ 'name': 'The Play',
+ 'nick': 'Othello'
+ });
+
+ expect(_converse.bookmarks.length).toBe(1);
+ await u.waitUntil(() => _converse.chatboxes.length >= 1);
+ expect(view.model.get('bookmarked')).toBeTruthy();
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') !== null);
+ spyOn(_converse.connection, 'getUniqueId').and.callThrough();
+ const bookmark_icon = view.querySelector('.toggle-bookmark');
+ bookmark_icon.click();
+ expect(view.showBookmarkModal).toHaveBeenCalled();
+
+ const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
+
+ expect(form.querySelector('input[name="name"]').value).toBe('The Play');
+ expect(form.querySelector('input[name="autojoin"]').checked).toBeFalsy();
+ expect(form.querySelector('input[name="nick"]').value).toBe('Othello');
+
+ // Remove the bookmark
+ modal.querySelector('.button-remove').click();
+
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null);
+ expect(_converse.bookmarks.length).toBe(0);
+
+ // Check that an IQ stanza is sent out, containing no
+ // conferences to bookmark (since we removed the one and
+ // only bookmark).
+ const sent_stanza = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="storage:bookmarks">`+
+ `<item id="current">`+
+ `<storage xmlns="storage:bookmarks"/>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#persist_items">`+
+ `<value>true</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>whitelist</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`
+ );
+ }));
+ });
+
+ describe("and when autojoin is set", function () {
+
+ it("will be be opened and joined automatically upon login", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilBookmarksReturned(_converse);
+ spyOn(_converse.api.rooms, 'create').and.callThrough();
+ const jid = 'theplay@conference.shakespeare.lit';
+ const model = _converse.bookmarks.create({
+ 'jid': jid,
+ 'autojoin': false,
+ 'name': 'The Play',
+ 'nick': ''
+ });
+ expect(_converse.api.rooms.create).not.toHaveBeenCalled();
+ _converse.bookmarks.remove(model);
+ _converse.bookmarks.create({
+ 'jid': jid,
+ 'autojoin': true,
+ 'name': 'Hamlet',
+ 'nick': ''
+ });
+ expect(_converse.api.rooms.create).toHaveBeenCalled();
+ }));
+ });
+});
+
+describe("Bookmarks", function () {
+
+ it("can be pushed from the XMPP server", mock.initConverse(
+ ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
+
+ const { $msg, u } = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilBookmarksReturned(_converse);
+
+ /* The stored data is automatically pushed to all of the user's
+ * connected resources.
+ *
+ * Publisher receives event notification
+ * -------------------------------------
+ * <message from='juliet@capulet.lit'
+ * to='juliet@capulet.lit/balcony'
+ * type='headline'
+ * id='rnfoo1'>
+ * <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ * <items node='storage:bookmarks'>
+ * <item id='current'>
+ * <storage xmlns='storage:bookmarks'>
+ * <conference name='The Play&apos;s the Thing'
+ * autojoin='true'
+ * jid='theplay@conference.shakespeare.lit'>
+ * <nick>JC</nick>
+ * </conference>
+ * </storage>
+ * </item>
+ * </items>
+ * </event>
+ * </message>
+ */
+ let stanza = $msg({
+ 'from': 'romeo@montague.lit',
+ 'to': _converse.jid,
+ 'type': 'headline',
+ 'id': u.getUniqueId()
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'The Play&apos;s the Thing',
+ 'autojoin': 'true',
+ 'jid':'theplay@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Another bookmark',
+ 'autojoin': 'false',
+ 'jid':'another@conference.shakespeare.lit'
+ }).c('nick').t('JC');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.bookmarks.length);
+ expect(_converse.bookmarks.length).toBe(2);
+ expect(_converse.bookmarks.map(b => b.get('name'))).toEqual(['Another bookmark', 'The Play&apos;s the Thing']);
+ expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
+
+ stanza = $msg({
+ 'from': 'romeo@montague.lit',
+ 'to': _converse.jid,
+ 'type': 'headline',
+ 'id': u.getUniqueId()
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'The Play&apos;s the Thing',
+ 'autojoin': 'true',
+ 'jid':'theplay@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Second bookmark',
+ 'autojoin': 'false',
+ 'jid':'another@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Yet another bookmark',
+ 'autojoin': 'false',
+ 'jid':'yab@conference.shakespeare.lit'
+ }).c('nick').t('JC');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => _converse.bookmarks.length === 3);
+ expect(_converse.bookmarks.map(b => b.get('name'))).toEqual(['Second bookmark', 'The Play&apos;s the Thing', 'Yet another bookmark']);
+ expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined();
+ expect(Object.keys(_converse.chatboxviews.getAll()).length).toBe(2);
+ }));
+
+
+ it("can be retrieved from the XMPP server", mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const { Strophe, sizzle, u, $iq } = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+ /* Client requests all items
+ * -------------------------
+ *
+ * <iq from='juliet@capulet.lit/randomID' type='get' id='retrieve1'>
+ * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+ * <items node='storage:bookmarks'/>
+ * </pubsub>
+ * </iq>
+ */
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+ '<items node="storage:bookmarks"/>'+
+ '</pubsub>'+
+ '</iq>');
+
+ /*
+ * Server returns all items
+ * ------------------------
+ * <iq type='result'
+ * to='juliet@capulet.lit/randomID'
+ * id='retrieve1'>
+ * <pubsub xmlns='http://jabber.org/protocol/pubsub'>
+ * <items node='storage:bookmarks'>
+ * <item id='current'>
+ * <storage xmlns='storage:bookmarks'>
+ * <conference name='The Play&apos;s the Thing'
+ * autojoin='true'
+ * jid='theplay@conference.shakespeare.lit'>
+ * <nick>JC</nick>
+ * </conference>
+ * </storage>
+ * </item>
+ * </items>
+ * </pubsub>
+ * </iq>
+ */
+ expect(_converse.bookmarks.models.length).toBe(0);
+
+ spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough();
+ var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+ .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'The Play&apos;s the Thing',
+ 'autojoin': 'true',
+ 'jid': 'theplay@conference.shakespeare.lit'
+ }).c('nick').t('JC').up().up()
+ .c('conference', {
+ 'name': 'Another room',
+ 'autojoin': 'false',
+ 'jid': 'another@conference.shakespeare.lit'
+ }); // Purposefully exclude the <nick> element to test #1043
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count());
+ await _converse.api.waitUntil('bookmarksInitialized');
+ expect(_converse.bookmarks.models.length).toBe(2);
+ expect(_converse.bookmarks.get('theplay@conference.shakespeare.lit').get('autojoin')).toBe(true);
+ expect(_converse.bookmarks.get('another@conference.shakespeare.lit').get('autojoin')).toBe(false);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js
new file mode 100644
index 0000000..4c54993
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js
@@ -0,0 +1,52 @@
+import invokeMap from 'lodash-es/invokeMap';
+import { Model } from '@converse/skeletor/src/model.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+import { checkBookmarksSupport } from '@converse/headless/plugins/bookmarks/utils';
+
+
+export function getHeadingButtons (view, buttons) {
+ if (api.settings.get('allow_bookmarks') && view.model.get('type') === _converse.CHATROOMS_TYPE) {
+ const data = {
+ 'i18n_title': __('Bookmark this groupchat'),
+ 'i18n_text': __('Bookmark'),
+ 'handler': (ev) => view.showBookmarkModal(ev),
+ 'a_class': 'toggle-bookmark',
+ 'icon_class': 'fa-bookmark',
+ 'name': 'bookmark'
+ };
+ const names = buttons.map(t => t.name);
+ const idx = names.indexOf('details');
+ const data_promise = checkBookmarksSupport().then((s) => (s ? data : null));
+ return idx > -1 ? [...buttons.slice(0, idx), data_promise, ...buttons.slice(idx)] : [data_promise, ...buttons];
+ }
+ return buttons;
+}
+
+export async function removeBookmarkViaEvent (ev) {
+ ev.preventDefault();
+ const name = ev.currentTarget.getAttribute('data-bookmark-name');
+ const jid = ev.currentTarget.getAttribute('data-room-jid');
+ const result = await api.confirm(__('Are you sure you want to remove the bookmark "%1$s"?', name));
+ if (result) {
+ invokeMap(_converse.bookmarks.where({ jid }), Model.prototype.destroy);
+ }
+}
+
+export function addBookmarkViaEvent (ev) {
+ ev.preventDefault();
+ const jid = ev.currentTarget.getAttribute('data-room-jid');
+ api.modal.show('converse-bookmark-form-modal', { jid }, ev);
+}
+
+
+export function openRoomViaEvent (ev) {
+ ev.preventDefault();
+ const { Strophe } = converse.env;
+ const name = ev.target.textContent;
+ const jid = ev.target.getAttribute('data-room-jid');
+ const data = {
+ 'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
+ };
+ api.rooms.open(jid, data, true);
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js
new file mode 100644
index 0000000..c26422a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js
@@ -0,0 +1,54 @@
+
+class ChatBoxViews {
+
+ constructor () {
+ this.views = {};
+ }
+
+ add (key, val) {
+ this.views[key] = val;
+ }
+
+ get (key) {
+ return this.views[key];
+ }
+
+ xget (id) {
+ return this.keys()
+ .filter(k => (k !== id))
+ .reduce((acc, k) => {
+ acc[k] = this.views[k]
+ return acc;
+ }, {});
+ }
+
+ getAll () {
+ return Object.values(this.views);
+ }
+
+ keys () {
+ return Object.keys(this.views);
+ }
+
+ remove (key) {
+ delete this.views[key];
+ }
+
+ map (f) {
+ return Object.values(this.views).map(f);
+ }
+
+ forEach (f) {
+ return Object.values(this.views).forEach(f);
+ }
+
+ filter (f) {
+ return Object.values(this.views).filter(f);
+ }
+
+ closeAllChatBoxes () {
+ return Promise.all(Object.values(this.views).map(v => v.close({ 'name': 'closeAllChatBoxes' })));
+ }
+}
+
+export default ChatBoxViews;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js
new file mode 100644
index 0000000..adf2fc9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js
@@ -0,0 +1,64 @@
+/**
+ * @module converse-chatboxviews
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './view.js';
+import '@converse/headless/plugins/chatboxes/index.js';
+import ChatBoxViews from './container.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { calculateViewportHeightUnit } from './utils.js';
+
+import './styles/chats.scss';
+
+
+converse.plugins.add('converse-chatboxviews', {
+ dependencies: ['converse-chatboxes', 'converse-vcard'],
+
+ initialize () {
+ api.promises.add(['chatBoxViewsInitialized']);
+
+ // Configuration values for this plugin
+ // ====================================
+ // Refer to docs/source/configuration.rst for explanations of these
+ // configuration settings.
+ api.settings.extend({ 'animate': true });
+
+ _converse.chatboxviews = new ChatBoxViews();
+
+ /************************ BEGIN Event Handlers ************************/
+ api.listen.on('chatBoxesInitialized', () => {
+ _converse.chatboxes.on('destroy', m => _converse.chatboxviews.remove(m.get('jid')));
+ });
+
+ api.listen.on('cleanup', () => delete _converse.chatboxviews);
+ api.listen.on('clearSession', () => _converse.chatboxviews.closeAllChatBoxes());
+ api.listen.on('chatBoxViewsInitialized', calculateViewportHeightUnit);
+
+ window.addEventListener('resize', calculateViewportHeightUnit);
+ /************************ END Event Handlers ************************/
+
+ Object.assign(converse, {
+ /**
+ * Public API method which will ensure that the #conversejs element
+ * is inserted into a container element.
+ *
+ * This method is useful when the #conversejs element has been
+ * detached from the DOM somehow.
+ * @async
+ * @memberOf converse
+ * @method insertInto
+ * @example
+ * converse.insertInto(document.querySelector('#converse-container'));
+ */
+ insertInto (container) {
+ const el = _converse.chatboxviews?.el;
+ if (el && !container.contains(el)) {
+ container.insertAdjacentElement('afterBegin', el);
+ } else if (!el) {
+ throw new Error('Cannot insert non-existing #conversejs element into the DOM');
+ }
+ }
+ });
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss
new file mode 100644
index 0000000..6164337
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss
@@ -0,0 +1,42 @@
+.conversejs {
+ converse-chats {
+ &.converse-chatboxes {
+ z-index: 1031; // One more than bootstrap navbar
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ }
+ &.converse-overlayed {
+ height: 3em;
+ > .row {
+ flex-direction: row-reverse;
+ }
+ }
+
+ &.converse-fullscreen,
+ &.converse-mobile {
+ flex-wrap: nowrap;
+ width: 100vw;
+ }
+ &.converse-embedded {
+ box-sizing: border-box;
+ *, *:before, *:after {
+ box-sizing: border-box;
+ }
+ bottom: auto;
+ height: 100%; // When embedded, it fills the containing element
+ position: relative;
+ right: auto;
+ width: 100%;
+
+ &.converse-chatboxes {
+ z-index: 1031; // One more than bootstrap navbar
+ position: inherit;
+ flex-wrap: nowrap;
+ bottom: auto;
+ height: 100%;
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js
new file mode 100644
index 0000000..8977de8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js
@@ -0,0 +1,44 @@
+import { html } from 'lit';
+import { repeat } from 'lit/directives/repeat.js';
+import { _converse, api } from '@converse/headless/core';
+
+
+function shouldShowChat (c) {
+ const { CONTROLBOX_TYPE } = _converse;
+ const is_minimized = (api.settings.get('view_mode') === 'overlayed' && c.get('minimized'));
+ return c.get('type') === CONTROLBOX_TYPE || !(c.get('hidden') || is_minimized);
+}
+
+
+export default () => {
+ const { chatboxes, CONTROLBOX_TYPE, CHATROOMS_TYPE, HEADLINES_TYPE } = _converse;
+ const view_mode = api.settings.get('view_mode');
+ const connection = _converse?.connection;
+ const logged_out = !connection?.connected || !connection?.authenticated || connection?.disconnecting;
+ return html`
+ ${!logged_out && view_mode === 'overlayed' ? html`<converse-minimized-chats></converse-minimized-chats>` : ''}
+ ${repeat(chatboxes.filter(shouldShowChat), m => m.get('jid'), m => {
+ if (m.get('type') === CONTROLBOX_TYPE) {
+ return html`
+ ${view_mode === 'overlayed' ? html`<converse-controlbox-toggle class="${!m.get('closed') ? 'hidden' : ''}"></converse-controlbox-toggle>` : ''}
+ <converse-controlbox
+ id="controlbox"
+ class="chatbox ${view_mode === 'overlayed' && m.get('closed') ? 'hidden' : ''} ${logged_out ? 'logged-out': ''}"
+ style="${m.get('width') ? `width: ${m.get('width')}` : ''}"></converse-controlbox>
+ `;
+ } else if (m.get('type') === CHATROOMS_TYPE) {
+ return html`
+ <converse-muc jid="${m.get('jid')}" class="chatbox chatroom"></converse-muc>
+ `;
+ } else if (m.get('type') === HEADLINES_TYPE) {
+ return html`
+ <converse-headlines jid="${m.get('jid')}" class="chatbox headlines"></converse-headlines>
+ `;
+ } else {
+ return html`
+ <converse-chat jid="${m.get('jid')}" class="chatbox"></converse-chat>
+ `;
+ }
+ })}
+ `;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js
new file mode 100644
index 0000000..09e8be7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js
@@ -0,0 +1,5 @@
+
+export function calculateViewportHeightUnit () {
+ const vh = window.innerHeight * 0.01;
+ document.documentElement.style.setProperty('--vh', `${vh}px`);
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js
new file mode 100644
index 0000000..3965fdc
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js
@@ -0,0 +1,50 @@
+import tplBackgroundLogo from '../../templates/background_logo.js';
+import tplChats from './templates/chats.js';
+import { CustomElement } from 'shared/components/element.js';
+import { api, _converse } from '@converse/headless/core';
+import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
+import { render } from 'lit';
+
+
+class ConverseChats extends CustomElement {
+
+ initialize () {
+ this.model = _converse.chatboxes;
+ this.listenTo(this.model, 'add', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:closed', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:hidden', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:jid', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:minimized', () => this.requestUpdate());
+ this.listenTo(this.model, 'destroy', () => this.requestUpdate());
+
+ // Use listenTo instead of api.listen.to so that event handlers
+ // automatically get deregistered when the component is dismounted
+ this.listenTo(_converse, 'connected', () => this.requestUpdate());
+ this.listenTo(_converse, 'reconnected', () => this.requestUpdate());
+ this.listenTo(_converse, 'disconnected', () => this.requestUpdate());
+
+ const settings = getAppSettings();
+ this.listenTo(settings, 'change:view_mode', () => this.requestUpdate())
+ this.listenTo(settings, 'change:singleton', () => this.requestUpdate())
+
+ const bg = document.getElementById('conversejs-bg');
+ if (bg && !bg.innerHTML.trim()) {
+ render(tplBackgroundLogo(), bg);
+ }
+ const body = document.querySelector('body');
+ body.classList.add(`converse-${api.settings.get('view_mode')}`);
+
+ /**
+ * Triggered once the _converse.ChatBoxViews view-colleciton has been initialized
+ * @event _converse#chatBoxViewsInitialized
+ * @example _converse.api.listen.on('chatBoxViewsInitialized', () => { ... });
+ */
+ api.trigger('chatBoxViewsInitialized');
+ }
+
+ render () { // eslint-disable-line class-methods-use-this
+ return tplChats();
+ }
+}
+
+api.elements.define('converse-chats', ConverseChats);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js
new file mode 100644
index 0000000..114cb69
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js
@@ -0,0 +1,98 @@
+import './message-form.js';
+import debounce from 'lodash-es/debounce';
+import tplBottomPanel from './templates/bottom-panel.js';
+import { ElementView } from '@converse/skeletor/src/element.js';
+import { _converse, api } from '@converse/headless/core';
+import { clearMessages } from './utils.js';
+import { render } from 'lit';
+
+import './styles/chat-bottom-panel.scss';
+
+
+export default class ChatBottomPanel extends ElementView {
+ events = {
+ 'click .send-button': 'sendButtonClicked',
+ 'click .toggle-clear': 'clearMessages'
+ };
+
+ constructor () {
+ super();
+ this.debouncedRender = debounce(this.render, 100);
+ }
+
+ async connectedCallback () {
+ super.connectedCallback();
+ await this.initialize();
+ this.render(); // don't call in initialize, since the MUCBottomPanel subclasses it
+ // and we want to render after it has finished as wel.
+ }
+
+ async initialize () {
+ this.model = await api.chatboxes.get(this.getAttribute('jid'));
+ await this.model.initialized;
+ this.listenTo(this.model, 'change:num_unread', this.debouncedRender)
+ this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
+
+ this.addEventListener('focusin', ev => this.emitFocused(ev));
+ this.addEventListener('focusout', ev => this.emitBlurred(ev));
+ }
+
+ render () {
+ render(tplBottomPanel({
+ 'model': this.model,
+ 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+ }), this);
+ }
+
+ sendButtonClicked (ev) {
+ this.querySelector('converse-message-form')?.onFormSubmitted(ev);
+ }
+
+ viewUnreadMessages (ev) {
+ ev?.preventDefault?.();
+ this.model.ui.set({ 'scrolled': false });
+ }
+
+ emitFocused (ev) {
+ _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev);
+ }
+
+ emitBlurred (ev) {
+ _converse.chatboxviews.get(this.getAttribute('jid'))?.emitBlurred(ev);
+ }
+
+ onDrop (evt) {
+ if (evt.dataTransfer.files.length == 0) {
+ // There are no files to be dropped, so this isn’t a file
+ // transfer operation.
+ return;
+ }
+ evt.preventDefault();
+ this.model.sendFiles(evt.dataTransfer.files);
+ }
+
+ onDragOver (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ }
+
+ clearMessages (ev) {
+ ev?.preventDefault?.();
+ clearMessages(this.model);
+ }
+
+ async autocompleteInPicker (input, value) {
+ await api.emojis.initialize();
+ const emoji_picker = this.querySelector('converse-emoji-picker');
+ if (emoji_picker) {
+ emoji_picker.model.set({
+ 'ac_position': input.selectionStart,
+ 'autocompleting': value,
+ 'query': value
+ });
+ const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
+ emoji_dropdown?.showMenu();
+ }
+ }
+}
+
+api.elements.define('converse-chat-bottom-panel', ChatBottomPanel);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js
new file mode 100644
index 0000000..916f15c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js
@@ -0,0 +1,59 @@
+import 'plugins/chatview/heading.js';
+import 'plugins/chatview/bottom-panel.js';
+import BaseChatView from 'shared/chat/baseview.js';
+import tplChat from './templates/chat.js';
+import { __ } from 'i18n';
+import { _converse, api } from '@converse/headless/core';
+
+/**
+ * The view of an open/ongoing chat conversation.
+ * @class
+ * @namespace _converse.ChatBoxView
+ * @memberOf _converse
+ */
+export default class ChatView extends BaseChatView {
+ length = 200
+
+ async initialize () {
+ _converse.chatboxviews.add(this.jid, this);
+ this.model = _converse.chatboxes.get(this.jid);
+ this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
+ this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
+ this.listenTo(this.model, 'change:show_help_messages', () => this.requestUpdate());
+
+ await this.model.messages.fetched;
+ !this.model.get('hidden') && this.afterShown()
+ /**
+ * Triggered once the {@link _converse.ChatBoxView} has been initialized
+ * @event _converse#chatBoxViewInitialized
+ * @type { _converse.ChatBoxView }
+ * @example _converse.api.listen.on('chatBoxViewInitialized', view => { ... });
+ */
+ api.trigger('chatBoxViewInitialized', this);
+ }
+
+ render () {
+ return tplChat(Object.assign({
+ 'model': this.model,
+ 'help_messages': this.getHelpMessages(),
+ 'show_help_messages': this.model.get('show_help_messages'),
+ }, this.model.toJSON()));
+ }
+
+ getHelpMessages () { // eslint-disable-line class-methods-use-this
+ return [
+ `<strong>/clear</strong>: ${__('Remove messages')}`,
+ `<strong>/close</strong>: ${__('Close this chat')}`,
+ `<strong>/me</strong>: ${__('Write in the third person')}`,
+ `<strong>/help</strong>: ${__('Show this menu')}`
+ ];
+ }
+
+ afterShown () {
+ this.model.setChatState(_converse.ACTIVE);
+ this.model.clearUnreadMsgCounter();
+ this.maybeFocus();
+ }
+}
+
+api.elements.define('converse-chat', ChatView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js
new file mode 100644
index 0000000..4fad77c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js
@@ -0,0 +1,125 @@
+import 'shared/modals/user-details.js';
+import tplChatboxHead from './templates/chat-head.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+import './styles/chat-head.scss';
+
+
+export default class ChatHeading extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String },
+ }
+ }
+
+ initialize () {
+ this.model = _converse.chatboxes.get(this.jid);
+ this.listenTo(this.model, 'change:status', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:add', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
+ if (this.model.contact) {
+ this.listenTo(this.model.contact, 'destroy', () => this.requestUpdate());
+ }
+ this.model.rosterContactAdded?.then(() => {
+ this.listenTo(this.model.contact, 'change:nickname', () => this.requestUpdate());
+ this.requestUpdate();
+ });
+ }
+
+ render () {
+ return tplChatboxHead(Object.assign(this.model.toJSON(), {
+ 'heading_buttons_promise': this.getHeadingButtons(),
+ 'model': this.model,
+ 'showUserDetailsModal': ev => this.showUserDetailsModal(ev),
+ }));
+ }
+
+ showUserDetailsModal (ev) {
+ ev.preventDefault();
+ api.modal.show('converse-user-details-modal', { model: this.model }, ev);
+ }
+
+ close (ev) {
+ ev.preventDefault();
+ this.model.close();
+ }
+
+ /**
+ * Returns a list of objects which represent buttons for the chat's header.
+ * @async
+ * @emits _converse#getHeadingButtons
+ */
+ getHeadingButtons () {
+ const buttons = [
+ /**
+ * @typedef { Object } HeadingButtonAttributes
+ * An object representing a chat heading button
+ * @property { Boolean } standalone
+ * True if shown on its own, false if it must be in the dropdown menu.
+ * @property { Function } handler
+ * A handler function to be called when the button is clicked.
+ * @property { String } a_class - HTML classes to show on the button
+ * @property { String } i18n_text - The user-visiible name of the button
+ * @property { String } i18n_title - The tooltip text for this button
+ * @property { String } icon_class - What kind of CSS class to use for the icon
+ * @property { String } name - The internal name of the button
+ */
+ {
+ 'a_class': 'show-user-details-modal',
+ 'handler': ev => this.showUserDetailsModal(ev),
+ 'i18n_text': __('Details'),
+ 'i18n_title': __('See more information about this person'),
+ 'icon_class': 'fa-id-card',
+ 'name': 'details',
+ 'standalone': api.settings.get('view_mode') === 'overlayed'
+ }
+ ];
+ if (!api.settings.get('singleton')) {
+ buttons.push({
+ 'a_class': 'close-chatbox-button',
+ 'handler': ev => this.close(ev),
+ 'i18n_text': __('Close'),
+ 'i18n_title': __('Close and end this conversation'),
+ 'icon_class': 'fa-times',
+ 'name': 'close',
+ 'standalone': api.settings.get('view_mode') === 'overlayed'
+ });
+ }
+ const el = _converse.chatboxviews.get(this.getAttribute('jid'));
+ if (el) {
+ /**
+ * *Hook* which allows plugins to add more buttons to a chat's heading.
+ *
+ * Note: This hook is fired for both 1 on 1 chats and groupchats.
+ * If you only care about one, you need to add a check in your code.
+ *
+ * @event _converse#getHeadingButtons
+ * @param { HTMLElement } el
+ * The `converse-chat` (or `converse-muc`) DOM element that represents the chat
+ * @param { Array.<HeadingButtonAttributes> }
+ * An array of the existing buttons. New buttons may be added,
+ * and existing ones removed or modified.
+ * @example
+ * api.listen.on('getHeadingButtons', (el, buttons) => {
+ * buttons.push({
+ * 'i18n_title': __('Foo'),
+ * 'i18n_text': __('Foo Bar'),
+ * 'handler': ev => alert('Foo!'),
+ * 'a_class': 'toggle-foo',
+ * 'icon_class': 'fa-foo',
+ * 'name': 'foo'
+ * });
+ * return buttons;
+ * });
+ */
+ return _converse.api.hook('getHeadingButtons', el, buttons);
+ } else {
+ return buttons; // Happens during tests
+ }
+ }
+}
+
+api.elements.define('converse-chat-heading', ChatHeading);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js
new file mode 100644
index 0000000..b9783e7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js
@@ -0,0 +1,65 @@
+/**
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../chatboxviews/index.js';
+import 'shared/chat/chat-content.js';
+import 'shared/chat/help-messages.js';
+import 'shared/chat/toolbar.js';
+import ChatView from './chat.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { clearHistory } from './utils.js';
+
+import './styles/index.scss';
+
+const { Strophe } = converse.env;
+
+
+converse.plugins.add('converse-chatview', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ *
+ * NB: These plugins need to have already been loaded via require.js.
+ */
+ dependencies: ['converse-chatboxviews', 'converse-chat', 'converse-disco', 'converse-modal'],
+
+ initialize () {
+ /* The initialize function gets called as soon as the plugin is
+ * loaded by converse.js's plugin machinery.
+ */
+ api.settings.extend({
+ 'allowed_audio_domains': null,
+ 'allowed_image_domains': null,
+ 'allowed_video_domains': null,
+ 'auto_focus': true,
+ 'debounced_content_rendering': true,
+ 'filter_url_query_params': null,
+ 'image_urls_regex': null,
+ 'message_limit': 0,
+ 'muc_hats': ['xep317'],
+ 'render_media': true,
+ 'show_message_avatar': true,
+ 'show_retraction_warning': true,
+ 'show_send_button': true,
+ 'show_toolbar': true,
+ 'time_format': 'HH:mm',
+ 'use_system_emojis': true,
+ 'visible_toolbar_buttons': {
+ 'call': false,
+ 'clear': true,
+ 'emoji': true,
+ 'spoiler': true
+ }
+ });
+
+ _converse.ChatBoxView = ChatView;
+
+ api.listen.on('connected', () => api.disco.own.features.add(Strophe.NS.SPOILER));
+ api.listen.on('chatBoxClosed', (model) => clearHistory(model.get('jid')));
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js
new file mode 100644
index 0000000..3ce9e80
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js
@@ -0,0 +1,238 @@
+import tplMessageForm from './templates/message-form.js';
+import { ElementView } from '@converse/skeletor/src/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core.js";
+import { parseMessageForCommands } from './utils.js';
+import { prefixMentions } from '@converse/headless/utils/core.js';
+
+const { u } = converse.env;
+
+
+export default class MessageForm extends ElementView {
+
+ async connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.getAttribute('jid'));
+ await this.model.initialized;
+ this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
+ this.listenTo(this.model, 'change:composing_spoiler', () => this.render());
+
+ this.handleEmojiSelection = ({ detail }) => {
+ if (this.model.get('jid') === detail.jid) {
+ this.insertIntoTextArea(detail.value, detail.autocompleting, false, detail.ac_position);
+ }
+ }
+ document.addEventListener("emojiSelected", this.handleEmojiSelection);
+ this.render();
+ }
+
+ disconnectedCallback () {
+ super.disconnectedCallback();
+ document.removeEventListener("emojiSelected", this.handleEmojiSelection);
+ }
+
+ toHTML () {
+ return tplMessageForm(
+ Object.assign(this.model.toJSON(), {
+ 'onDrop': ev => this.onDrop(ev),
+ 'hint_value': this.querySelector('.spoiler-hint')?.value,
+ 'message_value': this.querySelector('.chat-textarea')?.value,
+ 'onChange': ev => this.model.set({'draft': ev.target.value}),
+ 'onKeyDown': ev => this.onKeyDown(ev),
+ 'onKeyUp': ev => this.onKeyUp(ev),
+ 'onPaste': ev => this.onPaste(ev),
+ 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+ })
+ );
+ }
+
+ /**
+ * Insert a particular string value into the textarea of this chat box.
+ * @param { string } value - The value to be inserted.
+ * @param {(boolean|string)} [replace] - Whether an existing value
+ * should be replaced. If set to `true`, the entire textarea will
+ * be replaced with the new value. If set to a string, then only
+ * that string will be replaced *if* a position is also specified.
+ * @param { number } [position] - The end index of the string to be
+ * replaced with the new value.
+ */
+ insertIntoTextArea (value, replace = false, correcting = false, position) {
+ const textarea = this.querySelector('.chat-textarea');
+ if (correcting) {
+ u.addClass('correcting', textarea);
+ } else {
+ u.removeClass('correcting', textarea);
+ }
+ if (replace) {
+ if (position && typeof replace == 'string') {
+ textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
+ offset == position - replace.length ? value + ' ' : match
+ );
+ } else {
+ textarea.value = value;
+ }
+ } else {
+ let existing = textarea.value;
+ if (existing && existing[existing.length - 1] !== ' ') {
+ existing = existing + ' ';
+ }
+ textarea.value = existing + value + ' ';
+ }
+ const ev = document.createEvent('HTMLEvents');
+ ev.initEvent('change', false, true);
+ textarea.dispatchEvent(ev);
+ u.placeCaretAtEnd(textarea);
+ }
+
+ onMessageCorrecting (message) {
+ if (message.get('correcting')) {
+ this.insertIntoTextArea(prefixMentions(message), true, true);
+ } else {
+ const currently_correcting = this.model.messages.findWhere('correcting');
+ if (currently_correcting && currently_correcting !== message) {
+ this.insertIntoTextArea(prefixMentions(message), true, true);
+ } else {
+ this.insertIntoTextArea('', true, false);
+ }
+ }
+ }
+
+ onEscapePressed (ev) {
+ const idx = this.model.messages.findLastIndex('correcting');
+ const message = idx >= 0 ? this.model.messages.at(idx) : null;
+ if (message) {
+ ev.preventDefault();
+ message.save('correcting', false);
+ this.insertIntoTextArea('', true, false);
+ }
+ }
+
+ onPaste (ev) {
+ ev.stopPropagation();
+ if (ev.clipboardData.files.length !== 0) {
+ ev.preventDefault();
+ // Workaround for quirk in at least Firefox 60.7 ESR:
+ // It seems that pasted files disappear from the event payload after
+ // the event has finished, which apparently happens during async
+ // processing in sendFiles(). So we copy the array here.
+ this.model.sendFiles(Array.from(ev.clipboardData.files));
+ return;
+ }
+ this.model.set({'draft': ev.clipboardData.getData('text/plain')});
+ }
+
+ onKeyUp (ev) {
+ this.model.set({'draft': ev.target.value});
+ }
+
+ onKeyDown (ev) {
+ if (ev.ctrlKey) {
+ // When ctrl is pressed, no chars are entered into the textarea.
+ return;
+ }
+ if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
+ if (ev.keyCode === converse.keycodes.TAB) {
+ const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
+ if (value.startsWith(':')) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.model.trigger('emoji-picker-autocomplete', ev.target, value);
+ }
+ } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
+ // Forward slash is used to run commands. Nothing to do here.
+ return;
+ } else if (ev.keyCode === converse.keycodes.ESCAPE) {
+ return this.onEscapePressed(ev, this);
+ } else if (ev.keyCode === converse.keycodes.ENTER) {
+ return this.onFormSubmitted(ev);
+ } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
+ const textarea = this.querySelector('.chat-textarea');
+ if (!textarea.value || u.hasClass('correcting', textarea)) {
+ return this.model.editEarlierMessage();
+ }
+ } else if (
+ ev.keyCode === converse.keycodes.DOWN_ARROW &&
+ ev.target.selectionEnd === ev.target.value.length &&
+ u.hasClass('correcting', this.querySelector('.chat-textarea'))
+ ) {
+ return this.model.editLaterMessage();
+ }
+ }
+ if (
+ [
+ converse.keycodes.SHIFT,
+ converse.keycodes.META,
+ converse.keycodes.META_RIGHT,
+ converse.keycodes.ESCAPE,
+ converse.keycodes.ALT
+ ].includes(ev.keyCode)
+ ) {
+ return;
+ }
+ if (this.model.get('chat_state') !== _converse.COMPOSING) {
+ // Set chat state to composing if keyCode is not a forward-slash
+ // (which would imply an internal command and not a message).
+ this.model.setChatState(_converse.COMPOSING);
+ }
+ }
+
+ async onFormSubmitted (ev) {
+ ev?.preventDefault?.();
+
+ const textarea = this.querySelector('.chat-textarea');
+ const message_text = textarea.value.trim();
+ if (
+ (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
+ !message_text.replace(/\s/g, '').length
+ ) {
+ return;
+ }
+ if (!_converse.connection.authenticated) {
+ const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
+ api.alert('error', __('Error'), err_msg);
+ api.connection.reconnect();
+ return;
+ }
+ let spoiler_hint,
+ hint_el = {};
+ if (this.model.get('composing_spoiler')) {
+ hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
+ spoiler_hint = hint_el.value;
+ }
+ u.addClass('disabled', textarea);
+ textarea.setAttribute('disabled', 'disabled');
+ this.querySelector('converse-emoji-dropdown')?.hideMenu();
+
+ const is_command = await parseMessageForCommands(this.model, message_text);
+ const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint});
+ if (is_command || message) {
+ hint_el.value = '';
+ textarea.value = '';
+ u.removeClass('correcting', textarea);
+ textarea.style.height = 'auto';
+ this.model.set({'draft': ''});
+ }
+ if (api.settings.get('view_mode') === 'overlayed') {
+ // XXX: Chrome flexbug workaround. The .chat-content area
+ // doesn't resize when the textarea is resized to its original size.
+ const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
+ const msgs_container = chatview.querySelector('.chat-content__messages');
+ msgs_container.parentElement.style.display = 'none';
+ }
+ textarea.removeAttribute('disabled');
+ u.removeClass('disabled', textarea);
+
+ if (api.settings.get('view_mode') === 'overlayed') {
+ // XXX: Chrome flexbug workaround.
+ const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
+ const msgs_container = chatview.querySelector('.chat-content__messages');
+ msgs_container.parentElement.style.display = '';
+ }
+ // Suppress events, otherwise superfluous CSN gets set
+ // immediately after the message, causing rate-limiting issues.
+ this.model.setChatState(_converse.ACTIVE, { 'silent': true });
+ textarea.focus();
+ }
+}
+
+api.elements.define('converse-message-form', MessageForm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss
new file mode 100644
index 0000000..9aeba39
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss
@@ -0,0 +1,73 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+@import "shared/styles/_variables.scss";
+
+.conversejs {
+ .chatbox {
+ .bottom-panel {
+
+ .chat-content-sendbutton {
+ height: calc(100% - (var(--chat-textarea-height) + var(--send-button-height) + 2 * var(--send-button-margin)));
+ }
+
+ .sendXMPPMessage {
+ -moz-background-clip: padding;
+ -webkit-background-clip: padding-box;
+ border-bottom-radius: var(--chatbox-border-radius);
+ background-clip: padding-box;
+ background-color: var(--chat-textarea-background-color);
+ border: 0;
+ margin: 0;
+ padding: 0;
+ @media screen and (max-height: $mobile-landscape-height) {
+ width: 100%;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ width: 100%;
+ }
+
+ .suggestion-box__results {
+ &:after {
+ display: none;
+ }
+ }
+
+ .spoiler-hint {
+ width: 100%;
+ color: var(--foreground);
+ background-color: var(--background);
+ }
+
+ .chat-textarea, input {
+ &:active, &:focus{
+ outline-color: var(--chat-head-color);
+ }
+ &.correcting {
+ background-color: var(--chat-correcting-color);
+ }
+ }
+
+ .chat-textarea {
+ color: var(--chat-textarea-color);
+ background-color: var(--chat-textarea-background-color);
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ border-bottom-radius: var(--chatbox-border-radius);
+ padding-left: 0.5em;
+ padding-right: 4.5em;
+ padding-top: 0.5em;
+ padding-bottom:0.5em;
+ width: 100%;
+ border: none;
+ min-height: var(--chat-textarea-height);
+ margin-bottom: -4px; // Not clear why this is necessar :(
+ resize: none;
+ &.spoiler {
+ height: 42px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss
new file mode 100644
index 0000000..bd6dae4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss
@@ -0,0 +1,95 @@
+.conversejs {
+ .chatbox {
+ .chat-head {
+ display: flex;
+ flex-direction: row;
+ color: #ffffff;
+ font-size: 100%;
+ margin: 0;
+ padding: 0;
+ position: relative;
+
+ &.chat-head-chatbox {
+ background-color: var(--chat-head-color);
+ border-bottom: var(--chat-head-border-bottom);
+ }
+
+ .avatar {
+ margin-right: 0.5em;
+ }
+
+ .show-msg-author-modal {
+ color: var(--chat-head-text-color) !important;
+ }
+
+ .chat-head__desc {
+ color: var(--chat-head-color-lighten-50-percent);
+ font-size: var(--font-size-small);
+ margin: 0;
+ overflow: hidden;
+ padding: 0.5rem 1rem 0.5rem 1rem;
+ text-overflow: ellipsis;
+ width: 100%;
+ }
+
+ .chatbox-title {
+ padding: 0.75rem 1rem 0 1rem;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ width: 100%;
+ }
+
+ .chatbox-title--no-desc {
+ padding: 0.75rem 1rem;
+ }
+
+ .chatbox-title--row {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ width: 100%;
+ }
+
+ .chatbox-title__text {
+ color: var(--chat-head-text-color);;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .chatbox-title__buttons {
+ display: flex;
+ flex-direction: row-reverse;
+ flex-wrap: nowrap;
+ padding: 0;
+ }
+
+ .chatbox-btn {
+ color: white;
+ &:active {
+ position: relative;
+ top: 1px;
+ }
+ }
+
+ converse-dropdown {
+ .dropdown-menu {
+ converse-icon {
+ svg {
+ fill: var(--chat-color);
+ }
+ }
+ }
+ }
+
+
+ .chatbox-btn {
+ converse-icon {
+ svg {
+ fill: var(--chat-head-fg-color);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss
new file mode 100644
index 0000000..8a317cc
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss
@@ -0,0 +1,225 @@
+.conversejs {
+ .chatbox {
+ text-align: left;
+ margin: 0 var(--chat-gutter);
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ margin: 0;
+ width: var(--mobile-chat-width);
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ margin: 0;
+ width: var(--mobile-chat-width);
+ }
+
+ converse-controlbox-navback {
+ display: none;
+ }
+
+ .flyout {
+ position: absolute;
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ border-radius: 0;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ border-radius: 0;
+ }
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ bottom: 0;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ bottom: 0;
+ }
+ }
+
+ .chatbox-btn {
+ border-radius: 25%;
+ border: none;
+ cursor: pointer;
+ font-size: var(--chatbox-button-size);
+ margin: 0 0.2em;
+ padding: 0 0 0 0.5em;
+ text-decoration: none;
+
+ &:active {
+ position: relative;
+ top: 1px;
+ }
+ }
+
+ .box-flyout {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ box-shadow: 1px 3px 5px 3px rgba(0, 0, 0, 0.4);
+ z-index: 2;
+ overflow: hidden;
+ width: 100%;
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ height: var(--mobile-chat-height);
+ width: var(--mobile-chat-width);
+ height: var(--fullpage-chat-height);
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ height: var(--mobile-chat-height);
+ width: var(--mobile-chat-width);
+ height: var(--fullpage-chat-height);
+ }
+ }
+
+ .chat-title {
+ display: var(--heading-display);
+ font-family: var(--heading-font);
+ color: var(--heading-color);
+ display: block;
+ line-height: var(--line-height-large);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ &.groupchat {
+ padding-right: var(--chatroom-head-title-padding-right);
+ }
+ a {
+ color: var(--chat-head-text-color);
+ width: 100%;
+ }
+ }
+
+ .chat-body {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background-color: var(--chat-textarea-background-color);
+ border-bottom-left-radius: var(--chatbox-border-radius);
+ border-bottom-right-radius: var(--chatbox-border-radius);
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ border-top: 0;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ p {
+ color: var(--text-color);
+ font-size: var(--message-font-size);
+ margin: 0;
+ padding: 5px;
+ }
+ }
+ .new-msgs-indicator {
+ position: relative;
+ width: 100%;
+ cursor: pointer;
+ background-color: var(--chat-head-color);
+ color: var(--light-background-color);
+ padding: 0.5em;
+ font-size: 0.9em;
+ text-align: center;
+ z-index: 20;
+ white-space: nowrap;
+ margin-bottom: 0.25em;
+ }
+ .chat-content {
+ background-color: var(--chat-content-background-color);
+ border: 0;
+ color: var(--text-color);
+ font-size: var(--message-font-size);
+ height: 100%;
+ line-height: 1.3em;
+ overflow: hidden;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ converse-chat-message {
+ .spinner {
+ width: 100%;
+ overflow-y: hidden;
+ }
+ }
+
+ .chat-content__help {
+ max-height: 100%;
+ converse-chat-help {
+ border-top: 1px solid var(--chat-head-color);
+ display: block;
+ height: 100%;
+ overflow-y: auto;
+ padding: 0.5em 0;
+ }
+ .close-chat-help {
+ float: right;
+ padding-right: 1em;
+ cursor: pointer;
+ color: var(--chat-content-background-color);
+ svg {
+ fill: var(--chat-head-color);
+ }
+ }
+ }
+
+ .chat-content__messages {
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 100%;
+ }
+
+ .chat-content__notifications {
+ height: 1.7em;
+ white-space: pre;
+ background-color: var(--chat-content-background-color);
+ color: var(--subdued-color);
+ font-size: 90%;
+ font-style: italic;
+ line-height: var(--line-height-small);
+ padding: 0 1em 0.3em;
+ &:before {
+ content: " ";
+ }
+ }
+
+ progress {
+ margin: 0.5em 0;
+ width: 100%
+ }
+ }
+
+ .dragresize {
+ background: transparent;
+ border: 0;
+ margin: 0;
+ position: absolute;
+ top: 0;
+ z-index: 20;
+ &-top {
+ cursor: n-resize;
+ height: 5px;
+ width: 100%;
+ }
+ &-left,
+ &-occupants-left {
+ cursor: w-resize;
+ width: 5px;
+ height: 100%;
+ left: 0;
+ }
+ &-topleft {
+ cursor: nw-resize;
+ width: 15px;
+ height: 15px;
+ top: 0;
+ left: 0;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss
new file mode 100644
index 0000000..ac784c7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss
@@ -0,0 +1,240 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+@import "shared/styles/_variables.scss";
+@import "bootstrap/scss/media";
+@import "./chatbox.scss";
+
+
+/* ******************* Overlay and embedded styles *************************** */
+
+.conversejs {
+ converse-chats.converse-embedded,
+ converse-chats.converse-overlayed {
+ .controlbox-head {
+ padding: 0.5em;
+ }
+ .chat-head {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ .chatbox {
+ min-width: var(--overlayed-chat-width) !important;
+ width: var(--overlayed-chat-width);
+
+ .box-flyout {
+ min-width: var(--overlayed-chat-width) !important;
+ width: var(--overlayed-chat-width);
+ }
+ }
+ }
+
+ converse-chats.converse-overlayed {
+ .chat-head, .box-flyout {
+ border-top-left-radius: var(--chatbox-border-radius);
+ border-top-right-radius: var(--chatbox-border-radius);
+ @media screen and (max-height: $mobile-landscape-height) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ }
+
+ .flyout {
+ bottom: var(--overlayed-chatbox-hover-height);
+ }
+ .box-flyout {
+ height: var(--overlayed-chat-height);
+ min-height: calc(var(--overlayed-chat-height) / 2);
+ }
+ .chat-head {
+ min-height: var(--overlayed-chat-head-height);
+ }
+ .minimized-chats-flyout .chat-head {
+ cursor: default;
+ }
+ .chat-textarea {
+ max-height: var(--overlayed-max-chat-textarea-height);
+ }
+ .chatbox {
+ .chat-body {
+ height: calc(100% - var(--overlayed-chat-head-height));
+ }
+ .chatbox-title {
+ padding: 0.5rem 0.75rem 0 0.75rem;
+ }
+ .chatbox-title--no-desc {
+ padding: 0.5rem 0.75rem;
+ }
+ }
+ }
+}
+
+@include media-breakpoint-down(sm) {
+ .conversejs.converse-overlayed {
+ > .row {
+ flex-direction: column;
+ &.no-gutters {
+ margin: -1em;
+ }
+ }
+ }
+}
+
+
+.conversejs {
+ converse-chats.converse-embedded,
+ converse-chats.converse-fullscreen {
+ .flyout {
+ border-radius: 0;
+ border:none;
+ bottom: 0;
+ }
+
+ .chatbox {
+ margin: 0;
+ margin-left: 15px;
+ .box-flyout {
+ box-shadow: none;
+ overflow: hidden;
+ margin-left: 0;
+ }
+ }
+ }
+
+ converse-chats.converse-fullscreen {
+ &:not(.converse-singleton) {
+ .chatbox {
+ @include media-breakpoint-up(md) {
+ @include make-col(8);
+ }
+ @include media-breakpoint-up(lg) {
+ @include make-col(9);
+ }
+ @include media-breakpoint-up(xl) {
+ @include make-col(10);
+ }
+
+ &:not(#controlbox) {
+ .box-flyout {
+ @include media-breakpoint-up(md) {
+ max-width: 66.666667%;
+ }
+ @include media-breakpoint-up(lg) {
+ max-width: 75%;
+ }
+ @include media-breakpoint-up(xl) {
+ max-width: 83.333333%;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ converse-chats.converse-embedded {
+ .chat-head {
+ font-size: var(--font-size-huge);
+ }
+
+ .chatbox {
+ .box-flyout {
+ bottom: 0;
+ height: 100%;
+ min-width: auto;
+ width: 100%;
+ }
+ }
+
+ .chat-textarea {
+ max-height: var(--fullpage-max-chat-textarea-height);
+ }
+ }
+}
+
+/* ******************* Fullpage styles *************************** */
+
+.conversejs {
+ converse-chats.converse-fullscreen {
+ .chatbox-btn {
+ font-size: var(--fullpage-chatbox-button-size);
+ margin: 0 0.3em;
+ }
+ .chat-head {
+ font-size: var(--font-size-huge);
+ }
+ .chat-textarea {
+ max-height: var(--fullpage-max-chat-textarea-height);
+ }
+ .chatbox {
+ .box-flyout {
+ box-shadow: none;
+ height: var(--fullpage-chat-height);
+ min-height: calc(var(--fullpage-chat-height) / 2);
+ width: var(--fullpage-chat-width);
+ overflow: hidden;
+ }
+ .chat-body {
+ height: inherit;
+ overflow: hidden;
+ background-color: var(--chat-background-color);
+ }
+ .chat-title {
+ font-size: var(--font-size-huge);
+ line-height: var(--line-height-huge);
+ }
+ .sendXMPPMessage {
+ ul {
+ width: 100%;
+ }
+ }
+ }
+ }
+}
+
+
+@include media-breakpoint-down(sm) {
+ .conversejs {
+ converse-chats:not(.converse-embedded) {
+ > .row {
+ flex-direction: row-reverse;
+ }
+ #converse-login-panel {
+ .converse-form {
+ padding: 3em 2em 3em;
+ }
+ }
+ .chatbox {
+ width: calc(100% - 50px);
+ .row {
+ .box-flyout {
+ left: 50px;
+ bottom: 0;
+ height: var(--fullpage-chat-height);
+ box-shadow: none;
+ }
+ }
+ }
+ }
+
+ converse-chats.converse-mobile,
+ converse-chats.converse-overlayed,
+ converse-chats.converse-fullscreen {
+ .chat-head {
+ converse-controlbox-navback {
+ margin: auto 0;
+ margin-right: 1em;
+ display: flex;
+ .fa-arrow-left {
+ svg {
+ fill: var(--chat-head-text-color);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js
new file mode 100644
index 0000000..f60df5f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js
@@ -0,0 +1,30 @@
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core';
+import { html } from 'lit';
+
+
+export default (o) => {
+ const unread_msgs = __('You have unread messages');
+ const message_limit = api.settings.get('message_limit');
+ const show_call_button = api.settings.get('visible_toolbar_buttons').call;
+ const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
+ const show_send_button = api.settings.get('show_send_button');
+ const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
+ const show_toolbar = api.settings.get('show_toolbar');
+ return html`
+ ${ o.model.ui.get('scrolled') && o.model.get('num_unread') ?
+ html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+ ${api.settings.get('show_toolbar') ? html`
+ <converse-chat-toolbar
+ class="chat-toolbar no-text-select"
+ .model=${o.model}
+ ?composing_spoiler="${o.model.get('composing_spoiler')}"
+ ?show_call_button="${show_call_button}"
+ ?show_emoji_button="${show_emoji_button}"
+ ?show_send_button="${show_send_button}"
+ ?show_spoiler_button="${show_spoiler_button}"
+ ?show_toolbar="${show_toolbar}"
+ message_limit="${message_limit}"></converse-chat-toolbar>` : '' }
+ <converse-message-form jid="${o.model.get('jid')}"></converse-message-form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js
new file mode 100644
index 0000000..6f6e9e0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js
@@ -0,0 +1,34 @@
+import { __ } from 'i18n';
+import { _converse } from '@converse/headless/core';
+import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js';
+import { html } from "lit";
+import { until } from 'lit/directives/until.js';
+
+
+export default (o) => {
+ const i18n_profile = __("The User's Profile Image");
+ const avatar = html`<span title="${i18n_profile}">
+ <converse-avatar
+ class="avatar chat-msg__avatar"
+ .data=${o.model.vcard?.attributes}
+ nonce=${o.model.vcard?.get('vcard_updated')}
+ height="40" width="40"></converse-avatar></span>`;
+ const display_name = o.model.getDisplayName();
+
+ return html`
+ <div class="chatbox-title ${ o.status ? '' : "chatbox-title--no-desc"}">
+ <div class="chatbox-title--row">
+ ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
+ ${ (o.type !== _converse.HEADLINES_TYPE) ? html`<a class="show-msg-author-modal" @click=${o.showUserDetailsModal}>${ avatar }</a>` : '' }
+ <div class="chatbox-title__text" title="${o.jid}">
+ ${ (o.type !== _converse.HEADLINES_TYPE) ? html`<a class="user show-msg-author-modal" @click=${o.showUserDetailsModal}>${ display_name }</a>` : display_name }
+ </div>
+ </div>
+ <div class="chatbox-title__buttons row no-gutters">
+ ${ until(getDropdownButtons(o.heading_buttons_promise), '') }
+ ${ until(getStandaloneButtons(o.heading_buttons_promise), '') }
+ </div>
+ </div>
+ ${ o.status ? html`<p class="chat-head__desc">${ o.status }</p>` : '' }
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js
new file mode 100644
index 0000000..754f864
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js
@@ -0,0 +1,28 @@
+import { html } from "lit";
+import { _converse } from '@converse/headless/core';
+
+export default (o) => html`
+ <div class="flyout box-flyout">
+ <converse-dragresize></converse-dragresize>
+ ${ o.model ? html`
+ <converse-chat-heading jid="${o.jid}" class="chat-head chat-head-chatbox row no-gutters"></converse-chat-heading>
+ <div class="chat-body">
+ <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
+ <converse-chat-content
+ class="chat-content__messages"
+ jid="${o.jid}"></converse-chat-content>
+
+ ${o.show_help_messages ? html`<div class="chat-content__help">
+ <converse-chat-help
+ .model=${o.model}
+ .messages=${o.help_messages}
+ ?hidden=${!o.show_help_messages}
+ type="info"
+ chat_type="${_converse.CHATROOMS_TYPE}"
+ ></converse-chat-help></div>` : '' }
+ </div>
+ <converse-chat-bottom-panel jid="${o.jid}" class="bottom-panel"> </converse-chat-bottom-panel>
+ </div>
+ ` : '' }
+ </div>
+`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js
new file mode 100644
index 0000000..56a31a9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js
@@ -0,0 +1,34 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+import { resetElementHeight } from '../utils.js';
+
+
+export default (o) => {
+ const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
+ const label_spoiler_hint = __('Optional hint');
+ const show_send_button = api.settings.get('show_send_button');
+
+ return html`
+ <form class="sendXMPPMessage">
+ <input type="text"
+ enterkeyhint="send"
+ placeholder="${label_spoiler_hint || ''}"i
+ value="${o.hint_value || ''}"
+ class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
+ <textarea
+ autofocus
+ type="text"
+ enterkeyhint="send"
+ @drop=${o.onDrop}
+ @input=${resetElementHeight}
+ @keydown=${o.onKeyDown}
+ @keyup=${o.onKeyUp}
+ @paste=${o.onPaste}
+ @change=${o.onChange}
+ class="chat-textarea
+ ${ show_send_button ? 'chat-textarea-send-button' : '' }
+ ${ o.composing_spoiler ? 'spoiler' : '' }"
+ placeholder="${label_message}">${ o.message_value || '' }</textarea>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js
new file mode 100644
index 0000000..c1ce92c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js
@@ -0,0 +1,1056 @@
+/*global mock, converse */
+
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+const sizzle = converse.env.sizzle;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("Chatboxes", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ describe("A Chatbox", function () {
+
+ it("has a /help command to show the available commands", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ mock.sendMessage(view, '/help');
+ await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view).length);
+ const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view));
+ expect(info_messages.length).toBe(4);
+ expect(info_messages.pop().textContent).toBe('/help: Show this menu');
+ expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
+ expect(info_messages.pop().textContent).toBe('/close: Close this chat');
+ expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
+
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello world').tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === 'hello world');
+ }));
+
+
+ it("has a /clear command", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+
+ for (const i of Array(10).keys()) {
+ mock.sendMessage(view, `Message ${i}`);
+ }
+ await u.waitUntil(() => sizzle('converse-chat-message', view).length === 10);
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = '/clear';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+ await u.waitUntil(() => sizzle('converse-chat-message', view).length === 0);
+ expect(true).toBe(true);
+ }));
+
+
+ it("is created when you click on a roster item", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // openControlBox was called earlier, so the controlbox is
+ // visible, but no other chat boxes have been created.
+ expect(_converse.chatboxes.length).toEqual(1);
+ spyOn(_converse.minimize, 'trimChats');
+ expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ const online_contacts = rosterview.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
+ expect(online_contacts.length).toBe(17);
+ let el = online_contacts[0];
+ el.click();
+ await u.waitUntil(() => document.querySelectorAll("#conversejs .chatbox").length == 2);
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ online_contacts[1].click();
+ await u.waitUntil(() => _converse.chatboxes.length == 3);
+ el = online_contacts[1];
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ // Check that new chat boxes are created to the left of the
+ // controlbox (but to the right of all existing chat boxes)
+ expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3);
+ }));
+
+ it("opens when a new message is received", mock.initConverse(
+ [], {'allow_non_roster_messaging': true},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const stanza = u.toStanza(`
+ <message from="${sender_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard the news?</body>
+ </message>`);
+
+ const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ await u.waitUntil(() => message_promise);
+ expect(_converse.chatboxviews.keys().length).toBe(2);
+ expect(_converse.chatboxviews.keys().pop()).toBe(sender_jid);
+ }));
+
+ it("doesn't open when a message without body is received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const stanza = u.toStanza(`
+ <message from="${sender_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <composing xmlns="http://jabber.org/protocol/chatstates"/>
+ </message>`);
+ const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve))
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => message_promise);
+ expect(_converse.chatboxviews.keys().length).toBe(1);
+ }));
+
+ it("is focused if its already open and you click on its corresponding roster item",
+ mock.initConverse(['chatBoxesFetched'], {'auto_focus': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ expect(_converse.chatboxes.length).toEqual(1);
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(_converse.ChatBoxView.prototype, 'focus').and.callThrough();
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ const rosterview = document.querySelector('converse-roster');
+ const el = sizzle('a.open-chat:contains("'+view.model.getDisplayName()+'")', rosterview).pop();
+ await u.waitUntil(() => u.isVisible(el));
+ const textarea = view.querySelector('.chat-textarea');
+ await u.waitUntil(() => u.isVisible(textarea));
+ textarea.blur();
+ el.click();
+ await u.waitUntil(() => view.focus.calls.count(), 1000);
+ expect(view.focus).toHaveBeenCalled();
+ expect(_converse.chatboxes.length).toEqual(2);
+ }));
+
+ it("can be saved to, and retrieved from, browserStorage",
+ mock.initConverse([], {}, async function (_converse) {
+
+ spyOn(_converse.minimize, 'trimChats');
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ mock.openChatBoxes(_converse, 6);
+ await u.waitUntil(() => _converse.chatboxes.length == 7);
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ // We instantiate a new ChatBoxes collection, which by default
+ // will be empty.
+ const newchatboxes = new _converse.ChatBoxes();
+ expect(newchatboxes.length).toEqual(0);
+ // The chatboxes will then be fetched from browserStorage inside the
+ // onConnected method
+ newchatboxes.onConnected();
+ await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+ expect(newchatboxes.length).toEqual(7);
+ // Check that the chatboxes items retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ const attrs = ['id', 'box_id', 'visible'];
+ let new_attrs, old_attrs;
+ for (let i=0; i<attrs.length; i++) {
+ new_attrs = newchatboxes.models.map(m => m.attributes[i]);
+ old_attrs = _converse.chatboxes.models.map(m => m.attributes[i]);
+ expect(new_attrs).toEqual(old_attrs);
+ }
+ }));
+
+ it("can be closed by clicking a DOM element with class 'close-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ spyOn(chatview.model, 'close').and.callThrough();
+ spyOn(_converse.api, "trigger").and.callThrough();
+ chatview.querySelector('.close-chatbox-button').click();
+ expect(chatview.model.close).toHaveBeenCalled();
+ await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+ }));
+
+ it("will be removed from browserStorage when closed",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ spyOn(_converse.minimize, 'trimChats');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const promise = new Promise(resolve => _converse.api.listen.once('controlBoxClosed', resolve));
+ mock.closeControlBox();
+ await promise;
+ expect(_converse.chatboxes.length).toEqual(1);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ mock.openChatBoxes(_converse, 6);
+ await u.waitUntil(() => _converse.chatboxes.length == 7)
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ expect(_converse.chatboxes.length).toEqual(7);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxViewInitialized', jasmine.any(Object));
+ await mock.closeAllChatBoxes(_converse);
+
+ expect(_converse.chatboxes.length).toEqual(1);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+ const newchatboxes = new _converse.ChatBoxes();
+ expect(newchatboxes.length).toEqual(0);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ // onConnected will fetch chatboxes in browserStorage, but
+ // because there aren't any open chatboxes, there won't be any
+ // in browserStorage either. XXX except for the controlbox
+ newchatboxes.onConnected();
+ await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+ expect(newchatboxes.length).toEqual(1);
+ expect(newchatboxes.models[0].id).toBe("controlbox");
+ }));
+
+ describe("A chat toolbar", function () {
+
+ it("shows the remaining character count if a message_limit is configured",
+ mock.initConverse(['chatBoxesFetched'], {'message_limit': 200}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const toolbar = view.querySelector('.chat-toolbar');
+ const counter = toolbar.querySelector('.message-limit');
+ expect(counter.textContent).toBe('200');
+ view.getMessageForm().insertIntoTextArea('hello world');
+ await u.waitUntil(() => counter.textContent === '188');
+
+ toolbar.querySelector('.toggle-emojis').click();
+ const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));
+ const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
+ item.click()
+ await u.waitUntil(() => counter.textContent === '179');
+
+ const textarea = view.querySelector('.chat-textarea');
+ const ev = {
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ };
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(ev);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ message_form.onKeyUp(ev);
+ expect(counter.textContent).toBe('200');
+
+ textarea.value = 'hello world';
+ message_form.onKeyUp(ev);
+ await u.waitUntil(() => counter.textContent === '189');
+ }));
+
+
+ it("does not show a remaining character count if message_limit is zero",
+ mock.initConverse(['chatBoxesFetched'], {'message_limit': 0}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const counter = view.querySelector('.chat-toolbar .message-limit');
+ expect(counter).toBe(null);
+ }));
+
+
+ it("can contain a button for starting a call",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ let toolbar, call_button;
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(_converse.api, "trigger").and.callThrough();
+ // First check that the button doesn't show if it's not enabled
+ // via "visible_toolbar_buttons"
+
+ let buttons = api.settings.get('visible_toolbar_buttons');
+ api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': false}));
+
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let view = _converse.chatboxviews.get(contact_jid);
+ toolbar = view.querySelector('.chat-toolbar');
+ call_button = toolbar.querySelector('.toggle-call');
+ expect(call_button === null).toBeTruthy();
+ view.close();
+ // Now check that it's shown if enabled and that it emits
+ // callButtonClicked
+ buttons = api.settings.get('visible_toolbar_buttons');
+ api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': true}));
+
+ await mock.openChatBoxFor(_converse, contact_jid);
+ view = _converse.chatboxviews.get(contact_jid);
+ toolbar = view.querySelector('.chat-toolbar');
+ call_button = toolbar.querySelector('.toggle-call');
+ call_button.click();
+ expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object));
+ }));
+ });
+
+ describe("A Chat Status Notification", function () {
+
+ it("does not open a new chatbox", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ // <composing> state
+ const stanza = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.api.trigger.calls.count());
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ expect(_converse.chatboxviews.keys().length).toBe(1);
+ }));
+
+ describe("An active notification", function () {
+
+ it("is sent when the user opens a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ spyOn(_converse.connection, 'send');
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const model = _converse.chatboxes.get(contact_jid);
+ expect(model.get('chat_state')).toBe('active');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('active');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+ }));
+
+ it("is sent when the user maximizes a minimized a chat box", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const model = _converse.chatboxes.get(contact_jid);
+ _converse.minimize.minimize(model);
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ sent_stanzas.splice(0, sent_stanzas.length);
+ expect(model.get('chat_state')).toBe('inactive');
+ _converse.minimize.maximize(model);
+ await u.waitUntil(() => model.get('chat_state') === 'active', 1000);
+ const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`active`, s).length).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+ }));
+ });
+
+ describe("A composing notification", function () {
+
+ it("is sent as soon as the user starts typing a message which is not a command",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.connection.send).toHaveBeenCalled();
+
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('composing');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+
+ // The notification is not sent again
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.api.trigger.calls.count(), 1);
+ }));
+
+ it("is NOT sent out if send_chat_state_notifications doesn't allow it",
+ mock.initConverse(['chatBoxesFetched'], {'send_chat_state_notifications': []},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.connection.send).not.toHaveBeenCalled();
+ }));
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // <composing> state
+ let msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const view = _converse.chatboxviews.get(sender_jid);
+ let csn = mock.cur_names[1] + ' is typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+ expect(view.model.messages.length).toEqual(0);
+
+ // <paused> state
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ csn = mock.cur_names[1] + ' has stopped typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello world').tree();
+ await _converse.handleMessageStanza(msg);
+ const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg'));
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === '');
+ expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
+ }));
+
+ it("is ignored if it's a composing carbon message sent by this user from a different client",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.waitForRoster(_converse, 'current');
+ // Send a message from a different resource
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, recipient_jid);
+
+ spyOn(u, 'shouldCreateMessage').and.callThrough();
+
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+ expect(view.model.messages.length).toEqual(0);
+ const el = view.querySelector('.chat-content__notifications');
+ expect(el.textContent).toBe('');
+ }));
+ });
+
+ describe("A paused notification", function () {
+
+ it("is sent if the user has stopped typing since 30 seconds",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ _converse.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'setChatState').and.callThrough();
+ expect(view.model.get('chat_state')).toBe('active');
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+
+ const xmlns = 'https://jabber.org/protocol/chatstates';
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`composing`, s).length).pop(), 1000);
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'paused', 500);
+
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${xmlns}"]`, s)).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+
+ // Test #359. A paused notification should not be sent
+ // out if the user simply types longer than the
+ // timeout.
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.setChatState).toHaveBeenCalled();
+ expect(view.model.get('chat_state')).toBe('composing');
+
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ }));
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ // TODO: only show paused state if the previous state was composing
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ // <paused> state
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const csn = mock.cur_names[1] + ' has stopped typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+ expect(view.model.messages.length).toEqual(0);
+ }));
+
+ it("will not be shown if it's a paused carbon message that this user sent from a different client",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.waitForRoster(_converse, 'current');
+ // Send a message from a different resource
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(u, 'shouldCreateMessage').and.callThrough();
+ const view = await mock.openChatBoxFor(_converse, recipient_jid);
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+ expect(view.model.messages.length).toEqual(0);
+ const el = view.querySelector('.chat-content__notifications');
+ expect(el.textContent).toBe('');
+ }));
+ });
+
+ describe("An inactive notification", function () {
+
+ it("is sent if the user has stopped typing since 2 minutes",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ // Make the timeouts shorter so that we can test
+ _converse.TIMEOUTS.PAUSED = 100;
+ _converse.TIMEOUTS.INACTIVE = 100;
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 1000);
+
+ sent_stanzas.splice(0, sent_stanzas.length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'active');
+ expect(view.model.get('chat_state')).toBe('active');
+
+ const messages = sent_stanzas.filter(s => s.matches('message'));
+ expect(Strophe.serialize(messages[0])).toBe(
+ `<message id="${messages[0].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ await u.waitUntil(() => view.model.get('chat_state') === 'composing', 600);
+ let stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message composing')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'paused', 600);
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message paused')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'inactive', 600);
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message inactive')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ }));
+
+ it("is sent when the user a minimizes a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.connection, 'send');
+ _converse.minimize.minimize(view.model);
+ expect(view.model.get('chat_state')).toBe('inactive');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ var stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes[0].tagName).toBe('inactive');
+ }));
+
+ it("is sent if the user closes a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ view.close();
+ expect(view.model.get('chat_state')).toBe('inactive');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('inactive');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+ }));
+
+ it("will clear any other chat status notifications",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view.querySelectorAll('.chat-event').length).toBe(0);
+ // Insert <composing> message, to also check that
+ // text messages are inserted correctly with
+ // temporary chat events in the chat contents.
+ let msg = $msg({
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+ expect(view.model.messages.length).toBe(0);
+
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+ }));
+ });
+
+ describe("A gone notification", function () {
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
+ }));
+ });
+
+ describe("On receiving a message correction", function () {
+
+ it("will be removed", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // Original message
+ const original_id = u.getUniqueId();
+ const original = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: original_id,
+ body: "Original message",
+ }).c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(original));
+ await u.waitUntil(() => _converse.api.trigger.calls.count());
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view).toBeDefined();
+
+ // <composing> state
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+
+ // Edited message
+ const edited = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId(),
+ body: "Edited message",
+ })
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
+
+ await _converse.handleMessageStanza(edited);
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+ }));
+ });
+ });
+ });
+
+ describe("Special Messages", function () {
+
+ it("'/clear' can be used to clear messages in a conversation",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ let message = 'This message is another sent from this chatbox';
+ await mock.sendMessage(view, message);
+
+ expect(view.model.messages.length === 1).toBeTruthy();
+ const stored_messages = await view.model.messages.browserStorage.findAll();
+ expect(stored_messages.length).toBe(1);
+ await u.waitUntil(() => view.querySelector('.chat-msg'));
+
+ message = '/clear';
+ const message_form = view.querySelector('converse-message-form');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ view.querySelector('.chat-textarea').value = message;
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+ expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?');
+ await u.waitUntil(() => view.model.messages.length === 0);
+ await u.waitUntil(() => !view.querySelectorAll('.chat-msg__body').length);
+ }));
+ });
+
+
+ describe("A RosterView's Unread Message Count", function () {
+
+ it("is updated when message is received and chatbox is scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ let msg, indicator_el;
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('1');
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length > 1);
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('2');
+ }));
+
+ it("is updated when message is received and chatbox is minimized",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ let indicator_el, msg;
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.minimize.minimize(chatbox);
+
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('1');
+
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length === 2);
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('2');
+ }));
+
+ it("is cleared when chatbox is maximzied after receiving messages in minimized mode",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ _converse.minimize.minimize(view.model);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => chatbox.messages.length > 1);
+ expect(select_msgs_indicator().textContent).toBe('2');
+ _converse.minimize.maximize(view.model);
+ u.waitUntil(() => typeof select_msgs_indicator() === 'undefined');
+ }));
+
+ it("is cleared when unread messages are viewed which were received in scrolled-up chatbox",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`;
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ chatbox.ui.set('scrolled', true);
+ _converse.handleMessageStanza(msgFactory());
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
+ chat_new_msgs_indicator.click();
+ await u.waitUntil(() => select_msgs_indicator() === undefined);
+ }));
+
+ it("is not cleared after user clicks on roster view when chatbox is already opened and scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ const msg = 'This message will be received as unread, but eventually will be read';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, msg);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ chatbox.ui.set('scrolled', true);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => view.model.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js
new file mode 100644
index 0000000..b6f395e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js
@@ -0,0 +1,354 @@
+/*global mock, converse */
+
+const { Promise, $msg, Strophe, sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("can be sent as a correction by using the up arrow",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ expect(textarea.value).toBe('');
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+
+ spyOn(_converse.connection, 'send');
+ let new_text = 'But soft, what light through yonder window breaks?';
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text);
+
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const msg = _converse.connection.send.calls.all()[0].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>But soft, what light through yonder window breaks?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ expect(view.model.messages.models.length).toBe(1);
+ const corrected_message = view.model.messages.at(0);
+ expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+ expect(corrected_message.get('correcting')).toBe(false);
+
+ const older_versions = corrected_message.get('older_versions');
+ const keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ // Test that pressing the down arrow cancels message correction
+ await u.waitUntil(() => textarea.value === '')
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 40 // Down arrow
+ });
+ expect(textarea.value).toBe('');
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ new_text = 'It is the east, and Juliet is the one.';
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+
+ textarea.value = 'Arise, fair sun, and kill the envious moon';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+ await u.waitUntil(() => view.model.messages.at(2).get('correcting') === true);
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+ await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view).pop()), 750);
+
+ textarea.selectionEnd = 0; // Happens by pressing up,
+ // but for some reason not in tests, so we set it manually.
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('It is the east, and Juliet is the one.');
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBe(true);
+ expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+ await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500);
+
+ textarea.value = 'It is the east, and Juliet is the sun.';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => textarea.value === '');
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).filter(
+ m => m.textContent === 'It is the east, and Juliet is the sun.').length);
+
+ const messages = view.querySelectorAll('.chat-msg');
+ expect(messages.length).toBe(3);
+ expect(messages[0].querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder window breaks?');
+ expect(messages[1].querySelector('.chat-msg__text').textContent)
+ .toBe('It is the east, and Juliet is the sun.');
+ expect(messages[2].querySelector('.chat-msg__text').textContent)
+ .toBe('Arise, fair sun, and kill the envious moon');
+
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+ }));
+
+
+ it("can be sent as a correction by clicking the pencil icon",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+ await u.waitUntil(() => textarea.value === '');
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
+ let action = view.querySelector('.chat-msg .chat-msg__action');
+ expect(action.textContent.trim()).toBe('Edit');
+
+ action.style.opacity = 1;
+ action.click();
+
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
+
+ spyOn(_converse.connection, 'send');
+ const text = 'But soft, what light through yonder window breaks?';
+ textarea.value = text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === text);
+ expect(_converse.connection.send).toHaveBeenCalled();
+
+ const msg = _converse.connection.send.calls.all()[0].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>But soft, what light through yonder window breaks?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ expect(view.model.messages.models.length).toBe(1);
+ const corrected_message = view.model.messages.at(0);
+ expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+ expect(corrected_message.get('correcting')).toBe(false);
+
+ const older_versions = corrected_message.get('older_versions');
+ const keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ // Test that clicking the pencil icon a second time cancels editing.
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === true);
+
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(textarea.value).toBe('');
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ // Test that messages from other users don't have the pencil icon
+ _converse.handleMessageStanza(
+ $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t('Hello').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+
+ // Test confirmation dialog
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+
+ await u.waitUntil(() => _converse.api.confirm.calls.count());
+ expect(_converse.api.confirm).toHaveBeenCalledWith(
+ 'You have an unsent message which will be lost if you continue. Are you sure?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?'
+ action.click();
+
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 2);
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(_converse.api.confirm.calls.argsFor(0)).toEqual(
+ ['You have an unsent message which will be lost if you continue. Are you sure?']);
+ expect(_converse.api.confirm.calls.argsFor(1)).toEqual(
+ ['You have an unsent message which will be lost if you continue. Are you sure?']);
+ }));
+
+
+ describe("when received from someone else", function () {
+
+ it("can be replaced with a correction",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder chimney breaks?');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ expect(view.model.messages.models.length).toBe(1);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder window breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder window breaks?');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ view.querySelector('.chat-msg__content .fa-edit').click();
+
+ const modal = _converse.api.modal.get('converse-message-versions-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const older_msgs = modal.querySelectorAll('.older-msg');
+ expect(older_msgs.length).toBe(2);
+ expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
+ expect(view.model.messages.models.length).toBe(1);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js
new file mode 100644
index 0000000..88d3ecd
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js
@@ -0,0 +1,210 @@
+/*global mock, converse */
+
+const { Promise, $msg } = converse.env;
+const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("Emojis", function () {
+ describe("The emoji picker", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("can be opened by clicking a button in the chat toolbar",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
+ toolbar.querySelector('.toggle-emojis').click();
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+ const item = view.querySelector('.emoji-picker li.insert-emoji a');
+ item.click()
+ expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+ toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
+ }));
+ });
+
+ describe("A Chat Message", function () {
+
+ it("will display larger if it's only emojis",
+ mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇 Hello world! 😇 😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+ let sel = '.message:last-child .chat-msg__text';
+ await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
+
+ // Test that a modified message that no longer contains only
+ // emojis now renders normally again.
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':poop: :innocent:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+ await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
+
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('💩 😇');
+ expect(view.model.messages.at(2).get('correcting')).toBe(true);
+ sel = 'converse-chat-message:last-child .chat-msg'
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
+ const edited_text = textarea.value += 'This is no longer an emoji-only message';
+ textarea.value = edited_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(el => el.textContent === edited_text).length);
+ expect(view.model.messages.models.length).toBe(3);
+ let message = view.querySelector(last_msg_sel);
+ expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+
+ textarea.value = ':smile: Hello world!';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+
+ textarea.value = ':smile: :smiley: :imp:';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+
+ message = view.querySelector('.message:last-child .chat-msg__text');
+ expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+ }));
+
+ it("can render emojis as images",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'use_system_emojis': false},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(contact_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
+ '<img class="emoji" loading="lazy" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
+
+ const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+ let message = view.querySelector(last_msg_sel);
+ await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+ let imgs = message.querySelectorAll('.emoji');
+ expect(imgs.length).toBe(1);
+ expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':poop: :innocent:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ message = view.querySelector(last_msg_sel);
+ await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+ imgs = message.querySelectorAll('.emoji');
+ expect(imgs.length).toBe(2);
+ expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
+ expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
+ expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
+ }));
+
+ it("can show custom emojis",
+ mock.initConverse(
+ ['chatBoxesFetched'],
+ { emoji_categories: {
+ "smileys": ":grinning:",
+ "people": ":thumbsup:",
+ "activity": ":soccer:",
+ "travel": ":motorcycle:",
+ "objects": ":bomb:",
+ "nature": ":rainbow:",
+ "food": ":hotdog:",
+ "symbols": ":musical_note:",
+ "flags": ":flag_ac:",
+ "custom": ':xmpp:'
+ } },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ toolbar.querySelector('.toggle-emojis').click();
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+ const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000);
+ const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
+ expect(custom_category.innerHTML.replace(/<!-.*?->/g, '').trim()).toBe(
+ '<img class="emoji" loading="lazy" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = 'Running tests for :converse:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ const body = view.querySelector('converse-chat-message-body');
+ await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
+ 'Running tests for <img class="emoji" loading="lazy" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js
new file mode 100644
index 0000000..8bbd8f2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js
@@ -0,0 +1,477 @@
+/*global mock, converse */
+
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const u = converse.env.utils;
+
+describe("XEP-0363: HTTP File Upload", function () {
+
+ describe("Discovering support", function () {
+
+ it("is done automatically", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+ let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000);
+
+ /* <iq type='result'
+ * from='plays.shakespeare.lit'
+ * to='romeo@montague.net/orchard'
+ * id='info1'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'>
+ * <identity
+ * category='server'
+ * type='im'/>
+ * <feature var='http://jabber.org/protocol/disco#info'/>
+ * <feature var='http://jabber.org/protocol/disco#items'/>
+ * </query>
+ * </iq>
+ */
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': stanza.getAttribute('id'),
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'server',
+ 'type': 'im'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#info'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#items'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // Converse.js sees that the entity has a disco#items feature,
+ // so it will make a query for it.
+ selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000);
+ /* <iq from='montague.tld'
+ * id='step_01'
+ * to='romeo@montague.tld/garden'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#items'>
+ * <item jid='upload.montague.tld' name='HTTP File Upload' />
+ * <item jid='conference.montague.tld' name='Chatroom Service' />
+ * </query>
+ * </iq>
+ */
+ selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+ stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500);
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': stanza.getAttribute('id'),
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+ .c('item', {
+ 'jid': 'upload.montague.lit',
+ 'name': 'HTTP File Upload'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let entities = await api.disco.entities.get();
+ expect(entities.length).toBe(3);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+
+ expect(entities.get(_converse.domain).features.length).toBe(2);
+ expect(entities.get(_converse.domain).identities.length).toBe(1);
+
+ api.disco.entities.get().then(entities => {
+ expect(entities.length).toBe(3);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+ expect(api.disco.entities.items('montague.lit').length).toBe(1);
+ // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
+ const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0);
+ });
+
+ selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000);
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="`+stanza.getAttribute('id')+`" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ // Upload service responds and reports a maximum file size of 5MiB
+ /* <iq from='upload.montague.tld'
+ * id='step_02'
+ * to='romeo@montague.tld/garden'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'>
+ * <identity category='store'
+ * type='file'
+ * name='HTTP File Upload' />
+ * <feature var='urn:xmpp:http:upload:0' />
+ * <x type='result' xmlns='jabber:x:data'>
+ * <field var='FORM_TYPE' type='hidden'>
+ * <value>urn:xmpp:http:upload:0</value>
+ * </field>
+ * <field var='max-file-size'>
+ * <value>5242880</value>
+ * </field>
+ * </x>
+ * </query>
+ * </iq>
+ */
+ stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'})
+ .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+ .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+ .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('urn:xmpp:http:upload:0').up().up()
+ .c('field', {'var':'max-file-size'})
+ .c('value').t('5242880');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ entities = await _converse.api.disco.entities.get();
+ const entity = await api.disco.entities.get('upload.montague.lit');
+ expect(entity.get('parent_jids')).toEqual(['montague.lit']);
+ expect(entity.identities.where({'category': 'store'}).length).toBe(1);
+ const supported = await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ expect(supported).toBe(true);
+ const features = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ expect(features.length).toBe(1);
+ expect(features[0].get('jid')).toBe('upload.montague.lit');
+ expect(features[0].dataforms.where({'FORM_TYPE': {value: "urn:xmpp:http:upload:0", type: "hidden"}}).length).toBe(1);
+ }));
+ });
+
+ describe("When not supported", function () {
+ describe("A file upload toolbar button", function () {
+
+ it("does not appear in private chats",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.querySelector('.chat-toolbar .fileupload')).toBe(null);
+ }));
+ });
+ });
+
+ describe("When supported", function () {
+
+ describe("A file upload toolbar button", function () {
+
+ it("appears in private chats", mock.initConverse([], {}, async (_converse) => {
+ await mock.waitForRoster(_converse, 'current', 3);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items')
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+
+ const el = await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload'));
+ expect(el).not.toEqual(null);
+ }));
+
+ describe("when clicked and a file chosen", function () {
+
+ it("is uploaded and sent out", mock.initConverse(['chatBoxesFetched'], {} ,async (_converse) => {
+ const base_url = 'https://conversejs.org';
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const send_backup = XMLHttpRequest.prototype.send;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '23456' ,
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+ const iq = IQ_stanzas.pop();
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" `+
+ `id="${iq.getAttribute("id")}" `+
+ `to="upload.montague.tld" `+
+ `type="get" `+
+ `xmlns="jabber:client">`+
+ `<request `+
+ `content-type="image/jpeg" `+
+ `filename="my-juliet.jpg" `+
+ `size="23456" `+
+ `xmlns="urn:xmpp:http:upload:0"/>`+
+ `</iq>`);
+
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const stanza = u.toStanza(`
+ <iq from="upload.montague.tld"
+ id="${iq.getAttribute("id")}"
+ to="romeo@montague.lit/orchard"
+ type="result">
+ <slot xmlns="urn:xmpp:http:upload:0">
+ <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+ <header name="Authorization">Basic Base64String==</header>
+ <header name="Cookie">foo=bar; user=romeo</header>
+ </put>
+ <get url="${message}" />
+ </slot>
+ </iq>`);
+
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
+ const message = view.model.messages.at(0);
+ const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+ expect(el.getAttribute('value')).toBe('0');
+ message.set('progress', 0.5);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1')
+ message.save({
+ 'upload': _converse.SUCCESS,
+ 'oob_url': message.get('get'),
+ 'body': message.get('get'),
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ });
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => sent_stanza, 1000);
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lady.montague@montague.lit" `+
+ `type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>${message}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<x xmlns="jabber:x:oob">`+
+ `<url>${message}</url>`+
+ `</x>`+
+ `<origin-id id="${sent_stanza.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ const img_link_el = await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image__link'), 1000);
+ // Check that the image renders
+ expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`);
+ XMLHttpRequest.prototype.send = send_backup;
+ }));
+
+ it("shows an error message if the file is too large",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const IQ_ids = _converse.connection.IQ_ids;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+ await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).length
+ );
+
+ let stanza = IQ_stanzas.find((iq) =>
+ iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+
+ const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': info_IQ_id
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'server',
+ 'type': 'im'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#info'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#items'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(function () {
+ // Converse.js sees that the entity has a disco#items feature,
+ // so it will make a query for it.
+ return IQ_stanzas.filter(function (iq) {
+ return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+ }).length > 0;
+ }, 300);
+
+ stanza = IQ_stanzas.find(function (iq) {
+ return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+ });
+ const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': items_IQ_id
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+ .c('item', {
+ 'jid': 'upload.montague.lit',
+ 'name': 'HTTP File Upload'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let entities = await _converse.api.disco.entities.get();
+
+ expect(entities.length).toBe(3);
+ expect(entities.get(_converse.domain).features.length).toBe(2);
+ expect(entities.get(_converse.domain).identities.length).toBe(1);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+ expect(api.disco.entities.items('montague.lit').length).toBe(1);
+ await u.waitUntil(function () {
+ // Converse.js sees that the entity has a disco#info feature,
+ // so it will make a query for it.
+ return IQ_stanzas.filter(iq =>
+ iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')
+ ).length > 0;
+ }, 300);
+
+ stanza = IQ_stanzas.find(iq => iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+ const IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ // Upload service responds and reports a maximum file size of 5MiB
+ stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'})
+ .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+ .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+ .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('urn:xmpp:http:upload:0').up().up()
+ .c('field', {'var':'max-file-size'})
+ .c('value').t('5242880');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ entities = await _converse.api.disco.entities.get();
+ const entity = await api.disco.entities.get('upload.montague.lit');
+ expect(entity.get('parent_jids')).toEqual(['montague.lit']);
+ expect(entity.identities.where({'category': 'store'}).length).toBe(1);
+ await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ await mock.waitForRoster(_converse, 'current');
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '5242881',
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+ await u.waitUntil(() => view.querySelectorAll('.message').length)
+ const messages = view.querySelectorAll('.message.chat-error');
+ expect(messages.length).toBe(1);
+ expect(messages[0].textContent.trim()).toBe(
+ 'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5.24 MB.');
+ }));
+ });
+ });
+
+ describe("While a file is being uploaded", function () {
+
+ it("shows a progress bar", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '23456' ,
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
+ const iq = IQ_stanzas.pop();
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" `+
+ `id="${iq.getAttribute("id")}" `+
+ `to="upload.montague.tld" `+
+ `type="get" `+
+ `xmlns="jabber:client">`+
+ `<request `+
+ `content-type="image/jpeg" `+
+ `filename="my-juliet.jpg" `+
+ `size="23456" `+
+ `xmlns="urn:xmpp:http:upload:0"/>`+
+ `</iq>`);
+
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+ const stanza = u.toStanza(`
+ <iq from="upload.montague.tld"
+ id="${iq.getAttribute("id")}"
+ to="romeo@montague.lit/orchard"
+ type="result">
+ <slot xmlns="urn:xmpp:http:upload:0">
+ <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+ <header name="Authorization">Basic Base64String==</header>
+ <header name="Cookie">foo=bar; user=romeo</header>
+ </put>
+ <get url="${message}" />
+ </slot>
+ </iq>`);
+
+ const promise = u.getOpenPromise();
+
+ spyOn(XMLHttpRequest.prototype, 'setRequestHeader');
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
+ const message = view.model.messages.at(0);
+ const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+ expect(el.getAttribute('value')).toBe('0');
+ message.set('progress', 0.5);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5');
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1');
+ expect(view.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 23.46 kB');
+ promise.resolve();
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await promise;
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.count()).toBe(2);
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[0]).toBe('Content-type');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[1]).toBe('image/jpeg');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[0]).toBe('Authorization');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[1]).toBe('Basic Base64String==');
+ }));
+ });
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js
new file mode 100644
index 0000000..5b61b84
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js
@@ -0,0 +1,114 @@
+/*global mock, converse */
+
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("A XEP-0333 Chat Marker", function () {
+
+ it("is sent when a markable message is received from a roster contact",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const msgid = u.getUniqueId();
+ const stanza = u.toStanza(`
+ <message from='${contact_jid}'
+ id='${msgid}'
+ type="chat"
+ to='${_converse.jid}'>
+ <body>My lord, dispatch; read o'er these articles.</body>
+ <markable xmlns='urn:xmpp:chat-markers:0'/>
+ </message>`);
+
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => sent_stanzas.length === 2);
+ expect(Strophe.serialize(sent_stanzas[0])).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanzas[0].getAttribute('id')}" `+
+ `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
+ `</message>`);
+ }));
+
+ it("is not sent when a markable message is received from someone not on the roster",
+ mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const contact_jid = 'someone@montague.lit';
+ const msgid = u.getUniqueId();
+ const stanza = u.toStanza(`
+ <message from='${contact_jid}'
+ id='${msgid}'
+ type="chat"
+ to='${_converse.jid}'>
+ <body>My lord, dispatch; read o'er these articles.</body>
+ <markable xmlns='urn:xmpp:chat-markers:0'/>
+ </message>`);
+
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
+ await _converse.handleMessageStanza(stanza);
+ const sent_messages = sent_stanzas
+ .map(s => s?.nodeTree ?? s)
+ .filter(e => e.nodeName === 'message');
+
+ await u.waitUntil(() => sent_messages.length === 2);
+ expect(Strophe.serialize(sent_messages[0])).toBe(
+ `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+ }));
+
+ it("is ignored if it's a carbon copy of one that I sent from a different client",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.bare_jid}"
+ type="chat"
+ id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+ from="${contact_jid}">
+ <body>😊</body>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
+ <sent xmlns="urn:xmpp:carbons:2">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
+ <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <store xmlns="urn:xmpp:hints"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
+ </message>
+ </forwarded>
+ </sent>
+ </message>`);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js
new file mode 100644
index 0000000..aed3bf9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js
@@ -0,0 +1,56 @@
+/*global mock, converse */
+
+const { u, sizzle, $msg } = converse.env;
+
+describe("A Message", function () {
+
+ it("supports the /me command", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.openControlBox(_converse);
+ expect(_converse.chatboxes.length).toEqual(1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let message = '/me is tired';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ await _converse.handleMessageStanza(msg);
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(1);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
+ expect(view.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
+
+ message = '/me is as well';
+ await mock.sendMessage(view, message);
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(2);
+ await u.waitUntil(() => sizzle('.chat-msg__author:last', view).pop().textContent.trim() === '**Romeo');
+ const last_el = sizzle('.chat-msg__text:last', view).pop();
+ await u.waitUntil(() => last_el.textContent === 'is as well');
+ expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
+
+ // Check that /me messages after a normal message don't
+ // get the 'chat-msg--followup' class.
+ message = 'This a normal message';
+ await mock.sendMessage(view, message);
+ const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__text';
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message);
+ let el = view.querySelector('converse-chat-message:last-child .chat-msg__body');
+ expect(u.hasClass('chat-msg--followup', el)).toBeFalsy();
+
+ message = '/me wrote a 3rd person message';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message.replace('/me ', ''));
+ el = view.querySelector('converse-chat-message:last-child .chat-msg__body');
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(3);
+
+ expect(sizzle('.chat-msg__text:last', view).pop().textContent).toBe('wrote a 3rd person message');
+ expect(u.isVisible(sizzle('.chat-msg__author:last', view).pop())).toBeTruthy();
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js
new file mode 100644
index 0000000..2d4dbf3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js
@@ -0,0 +1,24 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render audio files from their URLs",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/audio.mp3";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000)
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+ `<audio controls="" src="${message}"></audio>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js
new file mode 100644
index 0000000..5552dc5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js
@@ -0,0 +1,23 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render gifs from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const gif_url = 'https://media.giphy.com/media/Byana3FscAMGQ/giphy.gif';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, gif_url);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content canvas').length);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ const html = `<converse-gif autoplay="" noloop="" fallback="empty" src="${gif_url}">`+
+ `<canvas class="gif-canvas"><img class="gif" src="${gif_url}"></canvas></converse-gif>`+
+ `<a target="_blank" rel="noopener" href="${gif_url}">${gif_url}</a>`;
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === html, 1000);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js
new file mode 100644
index 0000000..517fd12
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js
@@ -0,0 +1,239 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render images from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ let message = base_url+"/logo/conversejs-filled.svg";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000)
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="https://conversejs.org/logo/conversejs-filled.svg">`+
+ `</a>`);
+
+ message += "?param1=val1&param2=val2";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&amp;param2=val2">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="${message.replace(/&/g, '&amp;')}">`+
+ `</a>`);
+
+ // Test now with two images in one message
+ message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 4, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.textContent.trim()).toEqual('hello world');
+ expect(msg.querySelectorAll('img.chat-image').length).toEqual(2);
+
+ // Configured image URLs are rendered
+ _converse.api.settings.set('image_urls_regex', /^https?:\/\/(?:www.)?(?:imgur\.com\/\w{7})\/?$/i);
+ message = 'https://imgur.com/oxymPax';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 5, 1000);
+ expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(5);
+
+ // Check that the Imgur URL gets a .png attached to make it render
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-content .chat-image')).pop().src.endsWith('png'), 1000);
+ }));
+
+ it("will not render images if render_media is false",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+ expect(true).toBe(true);
+ }));
+
+ it("will automatically render images from approved URLs only",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': ['imgur.com']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ let message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ message = base_url+"/logo/conversejs-filled.svg";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 2, 1000);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 1, 1000)
+ expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(1);
+ }));
+
+ it("will automatically update its rendering of media and the message actions when settings change",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': ['imgur.com']},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ api.settings.set('render_media', false);
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ api.settings.set('render_media', ['imgur.com']);
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body img'));
+
+ api.settings.set('render_media', ['conversejs.org']);
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ api.settings.set('allowed_image_domains', ['conversejs.org']);
+ await u.waitUntil(() => !actions_el.textContent.includes('Show media'));
+ expect(actions_el.textContent.includes('Hide media')).toBe(false);
+
+ api.settings.set('render_media', ['imgur.com']);
+ return new Promise(resolve => setTimeout(() => {
+ expect(actions_el.textContent.includes('Hide media')).toBe(false);
+ expect(actions_el.textContent.includes('Show media')).toBe(false);
+ expect(view.querySelector('converse-chat-message-body img')).toBe(null);
+ resolve();
+ }, 500));
+ }));
+
+
+ it("will fall back to rendering images as URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/non-existing.svg";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000)
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() ==
+ `<a target="_blank" rel="noopener" href="https://conversejs.org/logo/non-existing.svg">https://conversejs.org/logo/non-existing.svg</a>`, 1000);
+ }));
+
+ it("will fall back to rendering URLs that match image_urls_regex as URLs",
+ mock.initConverse(
+ ['rosterGroupsFetched', 'chatBoxesFetched'], {
+ 'render_media': true,
+ 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i
+ },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const message = "https://pbs.twimg.com/media/string?format=jpg&name=small";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ await u.waitUntil(() => view.querySelector('.chat-content .chat-msg'), 1000);
+ const msg = view.querySelector('.chat-content .chat-msg .chat-msg__text');
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() ==
+ `<a target="_blank" rel="noopener" href="https://pbs.twimg.com/media/string?format=jpg&amp;name=small">https://pbs.twimg.com/media/string?format=jpg&amp;name=small</a>`, 1000);
+ }));
+
+ it("will respect a changed allowed_image_domains setting when re-rendered",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': true},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1);
+ expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
+
+ api.settings.set('allowed_image_domains', []);
+
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null);
+ expect(view.querySelector('.chat-msg__action-hide-previews')).toBe(null);
+
+ api.settings.set('allowed_image_domains', null);
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image'));
+ expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
+ }));
+
+ it("will allow the user to toggle visibility of rendered images",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body img'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+ .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js
new file mode 100644
index 0000000..dfa388e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js
@@ -0,0 +1,98 @@
+/*global mock, converse */
+
+const { Strophe, sizzle, u } = converse.env;
+
+describe("A chat message containing video URLs", function () {
+
+ it("will render videos from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ let message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${message}"></video>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+
+ message += "?param1=val1&param2=val2";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length === 2, 1000);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${Strophe.xmlescape(message)}"></video>`+
+ `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(message)}">${Strophe.xmlescape(message)}</a>`);
+ }));
+
+ it("will not render videos if render_media is false",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+ expect(true).toBe(true);
+ }));
+
+ it("will allow rendering of videos from approved URLs only",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ const base_url = 'https://conversejs.org';
+ message = base_url+"/logo/conversejs-filled.mp4";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${message}"></video>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+ }));
+
+ it("will allow the user to toggle visibility of rendered videos",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body video'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body video'));
+
+ expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+ .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js
new file mode 100644
index 0000000..777494c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js
@@ -0,0 +1,1331 @@
+/*global mock, converse */
+
+const { Promise, Strophe, $msg, dayjs, sizzle, u } = converse.env;
+
+
+describe("A Chat Message", function () {
+
+ it("will be demarcated if it's the first newly received message",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be read'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message .chat-msg__text')?.textContent === 'This message will be read');
+ expect(view.model.get('num_unread')).toBe(0);
+
+ _converse.windowState = 'hidden';
+ await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be new'));
+
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.get('num_unread')).toBe(1);
+ expect(view.model.get('first_unread_id')).toBe(view.model.messages.last().get('id'));
+
+ await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 2);
+ await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-msg__text')?.textContent === 'This message will be new');
+ const last_msg_el = view.querySelector('converse-chat-message:last-child');
+ expect(last_msg_el.firstElementChild?.textContent).toBe('New messages');
+ }));
+
+
+ it("is rejected if it's an unencapsulated forwarded message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 2);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let models = await _converse.api.chats.get();
+ expect(models.length).toBe(1);
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${contact_jid}' type='chat' id='${_converse.connection.getUniqueId()}'>
+ <body>A most courteous exposition!</body>
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
+ <message from='${forwarded_contact_jid}'
+ id='0202197'
+ to='${_converse.bare_jid}'
+ type='chat'
+ xmlns='jabber:client'>
+ <body>Yet I should kill thee with much cherishing.</body>
+ <mood xmlns='http://jabber.org/protocol/mood'>
+ <amorous/>
+ </mood>
+ </message>
+ </forwarded>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(received_stanza));
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('error')).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message id="${received_stanza.getAttribute('id')}" to="${contact_jid}" type="error" xmlns="jabber:client">`+
+ '<error type="cancel">'+
+ '<not-allowed xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>'+
+ '<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">'+
+ 'Forwarded messages not part of an encapsulating protocol are not supported</text>'+
+ '</error>'+
+ '</message>');
+ models = await _converse.api.chats.get();
+ expect(models.length).toBe(1);
+ }));
+
+ it("can be received out of order, and will still be displayed in the right order",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length)
+ api.settings.set('filter_by_resource', true);
+
+ let msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'})
+ .tree();
+ await _converse.handleMessageStanza(msg);
+ const view = _converse.chatboxviews.get(sender_jid);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("Older message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("Inbetween message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("another inbetween message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("An earlier message on the next day").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 5);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("newer message from the next day").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 6);
+
+ // Insert <composing> message, to also check that
+ // text messages are inserted correctly with
+ // temporary chat events in the chat contents.
+ msg = $msg({
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .tree();
+ _converse.handleMessageStanza(msg);
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual('Mercutio is typing');
+
+ msg = $msg({
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .c('body').t("latest message")
+ .tree();
+
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
+
+ expect(view.querySelectorAll('.date-separator').length).toEqual(4);
+
+ let day = sizzle('.date-separator:first', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2017-12-31T00:00:00').toISOString());
+
+ let time = sizzle('time:first', view).pop();
+ expect(time.textContent).toEqual('Sunday Dec 31st 2017')
+
+ day = sizzle('.date-separator:first', view).pop();
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Older message');
+
+ let el = sizzle('.chat-msg:first', view).pop().querySelector('.chat-msg__text')
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+ expect(el.textContent).toEqual('Older message');
+
+ time = sizzle('time.separator-text:eq(1)', view).pop();
+ expect(time.textContent).toEqual("Monday Jan 1st 2018");
+
+ day = sizzle('.date-separator:eq(1)', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-01T00:00:00').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Inbetween message');
+
+ el = sizzle('.chat-msg:eq(1)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
+ expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
+ el = sizzle('.chat-msg:eq(2)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent)
+ .toEqual('another inbetween message');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(true);
+
+ time = sizzle('time.separator-text:nth(2)', view).pop();
+ expect(time.textContent).toEqual("Tuesday Jan 2nd 2018");
+
+ day = sizzle('.date-separator:nth(2)', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-02T00:00:00').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('An earlier message on the next day');
+
+ el = sizzle('.chat-msg:eq(3)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('An earlier message on the next day');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+ el = sizzle('.chat-msg:eq(4)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
+ expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+ day = sizzle('.date-separator:last', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs().startOf('day').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('latest message');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+ }));
+
+ it("is ignored if it's a malformed headline message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // Ideally we wouldn't have to filter out headline
+ // messages, but Prosody gives them the wrong 'type' :(
+ spyOn(converse.env.log, 'info');
+ spyOn(_converse.api.chatboxes, 'get');
+ const msg = $msg({
+ from: 'montague.lit',
+ to: _converse.bare_jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t("This headline message will not be shown").tree();
+ await _converse.handleMessageStanza(msg);
+ expect(converse.env.log.info).toHaveBeenCalledWith(
+ "handleMessageStanza: Ignoring incoming server message from JID: montague.lit"
+ );
+ expect(_converse.api.chatboxes.get).not.toHaveBeenCalled();
+ }));
+
+ it("will render Openstreetmap-URL from geo-URI",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const message = "geo:37.786971,-122.399677";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/\<!-.*?-\>/g, '') ===
+ '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
+ 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
+ }));
+
+ it("can be a carbon message, as defined in XEP-0280",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ // Send a message from a different resource
+ const msgtext = 'This is a carbon message';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'to': _converse.bare_jid+'/another-resource',
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+
+ await _converse.handleMessageStanza(msg);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+ // Check that the message was received and check the message parameters
+ await u.waitUntil(() => chatbox.messages.length);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(msgtext);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('nickname')).toBe(null);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(msgtext);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+ }));
+
+ it("can be a carbon message that this user sent from a different client, as defined in XEP-0280",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // Send a message from a different resource
+ const msgtext = 'This is a sent carbon message';
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+
+ await _converse.handleMessageStanza(msg);
+ // Check that the chatbox and its view now exist
+ const chatbox = await _converse.api.chats.get(recipient_jid);
+ const view = _converse.chatboxviews.get(recipient_jid);
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+
+ // Check that the message was received and check the message parameters
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(msgtext);
+ expect(msg_obj.get('fullname')).toEqual(_converse.xmppstatus.get('fullname'));
+ expect(msg_obj.get('sender')).toEqual('me');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ const msg_el = await u.waitUntil(() => view.querySelector('.chat-content .chat-msg .chat-msg__text'));
+ expect(msg_el.textContent).toEqual(msgtext);
+ }));
+
+ it("will be discarded if it's a malicious message meant to look like a carbon copy",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ /* <message from="mallory@evil.example" to="b@xmpp.example">
+ * <received xmlns='urn:xmpp:carbons:2'>
+ * <forwarded xmlns='urn:xmpp:forward:0'>
+ * <message from="alice@xmpp.example" to="bob@xmpp.example/client1">
+ * <body>Please come to Creepy Valley tonight, alone!</body>
+ * </message>
+ * </forwarded>
+ * </received>
+ * </message>
+ */
+ const msgtext = 'Please come to Creepy Valley tonight, alone!';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const impersonated_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': impersonated_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+ await _converse.handleMessageStanza(msg);
+
+ // Check that chatbox for impersonated user is not created.
+ let chatbox = await _converse.api.chats.get(impersonated_jid);
+ expect(chatbox).toBe(null);
+
+ // Check that the chatbox for the malicous user is not created
+ chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox).toBe(null);
+ }));
+
+ it("will indicate when it has a time difference of more than a day between it and its predecessor",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const contact_name = mock.cur_names[1];
+ const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+
+ const one_day_ago = dayjs().subtract(1, 'day');
+ const chatbox = _converse.chatboxes.get(contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = 'This is a day old message';
+ let msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: one_day_ago.toDate().getTime()
+ }).c('body').t(message).up()
+ .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ expect(chatbox.messages.length).toEqual(1);
+ let msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('nickname')).toBe(null);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(true);
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+
+ expect(view.querySelectorAll('.date-separator').length).toEqual(1);
+ let day = view.querySelector('.date-separator');
+ expect(day.getAttribute('class')).toEqual('message date-separator');
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
+
+ let time = view.querySelector('time.separator-text');
+ expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
+
+ message = 'This is a current message';
+ msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: new Date().getTime()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that there is a <time> element, with the required props.
+ expect(view.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
+
+ const message_date = new Date();
+ day = sizzle('.date-separator:last', view);
+ expect(day.length).toEqual(1);
+ expect(day[0].getAttribute('class')).toEqual('message date-separator');
+ expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
+
+ time = sizzle('time.separator-text:last', view).pop();
+ expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
+
+ // Normal checks for the 2nd message
+ expect(chatbox.messages.length).toEqual(2);
+ msg_obj = chatbox.messages.models[1];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(message);
+
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+ }));
+
+ it("is sanitized to prevent Javascript injection attacks",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = '<p>This message contains <em>some</em> <b>markup</b></p>';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
+ }));
+
+ it("can contain hyperlinks, which will be clickable",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message contains a hyperlink: www.opkode.com';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+ }));
+
+ it("will remove url query parameters from hyperlinks as set",
+ mock.initConverse(['chatBoxesFetched'], {'filter_url_query_params': ['utm_medium', 'utm_content', 's']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This message contains a hyperlink with forbidden query params: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>');
+
+ // Test assigning a string to filter_url_query_params
+ _converse.api.settings.set('filter_url_query_params', 'utm_medium');
+ message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Another message with a hyperlink with forbidden query params: '+
+ '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1">https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1</a>');
+ }));
+
+ it("properly renders URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'https://mov.im/?node/pubsub.movim.eu/Dino/urn-uuid-979bd24f-0bf3-5099-9fa7-510b9ce9a884';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ const anchor = await u.waitUntil(() => msg.querySelector('a'));
+ expect(anchor.innerHTML.replace(/<!-.*?->/g, '')).toBe(message);
+ expect(anchor.getAttribute('href')).toBe(message);
+ }));
+
+ it("will render newlines", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard the news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard the news?');
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\n\n\nHave you heard the news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '');
+ expect(text).toBe('Hey\n\u200B\nHave you heard the news?');
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard\nthe news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard\nthe news?');
+
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard\n\n\nthe news?\nhttps://conversejs.org</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ await u.waitUntil(() => {
+ const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '');
+ return text === 'Hey\nHave you heard\n\u200B\nthe news?\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>';
+ });
+ }));
+
+ it("will render the message time as configured",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ api.settings.set('time_format', 'hh:mm');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message is sent from this chatbox';
+ await mock.sendMessage(view, message);
+
+ const chatbox = await _converse.api.chats.get(contact_jid);
+ expect(chatbox.messages.models.length, 1);
+ const msg_object = chatbox.messages.models[0];
+
+ const msg_author = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__author');
+ expect(msg_author.textContent.trim()).toBe('Romeo');
+
+ const msg_time = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
+ const time = dayjs(msg_object.get('time')).format(api.settings.get('time_format'));
+ expect(msg_time.textContent).toBe(time);
+ }));
+
+ it("will be correctly identified and rendered as a followup message",
+ mock.initConverse(
+ [], {'debounced_content_rendering': false},
+ async function (_converse) {
+
+ const { api } = _converse;
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const base_time = new Date();
+ const ONE_MINUTE_LATER = 60000;
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ api.settings.set('filter_by_resource', true);
+
+ jasmine.clock().install();
+ jasmine.clock().mockDate(base_time);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t('A message').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(3*ONE_MINUTE_LATER);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t("Another message 3 minutes later").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(11*ONE_MINUTE_LATER);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t("Another message 14 minutes since we started").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(1*ONE_MINUTE_LATER);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t("Another message 1 minute and 1 second since the previous one").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(1*ONE_MINUTE_LATER);
+ await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
+
+ await u.waitUntil(() => view.querySelectorAll('.message').length === 6);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(5);
+
+ const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ // Let's add a delayed, inbetween message
+ _converse.handleMessageStanza(
+ $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t("A delayed message, sent 5 minutes since we started").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
+ .tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.message').length).toBe(7);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(6);
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(true);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "A delayed message, sent 5 minutes since we started");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(false);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(true);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(false);
+ expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ _converse.handleMessageStanza(
+ $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': sender_jid,
+ 'from': _converse.bare_jid+"/some-other-resource",
+ 'type': 'chat'})
+ .c('body').t("A carbon message 4 minutes later").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
+ .tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(7);
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "A carbon message 4 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "A delayed message, sent 5 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(true);
+ expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(8)))).toBe(false);
+ expect(view.querySelector(`${nth_child(8)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ jasmine.clock().uninstall();
+
+ }));
+
+
+ describe("when sent", function () {
+
+ it("will appear inside the chatbox it was sent from",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message is sent from this chatbox';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ expect(view.model.messages.length, 2);
+ expect(sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop().textContent).toEqual(message);
+ }));
+
+
+ it("will be trimmed of leading and trailing whitespace",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = ' \nThis message is sent from this chatbox \n \n';
+ await mock.sendMessage(view, message);
+ expect(view.model.messages.at(0).get('message')).toEqual(message.trim());
+ const message_el = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(message_el.textContent).toEqual(message.trim());
+ }));
+ });
+
+
+ describe("when received from someone else", function () {
+
+ it("will open a chatbox and be displayed inside it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 1, include_nick);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const message = 'This is a received message';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ // We don't already have an open chatbox for this user
+ expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+ await _converse.handleMessageStanza(
+ $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ const chatbox = await _converse.chatboxes.get(sender_jid);
+ expect(chatbox).toBeDefined();
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view).toBeDefined();
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that the message was received and check the message parameters
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+ expect(mel.textContent).toEqual(message);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]);
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ }));
+
+ it("will be trimmed of leading and trailing whitespace",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1, false);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300);
+ const message = '\n\n This is a received message \n\n';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await _converse.handleMessageStanza(
+ $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.length).toEqual(1);
+ const msg_obj = view.model.messages.at(0);
+ expect(msg_obj.get('message')).toEqual(message.trim());
+ const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+ expect(mel.textContent).toEqual(message.trim());
+ }));
+
+
+ describe("when a chatbox is opened for someone who is not in the roster", function () {
+
+ it("the VCard for that user is fetched and the chatbox updated with the results",
+ mock.initConverse([], {'allow_non_roster_messaging': true},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ var vcard_fetched = false;
+ spyOn(_converse.api.vcard, "get").and.callFake(function () {
+ vcard_fetched = true;
+ return Promise.resolve({
+ 'fullname': mock.cur_names[0],
+ 'vcard_updated': (new Date()).toISOString(),
+ 'jid': sender_jid
+ });
+ });
+ const message = 'This is a received message from someone not on the roster';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ // We don't already have an open chatbox for this user
+ expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+
+ await _converse.handleMessageStanza(msg);
+ const view = await u.waitUntil(() => _converse.chatboxviews.get(sender_jid));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+
+ // Check that the chatbox and its view now exist
+ const chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox.get('fullname') === sender_jid);
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
+ let author_el = view.querySelector('.chat-msg__author');
+ expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy();
+ await u.waitUntil(() => vcard_fetched, 100);
+ expect(_converse.api.vcard.get).toHaveBeenCalled();
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
+ author_el = view.querySelector('.chat-msg__author');
+ expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy();
+ }));
+ });
+
+
+ describe("who is not on the roster", function () {
+
+ it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
+ mock.initConverse(
+ [], {'allow_non_roster_messaging': false},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ const { api } = _converse;
+ spyOn(api, "trigger").and.callThrough();
+ const message = 'This is a received message from someone not on the roster';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ // We don't already have an open chatbox for this user
+ expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+
+ let chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox).toBe(null);
+ await _converse.handleMessageStanza(msg);
+ let view = _converse.chatboxviews.get(sender_jid);
+ expect(view).not.toBeDefined();
+
+ api.settings.set('allow_non_roster_messaging', true);
+ await _converse.handleMessageStanza(msg);
+ view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that the chatbox and its view now exist
+ chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+ // Check that the message was received and check the message parameters
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toEqual(undefined);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
+ // Now check that the message appears inside the chatbox in the DOM
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ }));
+ });
+
+ describe("and for which then an error message is received from the server", function () {
+
+ it("will have the error message displayed after itself",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // TODO: what could still be done for error
+ // messages... if the <error> element has type
+ // "cancel", then we know the messages wasn't sent,
+ // and can give the user a nicer indication of
+ // that.
+ /* <message from="scotty@enterprise.com/_converse.js-84843526"
+ * to="kirk@enterprise.com.com"
+ * type="chat"
+ * id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+ * xmlns="jabber:client">
+ * <body>yo</body>
+ * <active xmlns="http://jabber.org/protocol/chatstates"/>
+ * </message>
+ */
+ const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
+ let msg_text = 'This message will not be sent, due to an error';
+ const view = _converse.chatboxviews.get(sender_jid);
+ const message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(msg_text);
+
+ // We send another message, for which an error will
+ // not be received, to test that errors appear
+ // after the relevant message.
+ msg_text = 'This message will be sent, and also receive an error';
+ const second_message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view).length === 2, 1000);
+ msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(msg_text);
+
+ /* <message xmlns="jabber:client"
+ * to="scotty@enterprise.com/_converse.js-84843526"
+ * type="error"
+ * id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+ * from="kirk@enterprise.com.com">
+ * <error type="cancel">
+ * <remote-server-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ * <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Server-to-server connection failed: Connecting failed: connection timeout</text>
+ * </error>
+ * </message>
+ */
+ let stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type': 'error',
+ 'id': message.get('msgid'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t('Server-to-server connection failed: Connecting failed: connection timeout');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelector('.chat-msg__error').textContent.trim() === error_txt);
+
+ const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type': 'error',
+ 'id': second_message.get('id'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t(other_error_txt);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() =>
+ view.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt);
+
+ // We don't render duplicates
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type':'error',
+ 'id': second_message.get('id'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t('Server-to-server connection failed: Connecting failed: connection timeout');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(view.querySelectorAll('.chat-msg__error').length).toEqual(2);
+
+ msg_text = 'This message will be sent, and also receive an error';
+ const third_message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view).pop()?.textContent === msg_text);
+
+ // A different error message will however render
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type':'error',
+ 'id': third_message.get('id'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('not-allowed', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t('Something else went wrong as well');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length > 2);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 3);
+
+ // Ensure messages with error are not editable or retractable
+ await u.waitUntil(() => !view.model.messages.models.reduce((acc, m) => acc || m.get('editable'), false), 1000);
+ view.querySelectorAll('.chat-msg').forEach(el => {
+ expect(el.querySelector('.chat-msg__action-edit')).toBe(null)
+ expect(el.querySelector('.chat-msg__action-retract')).toBe(null)
+ })
+ }));
+
+ it("will not show to the user an error message for a CSI message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ // See #1317
+ // https://github.com/conversejs/converse.js/issues/1317
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello world'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(enter_event);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xml:lang="en" type="error" from="${contact_jid}">
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <no-store xmlns="urn:xmpp:hints"/>
+ <no-permanent-store xmlns="urn:xmpp:hints"/>
+ <error code="503" type="cancel">
+ <service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xml:lang="en" xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">User session not found</text></error>
+ </message>
+ `)));
+ return new Promise(resolve => setTimeout(() => {
+ expect(view.querySelector('.chat-msg__error').textContent).toBe('');
+ resolve();
+ }, 500));
+ }));
+
+ it("will have the error displayed below it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello world'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(enter_event);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ // Normally "modify" errors need to have their id set to the
+ // message that couldn't be sent. Not doing that here on purpose to
+ // check the case where it's not done.
+ // See issue #2683
+ const err_txt = `Your message to ${contact_jid} was not end-to-end encrypted. For security reasons, using one of the following E2EE schemes is *REQUIRED* for conversations on this server: pgp, omemo`;
+ const error = u.toStanza(`
+ <message xmlns="jabber:client" from="${contact_jid}" type="error" to="${_converse.jid}">
+ <error type="modify">
+ <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_txt}</text>
+ </error>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(error));
+
+ expect(await u.waitUntil(() => view.querySelector('.chat-error')?.textContent?.trim())).toBe(err_txt);
+ expect(view.model.messages.length).toBe(2);
+ }));
+
+ });
+
+ it("will cause the chat area to be scrolled down only if it was at the bottom originally",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid)
+ const view = _converse.chatboxviews.get(sender_jid);
+ // Create enough messages so that there's a scrollbar.
+ const promises = [];
+ view.querySelector('.chat-content').scrollTop = 0;
+ view.model.ui.set('scrolled', true);
+
+ for (let i=0; i<20; i++) {
+ _converse.handleMessageStanza($msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: _converse.connection.getUniqueId(),
+ }).c('body').t('Message: '+i).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ promises.push(new Promise(resolve => view.model.messages.once('rendered', resolve)));
+ }
+ await Promise.all(promises);
+
+ const indicator_el = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
+
+ expect(view.model.ui.get('scrolled')).toBe(true);
+ expect(view.querySelector('.chat-content').scrollTop).toBe(0);
+ indicator_el.click();
+ await u.waitUntil(() => !view.querySelector('.new-msgs-indicator'));
+ await u.waitUntil(() => !view.model.get('scrolled'));
+ }));
+
+ it("is ignored if it's intended for a different resource and filter_by_resource is set to true",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length)
+ // Send a message from a different resource
+ spyOn(converse.env.log, 'error');
+ spyOn(_converse.api.chatboxes, 'create').and.callThrough();
+ api.settings.set('filter_by_resource', true);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let msg = $msg({
+ from: sender_jid,
+ to: _converse.bare_jid+"/some-other-resource",
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t("This message will not be shown").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ expect(converse.env.log.error.calls.all().pop().args[0]).toBe(
+ "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
+ );
+ expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
+ api.settings.set('filter_by_resource', false);
+
+ const message = "This message sent to a different resource will be shown";
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.bare_jid+"/some-other-resource",
+ type: 'chat',
+ id: '134234623462346'
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => _converse.chatboxviews.keys().length > 1, 1000);
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(_converse.api.chatboxes.create).toHaveBeenCalled();
+ const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view).pop());
+ const msg_txt = last_message.textContent;
+ expect(msg_txt).toEqual(message);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js
new file mode 100644
index 0000000..62ae5ba
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js
@@ -0,0 +1,168 @@
+/*global mock, converse */
+
+const { Strophe, Promise, u } = converse.env;
+
+describe("A Chat Message", function () {
+ describe("which contains an OOB URL", function () {
+
+ it("will render audio from oob mp3 URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+
+ const url = 'https://montague.lit/audio.mp3';
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you heard this funny audio?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`)
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
+ let msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.classList.length).toEqual(1);
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you heard this funny audio?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+ `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+
+ `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">${url}</a>`);
+
+ // If the <url> and <body> contents is the same, don't duplicate.
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>${url}</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you heard this funny audio?'); // Emtpy
+
+ // We don't render the OOB data
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null);
+
+ // But we do render the body
+ const msg_el = view.querySelector('converse-chat-message:last-child .chat-msg__text');
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim() ===
+ `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+
+ `<a target="_blank" rel="noopener" href="${url}">${url}</a>`);
+ }));
+
+ it("will render video from oob mp4 URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+
+ const url = 'https://montague.lit/video.mp4';
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you seen this funny video?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg video').length, 2000)
+ let msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.classList.length).toBe(1);
+ expect(msg.textContent).toEqual('Have you seen this funny video?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual(
+ `<video controls="" preload="metadata" src="${Strophe.xmlescape(url)}"></video>`+
+ `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(url)}">${Strophe.xmlescape(url)}</a>`);
+
+ // If the <url> and <body> contents is the same, don't duplicate.
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>https://montague.lit/video.mp4</body>
+ <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ msg = view.querySelector('converse-chat-message .chat-msg__text');
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you seen this funny video?');
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null);
+ }));
+
+ it("will render download links for files from oob URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ const stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you downloaded this funny file?</body>
+ <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000);
+ const msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you downloaded this funny file?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual(
+ `<a target="_blank" rel="noopener" href="https://montague.lit/funny.pdf">Download file "funny.pdf"</a>`);
+ }));
+
+ it("will render images from oob URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const base_url = 'https://conversejs.org';
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ const url = base_url+"/logo/conversejs-filled.svg";
+
+ const stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you seen this funny image?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000);
+ const msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you seen this funny image?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+ `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `Download file "conversejs-filled.svg"</a>`);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js
new file mode 100644
index 0000000..8fac60f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js
@@ -0,0 +1,151 @@
+/*global mock, converse, _ */
+
+const { Promise, Strophe, $msg, sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+describe("A delivery receipt", function () {
+
+ it("is emitted for a received message which requests it",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(stanza => sent_stanzas.push(stanza));
+ const msg = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t('Message!').up()
+ .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+ await _converse.handleMessageStanza(msg);
+ const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message');
+ // A chat state message is also included
+ expect(sent_messages.length).toBe(2);
+ const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[1]).pop();
+ expect(Strophe.serialize(receipt)).toBe(`<received id="${msg_id}" xmlns="${Strophe.NS.RECEIPTS}"/>`);
+ }));
+
+ it("is not emitted for a carbon message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+ const msg = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'to': _converse.bare_jid+'/another-resource',
+ 'type': 'chat',
+ 'id': msg_id
+ }).c('body').t('Message!').up()
+ .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+ await _converse.handleMessageStanza(msg);
+ expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+ }));
+
+ it("is not emitted for an archived message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+
+ const stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.jid}">
+ <result xmlns="urn:xmpp:mam:2" id="9ZWxmXMR8SVor-tC" queryid="f543c5f9-55e7-400e-860a-56baac121e6a">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2020-01-10T22:19:30Z"/>
+ <message xmlns="jabber:client" type="chat" to="${_converse.jid}" from="${sender_jid}" id="id8b6426b4-40fe-4151-941e-4c64e380acb9">
+ <body>Please confirm receipt</body>
+ <request xmlns="urn:xmpp:receipts"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="id8b6426b4-40fe-4151-941e-4c64e380acb9"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ let message_attrs;
+ _converse.api.listen.on('MAMResult', async data => {
+ message_attrs = await data.messages[0];
+ });
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ expect(message_attrs.is_archived).toBe(true);
+ expect(message_attrs.is_valid_receipt_request).toBe(false);
+ expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+ }));
+
+ it("can be received for a sent message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ const chatbox = _converse.chatboxes.get(contact_jid);
+ expect(chatbox).toBeDefined();
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ let msg_obj = chatbox.messages.models[0];
+ let msg_id = msg_obj.get('msgid');
+ let msg = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'id': u.getUniqueId(),
+ }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 1);
+
+ // Also handle receipts with type 'chat'. See #1353
+ spyOn(_converse, 'handleMessageStanza').and.callThrough();
+ textarea.value = 'Another message';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ msg_obj = chatbox.messages.models[1];
+ msg_id = msg_obj.get('msgid');
+ msg = $msg({
+ 'from': contact_jid,
+ 'type': 'chat',
+ 'to': _converse.connection.jid,
+ 'id': u.getUniqueId(),
+ }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 2);
+ expect(_converse.handleMessageStanza.calls.count()).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js
new file mode 100644
index 0000000..8035b63
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js
@@ -0,0 +1,238 @@
+/* global mock, converse */
+
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A spoiler message", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("can be received with a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+ * <body>And at the end of the story, both of them die! It is so tragic!</body>
+ * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+ * </message>
+ */
+ const spoiler_hint = "Love story end"
+ const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+ const $msg = converse.env.$msg;
+ const u = converse.env.utils;
+ const msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t(spoiler).up()
+ .c('spoiler', {
+ 'xmlns': 'urn:xmpp:spoiler:0',
+ }).t(spoiler_hint)
+ .tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+ const spoiler_hint_el = view.querySelector('.spoiler-hint');
+ expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
+ }));
+
+ it("can be received without a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+ * <body>And at the end of the story, both of them die! It is so tragic!</body>
+ * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+ * </message>
+ */
+ const $msg = converse.env.$msg;
+ const u = converse.env.utils;
+ const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+ const msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t(spoiler).up()
+ .c('spoiler', {
+ 'xmlns': 'urn:xmpp:spoiler:0',
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => u.isVisible(view));
+ await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+ await u.waitUntil(() => u.isVisible(view.querySelector('.chat-msg__author')));
+ expect(view.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+ const spoiler_hint_el = view.querySelector('.spoiler-hint');
+ expect(spoiler_hint_el.textContent).toBe('');
+ }));
+
+ it("can be sent without a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const { $pres, Strophe} = converse.env;
+ const u = converse.env.utils;
+
+ // XXX: We need to send a presence from the contact, so that we
+ // have a resource, that resource is then queried to see
+ // whether Strophe.NS.SPOILER is supported, in which case
+ // the spoiler button will appear.
+ const presence = $pres({
+ 'from': contact_jid+'/phone',
+ 'to': 'romeo@montague.lit'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.connection, 'send');
+
+ await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler'));
+ let spoiler_toggle = view.querySelector('.toggle-compose-spoiler');
+ spoiler_toggle.click();
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This is the spoiler';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ /* Test the XML stanza
+ *
+ * <message from="romeo@montague.lit/orchard"
+ * to="max.frankfurter@montague.lit"
+ * type="chat"
+ * id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
+ * xmlns="jabber:client">
+ * <body>This is the spoiler</body>
+ * <active xmlns="http://jabber.org/protocol/chatstates"/>
+ * <spoiler xmlns="urn:xmpp:spoiler:0"/>
+ * </message>"
+ */
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ const spoiler_el = await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]'));
+ expect(spoiler_el.textContent).toBe('');
+
+ const spoiler = 'This is the spoiler';
+ const body_el = stanza.querySelector('body');
+ expect(body_el.textContent).toBe(spoiler);
+
+ /* Test the HTML spoiler message */
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+
+ const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler');
+ expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy();
+
+ spoiler_toggle = view.querySelector('.spoiler-toggle');
+ expect(spoiler_toggle.textContent.trim()).toBe('Show more');
+ spoiler_toggle.click();
+ await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden'));
+ expect(spoiler_toggle.textContent.trim()).toBe('Show less');
+ spoiler_toggle.click();
+ await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden'));
+ }));
+
+ it("can be sent with a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const { $pres, Strophe} = converse.env;
+ const u = converse.env.utils;
+
+ // XXX: We need to send a presence from the contact, so that we
+ // have a resource, that resource is then queried to see
+ // whether Strophe.NS.SPOILER is supported, in which case
+ // the spoiler button will appear.
+ const presence = $pres({
+ 'from': contact_jid+'/phone',
+ 'to': 'romeo@montague.lit'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler'));
+ let spoiler_toggle = view.querySelector('.toggle-compose-spoiler');
+ spoiler_toggle.click();
+
+ spyOn(_converse.connection, 'send');
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This is the spoiler';
+ const hint_input = view.querySelector('.spoiler-hint');
+ hint_input.value = 'This is the hint';
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" ` +
+ `id="${stanza.getAttribute('id')}" `+
+ `to="mercutio@montague.lit" `+
+ `type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>This is the spoiler</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<spoiler xmlns="urn:xmpp:spoiler:0">This is the hint</spoiler>`+
+ `<origin-id id="${stanza.querySelector('origin-id').getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`
+ );
+
+ await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]')?.textContent === 'This is the hint');
+
+ const spoiler = 'This is the spoiler'
+ const body_el = stanza.querySelector('body');
+ expect(body_el.textContent).toBe(spoiler);
+
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+
+ const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler');
+ expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy();
+
+ spoiler_toggle = view.querySelector('.spoiler-toggle');
+ expect(spoiler_toggle.textContent.trim()).toBe('Show more');
+ spoiler_toggle.click();
+ await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden'));
+ expect(spoiler_toggle.textContent.trim()).toBe('Show less');
+ spoiler_toggle.click();
+ await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden'));
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js
new file mode 100644
index 0000000..f6f5872
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js
@@ -0,0 +1,517 @@
+/*global mock, converse */
+
+const { u, $msg } = converse.env;
+
+describe("An incoming chat Message", function () {
+
+ it("can have styling disabled via an \"unstyled\" element",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ const msg_text = '> _ >';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('body').t(msg_text).up()
+ .c('unstyled', {'xmlns': 'urn:xmpp:styling:0'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.models[0].get('is_unstyled')).toBe(true);
+
+ setTimeout(() => {
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ }, 500);
+ }));
+
+
+ it("can have styling disabled via the allow_message_styling setting",
+ mock.initConverse(['chatBoxesFetched'], {'allow_message_styling': false},
+ async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ const msg_text = '> _ >';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('body').t(msg_text).tree();
+ await _converse.handleMessageStanza(msg);
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.models[0].get('is_unstyled')).toBe(false);
+
+ setTimeout(() => {
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ }, 500);
+ }));
+
+ it("can be styled with span XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This <span class="styling-directive">*</span>'+
+ '<b>message <span class="styling-directive">_</span><i>contains</i><span class="styling-directive">_</span></b>'+
+ '<span class="styling-directive">*</span>'+
+ ' styling hints! '+
+ '<span class="styling-directive">`</span><code>Here\'s *some* code</code><span class="styling-directive">`</span>'
+ );
+
+ msg_text = "Here's a ~strikethrough section~";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Here\'s a <span class="styling-directive">~</span><del>strikethrough section</del><span class="styling-directive">~</span>');
+
+ // Span directives containing hyperlinks
+ msg_text = "~Check out this site: https://conversejs.org~"
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">~</span>'+
+ '<del>Check out this site: <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a></del>'+
+ '<span class="styling-directive">~</span>');
+
+ // Images inside directives aren't shown inline
+ const base_url = 'https://conversejs.org';
+ msg_text = `*${base_url}/logo/conversejs-filled.svg*`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">*</span>'+
+ '<b><a target="_blank" rel="noopener" href="https://conversejs.org/logo/conversejs-filled.svg">https://conversejs.org/logo/conversejs-filled.svg</a></b>'+
+ '<span class="styling-directive">*</span>');
+
+ // Emojis inside directives
+ msg_text = `~ Hello! :poop: ~`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">~</span><del> Hello! <span title=":poop:">💩</span> </del><span class="styling-directive">~</span>');
+
+ // Span directives don't cross lines
+ msg_text = "This *is not a styling hint \n * _But this is_!";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This *is not a styling hint \n'+
+ ' * <span class="styling-directive">_</span><i>But this is</i><span class="styling-directive">_</span>!');
+
+ msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '(There are three blocks in this body marked by parens,)\n'+
+ ' (but there is no *formatting)\n'+
+ ' (as spans* may not escape blocks.)\n'+
+ ' <span class="styling-directive">~</span><del>strikethrough</del><span class="styling-directive">~</span>');
+
+ // Some edge-case (unspecified) spans
+ msg_text = `__ hello world _`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '_<span class="styling-directive">_</span><i> hello world </i><span class="styling-directive">_</span>');
+
+ // Directives which are parts of words aren't matched
+ msg_text = `Go to ~https://conversejs.org~now _please_`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Go to ~https://conversejs.org~now <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
+
+ msg_text = `Go to _https://converse_js.org_ _please_`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Go to <span class="styling-directive">_</span>'+
+ '<i><a target="_blank" rel="noopener" href="https://converse_js.org/">https://converse_js.org</a></i>'+
+ '<span class="styling-directive">_</span> <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
+
+ }));
+
+ it("can be styled with block XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ 'Here\'s a code block: \n'+
+ '<div class="styling-directive">```</div><code class="block">Inside the code-block, &lt;code&gt;hello&lt;/code&gt; we don\'t enable *styling hints* like ~these~\n'+
+ '</code><div class="styling-directive">```</div>'
+ );
+
+ msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<div class="styling-directive">```</div>'+
+ '<code class="block">ignored\n(println "Hello, world!")\n</code>'+
+ '<div class="styling-directive">```</div>\n'+
+ 'This should show up as monospace, preformatted text ^');
+
+
+ msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not show up as monospace, *preformatted* text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '```ignored\n (println "Hello, world!")\n ```\n\n'+
+ ' This should not show up as monospace, '+
+ '<span class="styling-directive">*</span><b>preformatted</b><span class="styling-directive">*</span> text ^');
+ }));
+
+ it("can be styled with quote XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = `> https://conversejs.org\n> https://conversejs.org`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\u200B\u200B'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'+
+ '</blockquote>');
+
+ msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted');
+
+ msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+
+ 'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\n'+
+ 'This is not quoted');
+
+ msg_text = `> > This is doubly quoted text`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
+
+ msg_text = `>> This is doubly quoted text`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
+
+ msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>'+
+ '<div class="styling-directive">```</div>'+
+ '<code class="block">\u200Bignored\n\u200B\u200B&lt;span&gt;&lt;/span&gt; (println "Hello, world!")\n\u200B'+
+ '</code><div class="styling-directive">```</div>\n\u200B\u200B'+
+ 'This should show up as monospace, preformatted text ^'+
+ '</blockquote>');
+
+ msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+
+ 'The entire blockquote is a preformatted text block, but this line is plaintext!');
+
+ msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/configuration.html#assets-path">https://conversejs.org/docs/html/configuration.html#assets-path</a>');
+
+ msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>Where is it located?</blockquote>\n'+
+ '<a target="_blank" rel="noopener" '+
+ 'href="https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
+
+ msg_text = '> What do you think of it?\n :poop:';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>');
+
+ msg_text = '> What do you think of it?\n~hello~';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>');
+
+ msg_text = 'hello world > this is not a quote';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === 'hello world &gt; this is not a quote');
+
+ msg_text = '> What do you think of it romeo?\n Did you see this romeo?';
+ msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: (new Date()).getTime()
+ }).c('body').t(msg_text).up()
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'26',
+ 'end':'31',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ })
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'51',
+ 'end':'56',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ }).nodeTree;
+ await _converse.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 13);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<blockquote>What do you think of it <span class="mention" data-uri="romeo@montague.lit">romeo</span>?</blockquote>\n `+
+ `Did you see this <span class="mention" data-uri="romeo@montague.lit">romeo</span>?`);
+
+ expect(true).toBe(true);
+ }));
+
+ it("won't style invalid block quotes",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const msg_text = '```\ncode```';
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: (new Date()).getTime()
+ }).c('body').t(msg_text).up()
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'26',
+ 'end':'31',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ })
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'51',
+ 'end':'56',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ }).nodeTree;
+ await _converse.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === '```\ncode```');
+ expect(true).toBe(true);
+ }));
+});
+
+
+describe("An XEP-0393 styled message ", function () {
+
+ it("can be replaced with a correction and will still render properly",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const msg_text = `https://conversejs.org\nhttps://opkode.com`;
+ const msg_id = u.getUniqueId();
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t(msg_text).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('https://conversejs.org\nhttps://opkode.com');
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+ const msg_el = view.querySelector('converse-chat-message-body');
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t(`A\nhttps://conversejs.org\n\nhttps://opkode.com`).up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg__text').length).toBe(1);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+ }));
+
+ it("can be sent as a correction by using the up arrow",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ const message_form = view.querySelector('converse-message-form');
+
+ textarea.value = `https://conversejs.org\nhttps://opkode.com`;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+
+ textarea.value = `A\nhttps://conversejs.org\n\nhttps://opkode.com`;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg__text').length).toBe(1);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+ }));
+
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js
new file mode 100644
index 0000000..40eb4ea
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js
@@ -0,0 +1,156 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+
+describe("A ChatBox's Unread Message Count", function () {
+
+ it("is incremented when the message is received and ChatBoxView is scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+
+ const view = await mock.openChatBoxFor(_converse, sender_jid)
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ view.model.ui.set('scrolled', true);
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.get('num_unread')).toBe(1);
+ const msgid = view.model.messages.last().get('id');
+ expect(view.model.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.length);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is not incremented when the message is received and ChatBoxView is scrolled down",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ await _converse.handleMessageStanza(msg);
+ expect(chatbox.get('num_unread')).toBe(0);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+ expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
+ }));
+
+ it("is incremented when message is received, chatbox is scrolled down and the window is not focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = function () {
+ return mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ };
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is incremented when message is received, chatbox is scrolled up and the window is not focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is cleared when the chat was scrolled down and the window become focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ u.saveWindowState({'type': 'focus'});
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+ expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
+ expect(chatbox.get('num_unread')).toBe(0);
+ }));
+
+ it("is cleared when the chat was hidden in fullscreen mode and then becomes visible",
+ mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.save({'hidden': true});
+ _converse.handleMessageStanza(mock.createChatMessage(_converse, sender_jid, 'This message will be unread'));
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ chatbox.save({'hidden': false});
+ await u.waitUntil(() => chatbox.get('num_unread') === 0);
+ chatbox.close();
+ }));
+
+ it("is not cleared when ChatBoxView was scrolled up and the windows become focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ u.saveWindowState({'type': 'focus'});
+ await u.waitUntil(() => chatbox.get('num_unread') === 1);
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js
new file mode 100644
index 0000000..8e54382
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js
@@ -0,0 +1,254 @@
+/*global mock, converse */
+
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XSS", function () {
+ describe("A Chat Message", function () {
+
+ it("will escape IMG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ spyOn(window, 'alert').and.callThrough();
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "<img src=x onerror=alert('XSS');>";
+ await mock.sendMessage(view, message);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
+ expect(window.alert).not.toHaveBeenCalled();
+
+ message = "<img src=x onerror=alert('XSS')//";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS')//");
+
+ message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
+
+ message = "><img src=x onerror=alert('XSS');>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
+
+ message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ expect(window.alert).not.toHaveBeenCalled();
+ }));
+
+ it("will escape SVG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ spyOn(window, 'alert').and.callThrough();
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "<svg onload=alert(1)>";
+ await mock.sendMessage(view, message);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('&lt;svg onload=alert(1)&gt;');
+
+ message = "<svg/onload=alert('XSS')>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg/onload=alert('XSS')&gt;");
+
+ message = "<svg onload=alert(1)//";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg onload=alert(1)//");
+
+ message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
+
+ message = "<svg id=alert(1) onload=eval(id)>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
+
+ message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
+
+ message = '"><svg/onload=alert(/XSS/)';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
+
+ expect(window.alert).not.toHaveBeenCalled();
+ }));
+
+ it("will have properly escaped URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "http://www.opkode.com/'onmouseover='alert(1)'whatever";
+ await mock.sendMessage(view, message);
+
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, ''))
+ .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
+
+
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>`);
+
+ message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>`);
+
+ message = "https://en.wikipedia.org/wiki/Ender's_Game";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">https://en.wikipedia.org/wiki/Ender's_Game</a>`);
+
+ message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
+
+ message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;`);
+
+ message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
+ }));
+
+ it("will avoid malformed and unsafe urls urls from rendering as anchors",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const bad_urls =[
+ 'http://^$^(*^#$%^_1*(',
+ 'file://devili.sh'
+ ];
+
+ const good_urls =[{
+ entered: 'http://www.google.com',
+ href: 'http://www.google.com/'
+ }, {
+ entered: 'https://www.google.com/',
+ href: 'https://www.google.com/'
+ }, {
+ entered: 'www.url.com/something?else=1',
+ href: 'http://www.url.com/something?else=1',
+ }, {
+ entered: 'xmpp://anything/?join',
+ href: 'xmpp://anything/?join',
+ }, {
+ entered: 'WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
+ href: 'http://WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
+ }, {
+ entered: 'mailto:test@mail.org',
+ href: 'mailto:test@mail.org',
+ }];
+
+ function checkNonParsedURL (url) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(url);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual(url);
+ }
+
+ async function checkParsedURL ({ entered, href }) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(entered);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+ }
+
+ async function checkParsedXMPPURL ({ entered, href }) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent.trim()).toEqual(entered);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+ }
+
+ await mock.sendMessage(view, bad_urls[0]);
+ checkNonParsedURL(bad_urls[0]);
+
+ await mock.sendMessage(view, bad_urls[1]);
+ checkNonParsedURL(bad_urls[1]);
+
+ await mock.sendMessage(view, good_urls[0].entered);
+ await checkParsedURL(good_urls[0]);
+
+ await mock.sendMessage(view, good_urls[1].entered);
+ await checkParsedURL(good_urls[1]);
+
+ await mock.sendMessage(view, good_urls[2].entered);
+ await checkParsedURL(good_urls[2]);
+
+ await mock.sendMessage(view, good_urls[3].entered);
+ await checkParsedXMPPURL(good_urls[3]);
+
+ await mock.sendMessage(view, good_urls[4].entered);
+ await checkParsedURL(good_urls[4]);
+
+ await mock.sendMessage(view, good_urls[5].entered);
+ await checkParsedURL(good_urls[5]);
+
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js
new file mode 100644
index 0000000..6791bec
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js
@@ -0,0 +1,65 @@
+import { __ } from 'i18n';
+import { _converse, api } from '@converse/headless/core';
+
+export function clearHistory (jid) {
+ if (_converse.router.history.getFragment() === `converse/chat?jid=${jid}`) {
+ _converse.router.navigate('');
+ }
+}
+
+export async function clearMessages (chat) {
+ const result = await api.confirm(__('Are you sure you want to clear the messages from this conversation?'));
+ if (result) {
+ await chat.clearMessages();
+ }
+}
+
+export async function parseMessageForCommands (chat, text) {
+ const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/);
+ if (match) {
+ let handled = false;
+ /**
+ * *Hook* which allows plugins to add more commands to a chat's textbox.
+ * Data provided is the chatbox model and the text typed - {model, text}.
+ * Check `handled` to see if the hook was already handled.
+ * @event _converse#parseMessageForCommands
+ * @example
+ * api.listen.on('parseMessageForCommands', (data, handled) {
+ * if (!handled) {
+ * const command = (data.text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
+ * // custom code comes here
+ * }
+ * return handled;
+ * }
+ */
+ handled = await api.hook('parseMessageForCommands', { model: chat, text }, handled);
+ if (handled) {
+ return true;
+ }
+
+ if (match[1] === 'clear') {
+ clearMessages(chat);
+ return true;
+ } else if (match[1] === 'close') {
+ _converse.chatboxviews.get(chat.get('jid'))?.close();
+ return true;
+ } else if (match[1] === 'help') {
+ chat.set({ 'show_help_messages': false }, { 'silent': true });
+ chat.set({ 'show_help_messages': true });
+ return true;
+ }
+ }
+ return false;
+}
+
+export function resetElementHeight (ev) {
+ if (ev.target.value) {
+ const height = ev.target.scrollHeight + 'px';
+ if (ev.target.style.height != height) {
+ ev.target.style.height = 'auto';
+ ev.target.style.height = height;
+ }
+ } else {
+ ev.target.style = '';
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js
new file mode 100644
index 0000000..bc3bee8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js
@@ -0,0 +1,37 @@
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { u } = converse.env;
+
+export default {
+ /**
+ * The "controlbox" namespace groups methods pertaining to the
+ * controlbox view
+ *
+ * @namespace _converse.api.controlbox
+ * @memberOf _converse.api
+ */
+ controlbox: {
+ /**
+ * Opens the controlbox
+ * @method _converse.api.controlbox.open
+ * @returns { Promise<_converse.ControlBox> }
+ */
+ async open () {
+ await api.waitUntil('chatBoxesFetched');
+ const model = await api.chatboxes.get('controlbox') ||
+ api.chatboxes.create('controlbox', {}, _converse.Controlbox);
+ u.safeSave(model, {'closed': false});
+ return model;
+ },
+
+ /**
+ * Returns the controlbox view.
+ * @method _converse.api.controlbox.get
+ * @returns { View } View representing the controlbox
+ * @example const view = _converse.api.controlbox.get();
+ */
+ get () {
+ return _converse.chatboxviews.get('controlbox');
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js
new file mode 100644
index 0000000..2ca9174
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js
@@ -0,0 +1,42 @@
+import { converse } from '@converse/headless/core.js';
+
+const { Strophe } = converse.env;
+
+export const REPORTABLE_STATUSES = [
+ Strophe.Status.ERROR,
+ Strophe.Status.CONNECTING,
+ Strophe.Status.CONNFAIL,
+ Strophe.Status.AUTHENTICATING,
+ Strophe.Status.AUTHFAIL,
+ Strophe.Status.DISCONNECTING,
+ Strophe.Status.RECONNECTING,
+];
+
+export const PRETTY_CONNECTION_STATUS = Object.fromEntries([
+ [Strophe.Status.ERROR, 'Error'],
+ [Strophe.Status.CONNECTING, 'Connecting'],
+ [Strophe.Status.CONNFAIL, 'Connection failure'],
+ [Strophe.Status.AUTHENTICATING, 'Authenticating'],
+ [Strophe.Status.AUTHFAIL, 'Authentication failure'],
+ [Strophe.Status.CONNECTED, 'Connected'],
+ [Strophe.Status.DISCONNECTED, 'Disconnected'],
+ [Strophe.Status.DISCONNECTING, 'Disconnecting'],
+ [Strophe.Status.ATTACHED, 'Attached'],
+ [Strophe.Status.REDIRECT, 'Redirect'],
+ [Strophe.Status.CONNTIMEOUT, 'Connection timeout'],
+ [Strophe.Status.RECONNECTING, 'Reconnecting'],
+]);
+
+export const CONNECTION_STATUS_CSS_CLASS = Object.fromEntries([
+ [Strophe.Status.ERROR, 'error'],
+ [Strophe.Status.CONNECTING, 'info'],
+ [Strophe.Status.CONNFAIL, 'error'],
+ [Strophe.Status.AUTHENTICATING, 'info'],
+ [Strophe.Status.AUTHFAIL, 'error'],
+ [Strophe.Status.CONNECTED, 'info'],
+ [Strophe.Status.DISCONNECTED, 'error'],
+ [Strophe.Status.DISCONNECTING, 'warn'],
+ [Strophe.Status.ATTACHED, 'info'],
+ [Strophe.Status.REDIRECT, 'info'],
+ [Strophe.Status.RECONNECTING, 'warn'],
+]);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js
new file mode 100644
index 0000000..6611ca7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js
@@ -0,0 +1,79 @@
+import tplControlbox from './templates/controlbox.js';
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api, converse } from '@converse/headless/core.js';
+import { LOGOUT } from '@converse/headless/shared/constants.js';
+
+const u = converse.env.utils;
+
+/**
+ * The ControlBox is the section of the chat that contains the open groupchats,
+ * bookmarks and roster.
+ *
+ * In `overlayed` `view_mode` it's a box like the chat boxes, in `fullscreen`
+ * `view_mode` it's a left-aligned sidebar.
+ */
+class ControlBox extends CustomElement {
+
+ initialize () {
+ this.setModel();
+ _converse.chatboxviews.add('controlbox', this);
+ if (this.model.get('connected') && this.model.get('closed') === undefined) {
+ this.model.set('closed', !api.settings.get('show_controlbox_by_default'));
+ }
+ this.requestUpdate();
+
+ /**
+ * Triggered when the _converse.ControlBoxView has been initialized and therefore
+ * exists. The controlbox contains the login and register forms when the user is
+ * logged out and a list of the user's contacts and group chats when logged in.
+ * @event _converse#controlBoxInitialized
+ * @type { _converse.ControlBoxView }
+ * @example _converse.api.listen.on('controlBoxInitialized', view => { ... });
+ */
+ api.trigger('controlBoxInitialized', this);
+ }
+
+ setModel () {
+ this.model = _converse.chatboxes.get('controlbox');
+ this.listenTo(_converse.connfeedback, 'change:connection_status', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:active-form', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:connected', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:closed', () => !this.model.get('closed') && this.afterShown());
+ this.requestUpdate();
+ }
+
+ render () {
+ return this.model ? tplControlbox(this) : '';
+ }
+
+ close (ev) {
+ ev?.preventDefault?.();
+ if (
+ ev?.name === 'closeAllChatBoxes' &&
+ (_converse.disconnection_cause !== LOGOUT ||
+ api.settings.get('show_controlbox_by_default'))
+ ) {
+ return;
+ }
+ if (api.settings.get('sticky_controlbox')) {
+ return;
+ }
+ u.safeSave(this.model, { 'closed': true });
+ api.trigger('controlBoxClosed', this);
+ return this;
+ }
+
+ afterShown () {
+ /**
+ * Triggered once the controlbox has been opened
+ * @event _converse#controlBoxOpened
+ * @type {_converse.ControlBox}
+ */
+ api.trigger('controlBoxOpened', this);
+ return this;
+ }
+}
+
+api.elements.define('converse-controlbox', ControlBox);
+
+export default ControlBox;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js
new file mode 100644
index 0000000..3026d33
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js
@@ -0,0 +1,78 @@
+/**
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import "shared/components/brand-heading.js";
+import "../chatview/index.js";
+import './loginform.js';
+import './navback.js';
+import ControlBox from './model.js';
+import ControlBoxToggle from './toggle.js';
+import ControlBoxView from './controlbox.js';
+import controlbox_api from './api.js';
+import log from '@converse/headless/log';
+import { _converse, api, converse } from '@converse/headless/core';
+import { addControlBox, clearSession, disconnect, onChatBoxesFetched } from './utils.js';
+
+import './styles/_controlbox.scss';
+import './styles/controlbox-head.scss';
+
+
+converse.plugins.add('converse-controlbox', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ */
+ dependencies: ['converse-modal', 'converse-chatboxes', 'converse-chat', 'converse-rosterview', 'converse-chatview'],
+
+ enabled (_converse) {
+ return !_converse.api.settings.get('singleton');
+ },
+
+ // Overrides mentioned here will be picked up by converse.js's
+ // plugin architecture they will replace existing methods on the
+ // relevant objects or classes.
+ // New functions which don't exist yet can also be added.
+ overrides: {
+ ChatBoxes: {
+ model (attrs, options) {
+ if (attrs && attrs.id == 'controlbox') {
+ return new ControlBox(attrs, options);
+ } else {
+ return this.__super__.model.apply(this, arguments);
+ }
+ }
+ }
+ },
+
+ initialize () {
+ api.settings.extend({
+ allow_logout: true,
+ allow_user_trust_override: true,
+ default_domain: undefined,
+ locked_domain: undefined,
+ show_connection_url_input: false,
+ show_controlbox_by_default: false,
+ sticky_controlbox: false
+ });
+
+ api.promises.add('controlBoxInitialized', false);
+ Object.assign(api, controlbox_api);
+
+ _converse.ControlBoxView = ControlBoxView;
+ _converse.ControlBox = ControlBox;
+ _converse.ControlBoxToggle = ControlBoxToggle;
+
+ api.listen.on('chatBoxesFetched', onChatBoxesFetched);
+ api.listen.on('clearSession', clearSession);
+ api.listen.on('will-reconnect', disconnect);
+
+ api.waitUntil('chatBoxViewsInitialized')
+ .then(addControlBox)
+ .catch(e => log.fatal(e));
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js
new file mode 100644
index 0000000..0636aad
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js
@@ -0,0 +1,98 @@
+import bootstrap from 'bootstrap.native';
+import tplLoginPanel from './templates/loginform.js';
+import { ANONYMOUS } from '@converse/headless/shared/constants';
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api, converse } from '@converse/headless/core.js';
+import { initConnection } from '@converse/headless/utils/init.js';
+import { updateSettingsWithFormData, validateJID } from './utils.js';
+
+const { Strophe, u } = converse.env;
+
+
+class LoginForm extends CustomElement {
+
+ initialize () {
+ this.listenTo(_converse.connfeedback, 'change', () => this.requestUpdate());
+ this.handler = () => this.requestUpdate()
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ api.settings.listen.on('change', this.handler);
+ }
+
+ disconnectedCallback () {
+ super.disconnectedCallback();
+ api.settings.listen.not('change', this.handler);
+ }
+
+ render () {
+ return tplLoginPanel(this);
+ }
+
+ firstUpdated () {
+ this.initPopovers();
+ }
+
+ async onLoginFormSubmitted (ev) {
+ ev?.preventDefault();
+
+ if (api.settings.get('authentication') === ANONYMOUS) {
+ return this.connect(_converse.jid);
+ }
+
+ if (!validateJID(ev.target)) {
+ return;
+ }
+ updateSettingsWithFormData(ev.target);
+
+ if (!api.settings.get('bosh_service_url') && !api.settings.get('websocket_url')) {
+ // We don't have a connection URL available, so we try here to discover
+ // XEP-0156 connection methods now, and if not found we present the user
+ // with the option to enter their own connection URL
+ await this.discoverConnectionMethods(ev);
+ }
+
+ if (api.settings.get('bosh_service_url') || api.settings.get('websocket_url')) {
+ // FIXME: The connection class will still try to discover XEP-0156 connection methods
+ this.connect();
+ } else {
+ api.settings.set('show_connection_url_input', true);
+ }
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ discoverConnectionMethods (ev) {
+ if (!api.settings.get("discover_connection_methods")) {
+ return;
+ }
+ const form_data = new FormData(ev.target);
+ const jid = form_data.get('jid');
+ const domain = Strophe.getDomainFromJid(jid);
+ if (!_converse.connection?.jid || (jid && !u.isSameDomain(_converse.connection.jid, jid))) {
+ initConnection();
+ }
+ return _converse.connection.discoverConnectionMethods(domain);
+ }
+
+ initPopovers () {
+ Array.from(this.querySelectorAll('[data-title]')).forEach(el => {
+ new bootstrap.Popover(el, {
+ 'trigger': (api.settings.get('view_mode') === 'mobile' && 'click') || 'hover',
+ 'dismissible': (api.settings.get('view_mode') === 'mobile' && true) || false,
+ 'container': this.parentElement.parentElement.parentElement,
+ });
+ });
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ connect (jid) {
+ if (['converse/login', 'converse/register'].includes(_converse.router.history.getFragment())) {
+ _converse.router.navigate('', { 'replace': true });
+ }
+ _converse.connection?.reset();
+ api.user.login(jid);
+ }
+}
+
+api.elements.define('converse-login-form', LoginForm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js
new file mode 100644
index 0000000..7adbfe4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js
@@ -0,0 +1,52 @@
+import { _converse, api, converse } from '@converse/headless/core';
+import { Model } from '@converse/skeletor/src/model.js';
+
+const { dayjs } = converse.env;
+
+/**
+ * The ControlBox is the section of the chat that contains the open groupchats,
+ * bookmarks and roster.
+ *
+ * In `overlayed` `view_mode` it's a box like the chat boxes, in `fullscreen`
+ * `view_mode` it's a left-aligned sidebar.
+ * @mixin
+ */
+const ControlBox = Model.extend({
+
+ defaults () {
+ return {
+ 'bookmarked': false,
+ 'box_id': 'controlbox',
+ 'chat_state': undefined,
+ 'closed': !api.settings.get('show_controlbox_by_default'),
+ 'num_unread': 0,
+ 'time_opened': dayjs(0).valueOf(),
+ 'type': _converse.CONTROLBOX_TYPE,
+ 'url': ''
+ };
+ },
+
+ validate (attrs) {
+ if (attrs.type === _converse.CONTROLBOX_TYPE) {
+ if (api.settings.get('view_mode') === 'embedded' && api.settings.get('singleton')) {
+ return 'Controlbox not relevant in embedded view mode';
+ }
+ return;
+ }
+ return _converse.ChatBox.prototype.validate.call(this, attrs);
+ },
+
+ maybeShow (force) {
+ if (!force && this.get('id') === 'controlbox') {
+ // Must return the chatbox
+ return this;
+ }
+ return _converse.ChatBox.prototype.maybeShow.call(this, force);
+ },
+
+ onReconnection () {
+ this.save('connected', true);
+ }
+});
+
+export default ControlBox;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js
new file mode 100644
index 0000000..efa2eb1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js
@@ -0,0 +1,21 @@
+import tplControlboxNavback from "./templates/navback.js";
+import { CustomElement } from 'shared/components/element.js';
+import { api } from "@converse/headless/core";
+
+
+class ControlBoxNavback extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ render () {
+ return tplControlboxNavback(this.jid);
+ }
+}
+
+api.elements.define('converse-controlbox-navback', ControlBoxNavback);
+
+export default ControlBoxNavback;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss
new file mode 100644
index 0000000..f162a03
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss
@@ -0,0 +1,583 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+@import "shared/styles/_variables.scss";
+@import "shared/styles/_mixins.scss";
+
+.conversejs {
+ .set-xmpp-status,
+ .xmpp-status {
+ .chat-status--online {
+ color: var(--chat-status-online);
+ }
+ .chat-status--busy {
+ color: var(--chat-status-busy);
+ }
+ .chat-status--away {
+ color: var(--chat-status-away);
+ }
+ .far.fa-circle,
+ .fa-times-circle {
+ color: var(--subdued-color);
+ }
+ }
+
+ .set-xmpp-status {
+ .chat-status {
+ padding-right: 0.5em;
+ }
+ }
+
+ .room-info {
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: normal;
+
+ li.room-info {
+ display: block;
+ margin-left: 5px;
+ }
+ p.room-info {
+ line-height: var(--line-height);
+ margin: 0;
+ display: block;
+ white-space: normal;
+ }
+ }
+ div.room-info {
+ padding: 0.3em 0;
+ clear: left;
+ width: 100%;
+ }
+
+ #controlbox {
+ order: -1;
+ color: var(--controlbox-text-color);
+
+ .chat-status--avatar {
+ border: 1px solid var(--controlbox-pane-background-color);
+ background: var(--controlbox-pane-background-color);
+ }
+
+ converse-brand-logo {
+ width: 100%;
+ display: block;
+ }
+
+ converse-brand-heading {
+ width: 100%;
+ display: block;
+ }
+
+ .brand-name-wrapper {
+ font-size: 200%;
+ }
+
+ .brand-name-wrapper--fullscreen {
+ font-size: 100%;
+ }
+
+ .box-flyout {
+ background-color: var(--controlbox-pane-background-color);
+ }
+
+ margin-right: calc(3 * var(--chat-gutter));
+
+ &.logged-out {
+ .box-flyout {
+ .controlbox-pane {
+ overflow-y: auto;
+ }
+ }
+ }
+
+ form.search-xmpp-contact {
+ margin: 0;
+ padding-left: 5px;
+ padding: 0 0 5px 5px;
+ input {
+ width: 8em;
+ }
+ }
+
+ .msgs-indicator {
+ margin-right: 0.5em;
+ }
+
+ a.subscribe-to-user {
+ padding-left: 2em;
+ font-weight: bold;
+ }
+
+ .conn-feedback {
+ color: var(--controlbox-head-color);
+ &.error {
+ color: var(--error-color);
+ }
+ p {
+ padding-bottom: 1em;
+ &.feedback-subject.error {
+ font-weight: bold;
+ }
+ }
+ }
+
+ #converse-login-panel, #converse-register-panel {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ #converse-login-panel {
+ flex-direction: row;
+ }
+
+ .toggle-register-login {
+ font-weight: bold;
+ }
+
+ .controlbox-pane {
+ .userinfo {
+ padding-bottom: 1em;
+
+ .username {
+ margin-left: 0.5em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .profile {
+ margin-bottom: 0.75em;
+ }
+ }
+ }
+
+ #chatrooms {
+ padding: 0;
+
+ .add-chatroom {
+ input[type=button],
+ input[type=submit],
+ input[type=text] {
+ width: 100%;
+ }
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ .controlbox-section {
+
+ .controlbox-heading {
+ font-family: var(--heading-font);
+ color: var(--controlbox-heading-color);
+ font-weight: var(--controlbox-heading-font-weight);
+ padding: 0;
+ font-size: 1.1em;
+ line-height: 1.1em;
+ text-transform: uppercase;
+ }
+
+ .controlbox-heading--groupchats {
+ color: var(--groupchats-header-color);
+ }
+
+ .controlbox-heading--contacts {
+ color: var(--chat-head-color-dark);
+ }
+
+ .controlbox-heading--headline {
+ color: var(--headlines-head-color);
+ }
+
+ .controlbox-heading__btn {
+ cursor: pointer;
+ padding: 0 0 0 1em;
+ font-size: 1em;
+ margin: var(--controlbox-heading-top-margin) 0 var(--inline-action-margin) 0;
+ text-align: center;
+ &.fa-vcard {
+ margin-top: 1em;
+ }
+ }
+ }
+
+ .dropdown {
+ a {
+ width: 143px;
+ display: inline-block;
+ }
+ li {
+ list-style: none;
+ padding-left: 0;
+ }
+ dd {
+ ul {
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ z-index: 21;
+ background-color: var(--light-background-color);
+ li:hover {
+ background-color: var(--highlight-color);
+ }
+ }
+ }
+
+ dd.search-xmpp {
+ height: 0;
+ .contact-form-container {
+ position: absolute;
+ z-index: 22;
+ form {
+ box-shadow: 1px 4px 10px 1px rgba(0, 0, 0, 0.4);
+ background-color: white;
+ }
+ }
+ li:hover {
+ background-color: var(--light-background-color);
+ }
+ }
+ dt a span {
+ cursor: pointer;
+ display: block;
+ padding: 4px 7px 0 5px;
+ }
+ }
+
+ .controlbox-panes {
+ background-color: var(--controlbox-pane-background-color);
+ height: 100%;
+ overflow-y: auto;
+ }
+
+ .controlbox-subtitle {
+ font-size: 90%;
+ padding: 0.5em;
+ text-align: right;
+ }
+
+ .controlbox-pane {
+ background-color: var(--controlbox-pane-background-color);
+ border: 0;
+ font-size: var(--font-size);
+ left: 0;
+ text-align: left;
+ overflow-x: hidden;
+ padding: 0 0 1em 0;
+
+ .controlbox-padded {
+ padding-left: 1em;
+ padding-right: 1em;
+ align-items: center;
+ line-height: normal;
+ .change-status {
+ min-width: 25px;
+ text-align: center;
+ }
+ }
+
+ .add-converse-contact {
+ margin: 0 0 0.75em 0;
+ }
+
+ .chatbox-btn {
+ margin: 0;
+ }
+
+ .switch-form {
+ text-align: center;
+ padding: 2em 0;
+ }
+ dd {
+ margin-left: 0;
+ margin-bottom: 0;
+ &.odd {
+ background-color: #DCEAC5;
+ }
+ }
+ }
+
+ .add-xmpp-contact {
+ padding: 1em 0.5em;
+ input {
+ margin: 0 0 1rem;
+ width: 100%;
+ }
+ button {
+ width: 100%;
+ }
+ }
+ }
+}
+
+.conversejs {
+ converse-chats {
+ &.converse-overlayed {
+
+ display: flex;
+ flex-direction: row-reverse;
+
+ .toggle-controlbox {
+ order: -2;
+ text-align: center;
+ background-color: var(--controlbox-head-color);
+ border-top-left-radius: var(--button-border-radius);
+ border-top-right-radius: var(--button-border-radius);
+ color: #0a0a0a;
+ float: right;
+ height: 100%;
+ margin: 0 var(--chat-gutter);
+ padding: 1em;
+ span {
+ color: var(--inverse-link-color);
+ }
+ }
+
+ #controlbox {
+ order: -1;
+ min-width: var(--controlbox-width) !important;
+ width: var(--controlbox-width);
+ .box-flyout {
+ min-width: var(--controlbox-width) !important;
+ width: var(--controlbox-width);
+ }
+
+ @media screen and (max-width: $mobile-portrait-length) {
+ margin-left: -15px;
+ }
+ @include media-breakpoint-down(sm) {
+ margin-left: -15px;
+ }
+
+ .login-trusted {
+ white-space: nowrap;
+ font-size: 90%;
+ }
+
+ #converse-login-trusted {
+ margin-top: 0.5em;
+ }
+ &:not(.logged-out) {
+ .controlbox-head {
+ height: 15px;
+ }
+ }
+
+ #converse-register, #converse-login {
+ @include make-col(12);
+ padding-bottom: 0;
+ }
+
+ #converse-register {
+ .button-cancel {
+ font-size: 90%;
+ }
+ }
+ }
+
+ .brand-heading {
+ padding-top: 0.8rem;
+ padding-left: 0.8rem;
+ width: 100%;
+ }
+ .converse-svg-logo {
+ height: 1em;
+ }
+ #controlbox {
+ #converse-login-panel {
+ height: 100%;
+ }
+ .controlbox-panes {
+ margin-top: 0.5em;
+ }
+ }
+ }
+
+ &.converse-embedded,
+ &.converse-fullscreen{
+ .controlbox-panes {
+ border-right: 0.2rem solid var(--panel-divider-color);
+ }
+ .toggle-controlbox {
+ display: none;
+ }
+ }
+
+ &.converse-embedded,
+ &.converse-fullscreen,
+ &.converse-mobile {
+ #controlbox {
+ @include make-col-ready();
+
+ @include media-breakpoint-up(md) {
+ @include make-col(4);
+ }
+ @include media-breakpoint-up(lg) {
+ @include make-col(3);
+ }
+ @include media-breakpoint-up(xl) {
+ @include make-col(2);
+ }
+
+ &.logged-out {
+ @include make-col(12);
+ }
+
+ margin: 0;
+
+ .flyout {
+ border-radius: 0;
+ }
+
+ #converse-login-panel {
+ border-radius: 0;
+ .converse-form {
+ padding: 3em 2em 3em;
+ }
+ }
+
+ .toggle-register-login {
+ line-height: var(--line-height-huge);
+ }
+
+ converse-brand-logo {
+ @include make-col(12);
+ margin-top: 5em;
+ margin-bottom: 1em;
+ .brand-heading {
+ width: 100%;
+ font-size: 500%;
+ padding: 0.7em 0 0 0;
+ opacity: 0.8;
+ color: var(--brand-heading-color);
+ }
+ .brand-subtitle {
+ font-size: 90%;
+ padding: 0.5em;
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ .brand-heading {
+ font-size: 300%;
+ }
+ }
+ }
+
+ &.logged-out {
+ @include make-col(12);
+ @include fade-in;
+ width: 100%;
+ .box-flyout {
+ width: 100%;
+ }
+ }
+ .box-flyout {
+ border: 0;
+ width: 100%;
+ z-index: 1;
+ background-color: var(--controlbox-head-color);
+
+ .controlbox-head {
+ display: none;
+ }
+ }
+
+ #converse-register, #converse-login {
+ @include make-col-ready();
+ @include make-col(8);
+ @include make-col-offset(2);
+
+ @include media-breakpoint-up(sm) {
+ @include make-col(8);
+ @include make-col-offset(2);
+ }
+ @include media-breakpoint-up(md) {
+ @include make-col(8);
+ @include make-col-offset(2);
+ }
+ @include media-breakpoint-up(lg) {
+ @include make-col(6);
+ @include make-col-offset(3);
+ }
+ .title, .instructions {
+ margin: 1em 0;
+ }
+ input[type=submit],
+ input[type=button] {
+ width: auto;
+ }
+ }
+ }
+ }
+
+ &.converse-fullscreen {
+ #controlbox {
+ margin-left: -15px;
+ @media screen and (max-width: $mobile-portrait-length) {
+ margin-left: 0;
+ }
+ @include media-breakpoint-down(sm) {
+ margin-left: 0;
+ }
+ }
+ .controlbox-panes {
+ padding-top: 2em;
+ }
+ }
+ }
+}
+
+@include media-breakpoint-down(sm) {
+
+ .conversejs {
+ left: 0;
+ right: 0;
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+
+ .converse-chatboxes {
+ margin: 0 !important;
+ flex-direction: row !important;
+ justify-content: space-between;
+
+ .converse-chatroom {
+ font-size: 14px;
+ }
+
+ .chatbox {
+ .box-flyout {
+ left: 0;
+ bottom: 0;
+ border-radius: 0;
+ width: 100vw !important;
+ height: var(--fullpage-chat-height);
+ }
+ }
+
+ #controlbox {
+ margin-left: 0;
+ width: 100vw !important;
+ .box-flyout {
+ width: 100vw !important;
+ height: var(--fullpage-chat-height);
+ margin-right: -15px;
+ }
+ .sidebar {
+ display: block;
+ }
+ }
+
+ &.sidebar-open {
+ .chatbox:not(#controlbox) {
+ display: none;
+ }
+ #controlbox {
+ .controlbox-pane {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss
new file mode 100644
index 0000000..f10c333
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss
@@ -0,0 +1,24 @@
+.conversejs {
+ #controlbox {
+ .controlbox-head {
+ display: flex;
+ flex-direction: row-reverse;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ min-height: 0;
+
+ .brand-heading {
+ color: var(--controlbox-text-color);
+ font-size: 2em;
+ }
+ .chatbox-btn {
+ margin: 0;
+ converse-icon {
+ svg {
+ fill: var(--controlbox-head-btn-color);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js
new file mode 100644
index 0000000..437acc4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js
@@ -0,0 +1,51 @@
+import tplSpinner from "templates/spinner.js";
+import { _converse, api, converse } from "@converse/headless/core.js";
+import { html } from 'lit';
+
+const { Strophe } = converse.env;
+
+
+function whenNotConnected (o) {
+ const connection_status = _converse.connfeedback.get('connection_status');
+ if ([Strophe.Status.RECONNECTING, Strophe.Status.CONNECTING].includes(connection_status)) {
+ return tplSpinner();
+ }
+ if (o['active-form'] === 'register') {
+ return html`<converse-register-panel></converse-register-panel>`;
+ }
+ return html`<converse-login-form id="converse-login-panel" class="controlbox-pane fade-in row no-gutters"></converse-login-form>`;
+}
+
+
+export default (el) => {
+ const o = el.model.toJSON();
+ const sticky_controlbox = api.settings.get('sticky_controlbox');
+
+ return html`
+ <div class="flyout box-flyout">
+ <converse-dragresize></converse-dragresize>
+ <div class="chat-head controlbox-head">
+ ${sticky_controlbox
+ ? ''
+ : html`
+ <a class="chatbox-btn close-chatbox-button" @click=${(ev) => el.close(ev)}>
+ <converse-icon class="fa fa-times" size="1em"></converse-icon>
+ </a>
+ `}
+ </div>
+ <div class="controlbox-panes">
+ <div class="controlbox-pane">
+ ${o.connected
+ ? html`
+ <converse-user-profile></converse-user-profile>
+ <converse-headlines-feeds-list class="controlbox-section"></converse-headlines-feeds-list>
+ <div id="chatrooms" class="controlbox-section"><converse-rooms-list></converse-rooms-list></div>
+ ${ api.settings.get("authentication") === _converse.ANONYMOUS ? '' :
+ html`<div id="converse-roster" class="controlbox-section"><converse-roster></converse-roster></div>`
+ }`
+ : whenNotConnected(o)
+ }
+ </div>
+ </div>
+ </div>`
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js
new file mode 100644
index 0000000..e68015c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js
@@ -0,0 +1,163 @@
+import 'shared/components/brand-heading.js';
+import tplSpinner from 'templates/spinner.js';
+import { ANONYMOUS, EXTERNAL, LOGIN, PREBIND, CONNECTION_STATUS } from '@converse/headless/shared/constants';
+import { REPORTABLE_STATUSES, PRETTY_CONNECTION_STATUS, CONNECTION_STATUS_CSS_CLASS } from '../constants.js';
+import { __ } from 'i18n';
+import { _converse, api } from '@converse/headless/core';
+import { html } from 'lit';
+
+const trust_checkbox = (checked) => {
+ const i18n_hint_trusted = __(
+ 'To improve performance, we cache your data in this browser. ' +
+ 'Uncheck this box if this is a public computer or if you want your data to be deleted when you log out. ' +
+ "It's important that you explicitly log out, otherwise not all cached data might be deleted. " +
+ 'Please note, when using an untrusted device, OMEMO encryption is NOT available.'
+ );
+ const i18n_trusted = __('This is a trusted device');
+ return html`
+ <div class="form-group form-check login-trusted">
+ <input
+ id="converse-login-trusted"
+ type="checkbox"
+ class="form-check-input"
+ name="trusted"
+ ?checked=${checked}
+ />
+ <label for="converse-login-trusted" class="form-check-label login-trusted__desc">${i18n_trusted}</label>
+
+ <converse-icon
+ class="fa fa-info-circle"
+ data-toggle="popover"
+ data-title="Trusted device?"
+ data-content="${i18n_hint_trusted}"
+ size="1.2em"
+ title="${i18n_hint_trusted}"
+ ></converse-icon>
+ </div>
+ `;
+};
+
+const connection_url_input = () => {
+ const i18n_connection_url = __('Connection URL');
+ const i18n_form_help = __('HTTP or websocket URL that is used to connect to your XMPP server');
+ const i18n_placeholder = __('e.g. wss://example.org/xmpp-websocket');
+ return html`
+ <div class="form-group fade-in">
+ <label for="converse-conn-url">${i18n_connection_url}</label>
+ <p class="form-help instructions">${i18n_form_help}</p>
+ <input
+ id="converse-conn-url"
+ class="form-control"
+ type="url"
+ name="connection-url"
+ placeholder="${i18n_placeholder}"
+ />
+ </div>
+ `;
+};
+
+const password_input = () => {
+ const i18n_password = __('Password');
+ return html`
+ <div class="form-group">
+ <label for="converse-login-password">${i18n_password}</label>
+ <input
+ id="converse-login-password"
+ class="form-control"
+ required="required"
+ value="${api.settings.get('password') ?? ''}"
+ type="password"
+ name="password"
+ placeholder="${i18n_password}"
+ />
+ </div>
+ `;
+};
+
+const tplRegisterLink = () => {
+ const i18n_create_account = __('Create an account');
+ const i18n_hint_no_account = __("Don't have a chat account?");
+ return html`
+ <fieldset class="switch-form">
+ <p>${i18n_hint_no_account}</p>
+ <p>
+ <a class="register-account toggle-register-login" href="#converse/register">${i18n_create_account}</a>
+ </p>
+ </fieldset>
+ `;
+};
+
+const tplShowRegisterLink = () => {
+ return (
+ api.settings.get('allow_registration') &&
+ !api.settings.get('auto_login') &&
+ _converse.pluggable.plugins['converse-register'].enabled(_converse)
+ );
+};
+
+const auth_fields = (el) => {
+ const authentication = api.settings.get('authentication');
+ const i18n_login = __('Log in');
+ const i18n_xmpp_address = __('XMPP Address');
+ const locked_domain = api.settings.get('locked_domain');
+ const default_domain = api.settings.get('default_domain');
+ const placeholder_username = ((locked_domain || default_domain) && __('Username')) || __('user@domain');
+ const show_trust_checkbox = api.settings.get('allow_user_trust_override');
+
+ return html`
+ <div class="form-group">
+ <label for="converse-login-jid">${i18n_xmpp_address}:</label>
+ <input
+ id="converse-login-jid"
+ ?autofocus=${api.settings.get('auto_focus') ? true : false}
+ @changed=${el.validate}
+ value="${api.settings.get('jid') ?? ''}"
+ required
+ class="form-control"
+ type="text"
+ name="jid"
+ placeholder="${placeholder_username}"
+ />
+ </div>
+ ${authentication !== EXTERNAL ? password_input() : ''}
+ ${api.settings.get('show_connection_url_input') ? connection_url_input() : ''}
+ ${show_trust_checkbox ? trust_checkbox(show_trust_checkbox === 'off' ? false : true) : ''}
+ <fieldset class="form-group buttons">
+ <input class="btn btn-primary" type="submit" value="${i18n_login}" />
+ </fieldset>
+ ${tplShowRegisterLink() ? tplRegisterLink() : ''}
+ `;
+};
+
+const form_fields = (el) => {
+ const authentication = api.settings.get('authentication');
+ const i18n_disconnected = __('Disconnected');
+ const i18n_anon_login = __('Click here to log in anonymously');
+ return html`
+ ${authentication == LOGIN || authentication == EXTERNAL ? auth_fields(el) : ''}
+ ${authentication == ANONYMOUS
+ ? html`<input class="btn btn-primary login-anon" type="submit" value="${i18n_anon_login}" />`
+ : ''}
+ ${authentication == PREBIND ? html`<p>${i18n_disconnected}</p>` : ''}
+ `;
+};
+
+export default (el) => {
+ const connection_status = _converse.connfeedback.get('connection_status');
+ let feedback_class, pretty_status;
+ if (REPORTABLE_STATUSES.includes(connection_status)) {
+ pretty_status = PRETTY_CONNECTION_STATUS[connection_status];
+ feedback_class = CONNECTION_STATUS_CSS_CLASS[connection_status];
+ }
+ const conn_feedback_message = _converse.connfeedback.get('message');
+ return html` <converse-brand-heading></converse-brand-heading>
+ <form id="converse-login" class="converse-form" method="post" @submit=${el.onLoginFormSubmitted}>
+ <div class="conn-feedback fade-in ${!pretty_status ? 'hidden' : feedback_class}">
+ <p class="feedback-subject">${pretty_status}</p>
+ <p class="feedback-message ${!conn_feedback_message ? 'hidden' : ''}">${conn_feedback_message}</p>
+ </div>
+ ${CONNECTION_STATUS[connection_status] === 'CONNECTING'
+ ? tplSpinner({ 'classes': 'hor_centered' })
+ : form_fields(el)}
+ </form>`;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js
new file mode 100644
index 0000000..57819fc
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js
@@ -0,0 +1,6 @@
+import { html } from "lit";
+import { navigateToControlBox } from '../utils.js';
+
+export default (jid) => {
+ return html`<converse-icon size="1em" class="fa fa-arrow-left" @click=${() => navigateToControlBox(jid)}></converse-icon>`
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js
new file mode 100644
index 0000000..2ce005c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js
@@ -0,0 +1,8 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+
+export default (o) => {
+ const i18n_toggle = api.connection.connected() ? __('Chat Contacts') : __('Toggle chat');
+ return html`<a id="toggle-controlbox" class="toggle-controlbox ${o.hide ? 'hidden' : ''}" @click=${o.onClick}><span class="toggle-feedback">${i18n_toggle}</span></a>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js
new file mode 100644
index 0000000..01100c0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js
@@ -0,0 +1,107 @@
+/*global mock, converse */
+
+const $msg = converse.env.$msg;
+const u = converse.env.utils;
+
+describe("The Controlbox", function () {
+
+ it("can be opened by clicking a DOM element with class 'toggle-controlbox'",
+ mock.initConverse([], {}, async function (_converse) {
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ document.querySelector('.toggle-controlbox').click();
+ expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxOpened', jasmine.any(Object));
+ const el = await u.waitUntil(() => document.querySelector("#controlbox"));
+ expect(u.isVisible(el)).toBe(true);
+ }));
+
+
+ it("can be closed by clicking a DOM element with class 'close-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ const view = _converse.chatboxviews.get('controlbox');
+
+ spyOn(view, 'close').and.callThrough();
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ view.querySelector('.close-chatbox-button').click();
+ expect(view.close).toHaveBeenCalled();
+ expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxClosed', jasmine.any(Object));
+ }));
+
+
+ describe("The \"Contacts\" section", function () {
+
+ it("can be used to add contact and it checks for case-sensivity",
+ mock.initConverse([], {}, async function (_converse) {
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ // Adding two contacts one with Capital initials and one with small initials of same JID (Case sensitive check)
+ _converse.roster.create({
+ jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[0]
+ });
+ _converse.roster.create({
+ jid: mock.pend_names[0].replace(/ /g,'.') + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[0]
+ });
+ const rosterview = await u.waitUntil(() => document.querySelector('converse-roster'));
+ await u.waitUntil(() => Array.from(rosterview.querySelectorAll('.roster-group li')).filter(u.isVisible).length, 700);
+ // Checking that only one entry is created because both JID is same (Case sensitive check)
+ expect(Array.from(rosterview.querySelectorAll('li')).filter(u.isVisible).length).toBe(1);
+ }));
+
+ it("shows the number of unread mentions received",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+ await u.waitUntil(() => _converse.chatboxes.length);
+ const chatview = _converse.chatboxviews.get(sender_jid);
+ chatview.model.set({'minimized': true});
+
+ const el = document.querySelector('converse-chats');
+ expect(el.querySelector('.restore-chat .message-count') === null).toBeTruthy();
+ const rosterview = document.querySelector('converse-roster');
+ expect(rosterview.querySelector('.msgs-indicator') === null).toBeTruthy();
+
+ let msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => rosterview.querySelectorAll(".msgs-indicator").length);
+ spyOn(chatview.model, 'handleUnreadMessage').and.callThrough();
+ await u.waitUntil(() => el.querySelector('.restore-chat .message-count')?.textContent === '1');
+ expect(rosterview.querySelector('.msgs-indicator').textContent).toBe('1');
+
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello again').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatview.model.handleUnreadMessage.calls.count());
+ await u.waitUntil(() => el.querySelector('.restore-chat .message-count')?.textContent === '2');
+ expect(rosterview.querySelector('.msgs-indicator').textContent).toBe('2');
+ chatview.model.set({'minimized': false});
+ await u.waitUntil(() => el.querySelector('.restore-chat .message-count') === null);
+ await u.waitUntil(() => rosterview.querySelector('.msgs-indicator') === null);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js
new file mode 100644
index 0000000..cd8a25b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js
@@ -0,0 +1,70 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+
+describe("The Login Form", function () {
+
+ it("contains a checkbox to indicate whether the computer is trusted or not",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ allow_registration: false },
+ async function (_converse) {
+
+ const cbview = await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+ mock.toggleControlBox();
+ const checkboxes = cbview.querySelectorAll('input[type="checkbox"]');
+ expect(checkboxes.length).toBe(1);
+
+ const checkbox = checkboxes[0];
+ const label = cbview.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
+ expect(label.textContent).toBe('This is a trusted device');
+ expect(checkbox.checked).toBe(true);
+
+ cbview.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
+ cbview.querySelector('input[name="password"]').value = 'secret';
+
+ expect(_converse.config.get('trusted')).toBe(true);
+ expect(_converse.getDefaultStore()).toBe('persistent');
+ cbview.querySelector('input[type="submit"]').click();
+ expect(_converse.config.get('trusted')).toBe(true);
+ expect(_converse.getDefaultStore()).toBe('persistent');
+
+ checkbox.click();
+ cbview.querySelector('input[type="submit"]').click();
+ expect(_converse.config.get('trusted')).toBe(false);
+ expect(_converse.getDefaultStore()).toBe('session');
+ }));
+
+ it("checkbox can be set to false by default",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ allow_user_trust_override: 'off',
+ allow_registration: false },
+ async function (_converse) {
+
+ await u.waitUntil(() => _converse.chatboxviews.get('controlbox'))
+ const cbview = _converse.chatboxviews.get('controlbox');
+ mock.toggleControlBox();
+ const checkboxes = cbview.querySelectorAll('input[type="checkbox"]');
+ expect(checkboxes.length).toBe(1);
+
+ const checkbox = checkboxes[0];
+ const label = cbview.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
+ expect(label.textContent).toBe('This is a trusted device');
+ expect(checkbox.checked).toBe(false);
+
+ cbview.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
+ cbview.querySelector('input[name="password"]').value = 'secret';
+
+ cbview.querySelector('input[type="submit"]').click();
+ expect(_converse.config.get('trusted')).toBe(false);
+ expect(_converse.getDefaultStore()).toBe('session');
+
+ checkbox.click();
+ cbview.querySelector('input[type="submit"]').click();
+ expect(_converse.config.get('trusted')).toBe(true);
+ expect(_converse.getDefaultStore()).toBe('persistent');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js
new file mode 100644
index 0000000..e2293f6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js
@@ -0,0 +1,27 @@
+import tplControlboxToggle from "./templates/toggle.js";
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api } from "@converse/headless/core";
+import { showControlBox } from './utils.js';
+
+
+class ControlBoxToggle extends CustomElement {
+
+ async connectedCallback () {
+ super.connectedCallback();
+ await api.waitUntil('initialized')
+ this.model = _converse.chatboxes.get('controlbox');
+ this.listenTo(this.model, 'change:closed', () => this.requestUpdate());
+ this.requestUpdate();
+ }
+
+ render () {
+ return tplControlboxToggle({
+ 'onClick': showControlBox,
+ 'hide': !this.model?.get('closed')
+ });
+ }
+}
+
+api.elements.define('converse-controlbox-toggle', ControlBoxToggle);
+
+export default ControlBoxToggle;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js
new file mode 100644
index 0000000..9da262e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js
@@ -0,0 +1,102 @@
+import { __ } from 'i18n/index.js';
+import { _converse, api, converse } from "@converse/headless/core.js";
+
+const { Strophe, u } = converse.env;
+
+export function addControlBox () {
+ const m = _converse.chatboxes.add(new _converse.ControlBox({'id': 'controlbox'}));
+ _converse.chatboxviews.get('controlbox')?.setModel();
+ return m;
+}
+
+export function showControlBox (ev) {
+ ev?.preventDefault?.();
+ const controlbox = _converse.chatboxes.get('controlbox') || addControlBox();
+ u.safeSave(controlbox, {'closed': false});
+}
+
+export function navigateToControlBox (jid) {
+ showControlBox();
+ const model = _converse.chatboxes.get(jid);
+ u.safeSave(model, {'hidden': true});
+}
+
+export function disconnect () {
+ /* Upon disconnection, set connected to `false`, so that if
+ * we reconnect, "onConnected" will be called,
+ * to fetch the roster again and to send out a presence stanza.
+ */
+ const view = _converse.chatboxviews.get('controlbox');
+ view.model.set({ 'connected': false });
+ return view;
+}
+
+export function clearSession () {
+ const chatboxviews = _converse?.chatboxviews;
+ const view = chatboxviews && chatboxviews.get('controlbox');
+ if (view) {
+ u.safeSave(view.model, { 'connected': false });
+ if (view?.controlbox_pane) {
+ view.controlbox_pane.remove();
+ delete view.controlbox_pane;
+ }
+ }
+}
+
+export function onChatBoxesFetched () {
+ const controlbox = _converse.chatboxes.get('controlbox') || addControlBox();
+ controlbox.save({ 'connected': true });
+}
+
+
+/**
+ * Given the login `<form>` element, parse its data and update the
+ * converse settings with the supplied JID, password and connection URL.
+ * @param { HTMLElement } form
+ * @param { Object } settings - Extra settings that may be passed in and will
+ * also be set together with the form settings.
+ */
+export function updateSettingsWithFormData (form, settings={}) {
+ const form_data = new FormData(form);
+
+ const connection_url = form_data.get('connection-url');
+ if (connection_url?.startsWith('ws')) {
+ settings['websocket_url'] = connection_url;
+ } else if (connection_url?.startsWith('http')) {
+ settings['bosh_service_url'] = connection_url;
+ }
+
+ let jid = form_data.get('jid');
+ if (api.settings.get('locked_domain')) {
+ const last_part = '@' + api.settings.get('locked_domain');
+ if (jid.endsWith(last_part)) {
+ jid = jid.substr(0, jid.length - last_part.length);
+ }
+ jid = Strophe.escapeNode(jid) + last_part;
+ } else if (api.settings.get('default_domain') && !jid.includes('@')) {
+ jid = jid + '@' + api.settings.get('default_domain');
+ }
+ settings['jid'] = jid;
+ settings['password'] = form_data.get('password');
+
+ api.settings.set(settings);
+
+ _converse.config.save({ 'trusted': (form_data.get('trusted') && true) || false });
+}
+
+
+export function validateJID (form) {
+ const jid_element = form.querySelector('input[name=jid]');
+ if (
+ jid_element.value &&
+ !api.settings.get('locked_domain') &&
+ !api.settings.get('default_domain') &&
+ !u.isValidJID(jid_element.value)
+ ) {
+ jid_element.setCustomValidity(__('Please enter a valid XMPP address'));
+ return false;
+ }
+ jid_element.setCustomValidity('');
+ return true;
+}
+
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js
new file mode 100644
index 0000000..0742fbd
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js
@@ -0,0 +1,13 @@
+import tplDragresize from "../templates/dragresize.js";
+import { CustomElement } from 'shared/components/element.js';
+import { api } from '@converse/headless/core.js';
+
+
+class ConverseDragResize extends CustomElement {
+
+ render () { // eslint-disable-line class-methods-use-this
+ return tplDragresize();
+ }
+}
+
+api.elements.define('converse-dragresize', ConverseDragResize);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js
new file mode 100644
index 0000000..990a560
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js
@@ -0,0 +1,97 @@
+/**
+ * @module converse-dragresize
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './components/dragresize.js';
+import { applyDragResistance, onMouseUp, onMouseMove } from './utils.js';
+import DragResizableMixin from './mixin.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+converse.plugins.add('converse-dragresize', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ */
+ dependencies: ['converse-chatview', 'converse-headlines-view', 'converse-muc-views'],
+
+ enabled (_converse) {
+ return _converse.api.settings.get('view_mode') == 'overlayed';
+ },
+
+ // Overrides mentioned here will be picked up by converse.js's
+ // plugin architecture they will replace existing methods on the
+ // relevant objects or classes.
+ overrides: {
+ ChatBox: {
+ initialize () {
+ const result = this.__super__.initialize.apply(this, arguments);
+ const height = this.get('height');
+ const width = this.get('width');
+ const save = this.get('id') === 'controlbox' ? a => this.set(a) : a => this.save(a);
+ save({
+ 'height': applyDragResistance(height, this.get('default_height')),
+ 'width': applyDragResistance(width, this.get('default_width'))
+ });
+ return result;
+ }
+ }
+ },
+
+ initialize () {
+ /* The initialize function gets called as soon as the plugin is
+ * loaded by converse.js's plugin machinery.
+ */
+ api.settings.extend({
+ 'allow_dragresize': true
+ });
+
+ Object.assign(_converse.ChatBoxView.prototype, DragResizableMixin);
+ Object.assign(_converse.ChatRoomView.prototype, DragResizableMixin);
+ if (_converse.ControlBoxView) {
+ Object.assign(_converse.ControlBoxView.prototype, DragResizableMixin);
+ }
+
+ /************************ BEGIN Event Handlers ************************/
+ function registerGlobalEventHandlers () {
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ }
+
+ function unregisterGlobalEventHandlers () {
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ }
+
+ /**
+ * This function registers mousedown and mouseup events hadlers to
+ * all iframes in the DOM when converse UI resizing events are called
+ * to prevent mouse drag stutter effect which is bad user experience.
+ * @function dragresizeOverIframeHandler
+ * @param {Object} e - dragging node element.
+ */
+ function dragresizeOverIframeHandler (e) {
+ const iframes = document.getElementsByTagName('iframe');
+ for (let iframe of iframes) {
+ e.addEventListener('mousedown', () => {
+ iframe.style.pointerEvents = 'none';
+ }, { once: true });
+
+ e.addEventListener('mouseup', () => {
+ iframe.style.pointerEvents = 'initial';
+ }, { once: true });
+ }
+ }
+
+ api.listen.on('registeredGlobalEventHandlers', registerGlobalEventHandlers);
+ api.listen.on('unregisteredGlobalEventHandlers', unregisterGlobalEventHandlers);
+ api.listen.on('beforeShowingChatView', view => view.initDragResize().setDimensions());
+ api.listen.on('startDiagonalResize', dragresizeOverIframeHandler);
+ api.listen.on('startHorizontalResize', dragresizeOverIframeHandler);
+ api.listen.on('startVerticalResize', dragresizeOverIframeHandler);
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js
new file mode 100644
index 0000000..a05ef94
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js
@@ -0,0 +1,114 @@
+import debounce from 'lodash-es/debounce';
+import { _converse } from '@converse/headless/core';
+import { applyDragResistance } from './utils.js';
+
+const DragResizableMixin = {
+ initDragResize () {
+ const view = this;
+ const debouncedSetDimensions = debounce(() => view.setDimensions());
+ window.addEventListener('resize', view.debouncedSetDimensions);
+ this.listenTo(this.model, 'destroy', () => window.removeEventListener('resize', debouncedSetDimensions));
+
+ // Determine and store the default box size.
+ // We need this information for the drag-resizing feature.
+ const flyout = this.querySelector('.box-flyout');
+ const style = window.getComputedStyle(flyout);
+
+ if (this.model.get('height') === undefined) {
+ const height = parseInt(style.height.replace(/px$/, ''), 10);
+ const width = parseInt(style.width.replace(/px$/, ''), 10);
+ this.model.set('height', height);
+ this.model.set('default_height', height);
+ this.model.set('width', width);
+ this.model.set('default_width', width);
+ }
+ const min_width = style['min-width'];
+ const min_height = style['min-height'];
+ this.model.set('min_width', min_width.endsWith('px') ? Number(min_width.replace(/px$/, '')) : 0);
+ this.model.set('min_height', min_height.endsWith('px') ? Number(min_height.replace(/px$/, '')) : 0);
+ // Initialize last known mouse position
+ this.prev_pageY = 0;
+ this.prev_pageX = 0;
+ if (_converse.connection?.connected) {
+ this.height = this.model.get('height');
+ this.width = this.model.get('width');
+ }
+ return this;
+ },
+
+ resizeChatBox (ev) {
+ let diff;
+ if (_converse.resizing.direction.indexOf('top') === 0) {
+ diff = ev.pageY - this.prev_pageY;
+ if (diff) {
+ this.height =
+ this.height - diff > (this.model.get('min_height') || 0)
+ ? this.height - diff
+ : this.model.get('min_height');
+ this.prev_pageY = ev.pageY;
+ this.setChatBoxHeight(this.height);
+ }
+ }
+ if (_converse.resizing.direction.includes('left')) {
+ diff = this.prev_pageX - ev.pageX;
+ if (diff) {
+ this.width =
+ this.width + diff > (this.model.get('min_width') || 0)
+ ? this.width + diff
+ : this.model.get('min_width');
+ this.prev_pageX = ev.pageX;
+ this.setChatBoxWidth(this.width);
+ }
+ }
+ },
+
+ setDimensions () {
+ // Make sure the chat box has the right height and width.
+ this.adjustToViewport();
+ this.setChatBoxHeight(this.model.get('height'));
+ this.setChatBoxWidth(this.model.get('width'));
+ },
+
+ setChatBoxHeight (height) {
+ if (height) {
+ height = applyDragResistance(height, this.model.get('default_height')) + 'px';
+ } else {
+ height = '';
+ }
+ const flyout_el = this.querySelector('.box-flyout');
+ if (flyout_el !== null) {
+ flyout_el.style.height = height;
+ }
+ },
+
+ setChatBoxWidth (width) {
+ if (width) {
+ width = applyDragResistance(width, this.model.get('default_width')) + 'px';
+ } else {
+ width = '';
+ }
+ this.style.width = width;
+ const flyout_el = this.querySelector('.box-flyout');
+ if (flyout_el !== null) {
+ flyout_el.style.width = width;
+ }
+ },
+
+ adjustToViewport () {
+ /* Event handler called when viewport gets resized. We remove
+ * custom width/height from chat boxes.
+ */
+ const viewport_width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
+ const viewport_height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
+ if (viewport_width <= 480) {
+ this.model.set('height', undefined);
+ this.model.set('width', undefined);
+ } else if (viewport_width <= this.model.get('width')) {
+ this.model.set('width', undefined);
+ } else if (viewport_height <= this.model.get('height')) {
+ this.model.set('height', undefined);
+ }
+ }
+};
+
+export default DragResizableMixin;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js
new file mode 100644
index 0000000..c795ffe
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js
@@ -0,0 +1,8 @@
+import { html } from 'lit';
+import { onStartDiagonalResize, onStartHorizontalResize, onStartVerticalResize } from '../utils.js';
+
+export default () => html`
+ <div class="dragresize dragresize-top" @mousedown="${onStartVerticalResize}"></div>
+ <div class="dragresize dragresize-topleft" @mousedown="${onStartDiagonalResize}"></div>
+ <div class="dragresize dragresize-left" @mousedown="${onStartHorizontalResize}"></div>
+`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js
new file mode 100644
index 0000000..f15877e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js
@@ -0,0 +1,117 @@
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { u } = converse.env;
+
+
+export function onStartVerticalResize (ev, trigger = true) {
+ if (!api.settings.get('allow_dragresize')) {
+ return true;
+ }
+ ev.preventDefault();
+ // Record element attributes for mouseMove().
+ const flyout = u.ancestor(ev.target, '.box-flyout');
+ const style = window.getComputedStyle(flyout);
+ const chatbox_el = flyout.parentElement;
+ chatbox_el.height = parseInt(style.height.replace(/px$/, ''), 10);
+ _converse.resizing = {
+ 'chatbox': chatbox_el,
+ 'direction': 'top'
+ };
+ chatbox_el.prev_pageY = ev.pageY;
+ if (trigger) {
+ /**
+ * Triggered once the user starts to vertically resize a {@link _converse.ChatBoxView}
+ * @event _converse#startVerticalResize
+ * @example _converse.api.listen.on('startVerticalResize', (view) => { ... });
+ */
+ api.trigger('startVerticalResize', chatbox_el);
+ }
+}
+
+export function onStartHorizontalResize (ev, trigger = true) {
+ if (!api.settings.get('allow_dragresize')) {
+ return true;
+ }
+ ev.preventDefault();
+ const flyout = u.ancestor(ev.target, '.box-flyout');
+ const style = window.getComputedStyle(flyout);
+ const chatbox_el = flyout.parentElement;
+ chatbox_el.width = parseInt(style.width.replace(/px$/, ''), 10);
+ _converse.resizing = {
+ 'chatbox': chatbox_el,
+ 'direction': 'left'
+ };
+ chatbox_el.prev_pageX = ev.pageX;
+ if (trigger) {
+ /**
+ * Triggered once the user starts to horizontally resize a {@link _converse.ChatBoxView}
+ * @event _converse#startHorizontalResize
+ * @example _converse.api.listen.on('startHorizontalResize', (view) => { ... });
+ */
+ api.trigger('startHorizontalResize', chatbox_el);
+ }
+}
+
+export function onStartDiagonalResize (ev) {
+ onStartHorizontalResize(ev, false);
+ onStartVerticalResize(ev, false);
+ _converse.resizing.direction = 'topleft';
+ /**
+ * Triggered once the user starts to diagonally resize a {@link _converse.ChatBoxView}
+ * @event _converse#startDiagonalResize
+ * @example _converse.api.listen.on('startDiagonalResize', (view) => { ... });
+ */
+ api.trigger('startDiagonalResize', this);
+}
+
+/**
+ * Applies some resistance to `value` around the `default_value`.
+ * If value is close enough to `default_value`, then it is returned, otherwise
+ * `value` is returned.
+ * @param { number } value
+ * @param { number } default_value
+ * @returns { number }
+ */
+export function applyDragResistance (value, default_value) {
+ if (value === undefined) {
+ return undefined;
+ } else if (default_value === undefined) {
+ return value;
+ }
+ const resistance = 10;
+ if (value !== default_value && Math.abs(value - default_value) < resistance) {
+ return default_value;
+ }
+ return value;
+}
+
+export function onMouseMove (ev) {
+ if (!_converse.resizing || !api.settings.get('allow_dragresize')) {
+ return true;
+ }
+ ev.preventDefault();
+ _converse.resizing.chatbox.resizeChatBox(ev);
+}
+
+export function onMouseUp (ev) {
+ if (!_converse.resizing || !api.settings.get('allow_dragresize')) {
+ return true;
+ }
+ ev.preventDefault();
+ const height = applyDragResistance(
+ _converse.resizing.chatbox.height,
+ _converse.resizing.chatbox.model.get('default_height')
+ );
+ const width = applyDragResistance(
+ _converse.resizing.chatbox.width,
+ _converse.resizing.chatbox.model.get('default_width')
+ );
+ if (api.connection.connected()) {
+ _converse.resizing.chatbox.model.save({ 'height': height });
+ _converse.resizing.chatbox.model.save({ 'width': width });
+ } else {
+ _converse.resizing.chatbox.model.set({ 'height': height });
+ _converse.resizing.chatbox.model.set({ 'width': width });
+ }
+ _converse.resizing = null;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js
new file mode 100644
index 0000000..72b6733
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js
@@ -0,0 +1,27 @@
+/**
+ * @module converse-fullscreen
+ * @license Mozilla Public License (MPLv2)
+ * @copyright 2022, the Converse.js contributors
+ */
+import { api, converse } from "@converse/headless/core";
+import { isUniView } from '@converse/headless/utils/core.js';
+
+import './styles/fullscreen.scss';
+
+
+converse.plugins.add('converse-fullscreen', {
+
+ enabled () {
+ return isUniView();
+ },
+
+ initialize () {
+ api.settings.extend({
+ chatview_avatar_height: 50,
+ chatview_avatar_width: 50,
+ hide_open_bookmarks: true,
+ show_controlbox_by_default: true,
+ sticky_controlbox: true
+ });
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss
new file mode 100644
index 0000000..70a7712
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss
@@ -0,0 +1,5 @@
+body.converse-fullscreen {
+ margin: 0;
+ background-color: var(--global-background-color);
+ overflow: hidden;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js
new file mode 100644
index 0000000..bd60f05
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js
@@ -0,0 +1,37 @@
+import tplFeedsList from './templates/feeds-list.js';
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api } from '@converse/headless/core';
+
+/**
+ * Custom element which renders a list of headline feeds
+ * @class
+ * @namespace _converse.HeadlinesFeedsList
+ * @memberOf _converse
+ */
+export class HeadlinesFeedsList extends CustomElement {
+
+ initialize () {
+ this.model = _converse.chatboxes;
+ this.listenTo(this.model, 'add', (m) => this.renderIfHeadline(m));
+ this.listenTo(this.model, 'remove', (m) => this.renderIfHeadline(m));
+ this.listenTo(this.model, 'destroy', (m) => this.renderIfHeadline(m));
+ this.requestUpdate();
+ }
+
+ render () {
+ return tplFeedsList(this);
+ }
+
+ renderIfHeadline (model) {
+ return model?.get('type') === _converse.HEADLINES_TYPE && this.requestUpdate();
+ }
+
+ async openHeadline (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ const jid = ev.target.getAttribute('data-headline-jid');
+ const feed = await api.headlines.get(jid);
+ feed.maybeShow(true);
+ }
+}
+
+api.elements.define('converse-headlines-feeds-list', HeadlinesFeedsList);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js
new file mode 100644
index 0000000..6df5335
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js
@@ -0,0 +1,59 @@
+import tplChatHead from './templates/chat-head.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core.js";
+
+
+export default class HeadlinesHeading extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String },
+ }
+ }
+
+ async initialize () {
+ this.model = _converse.chatboxes.get(this.jid);
+ await this.model.initialized;
+ this.requestUpdate();
+ }
+
+ render () {
+ return tplChatHead({
+ ...this.model.toJSON(),
+ ...{
+ 'display_name': this.model.getDisplayName(),
+ 'heading_buttons_promise': this.getHeadingButtons()
+ }
+ });
+ }
+
+ /**
+ * Returns a list of objects which represent buttons for the headlines header.
+ * @async
+ * @emits _converse#getHeadingButtons
+ * @method HeadlinesHeading#getHeadingButtons
+ */
+ getHeadingButtons () {
+ const buttons = [];
+ if (!api.settings.get('singleton')) {
+ buttons.push({
+ 'a_class': 'close-chatbox-button',
+ 'handler': ev => this.close(ev),
+ 'i18n_text': __('Close'),
+ 'i18n_title': __('Close these announcements'),
+ 'icon_class': 'fa-times',
+ 'name': 'close',
+ 'standalone': api.settings.get('view_mode') === 'overlayed'
+ });
+ }
+ return _converse.api.hook('getHeadingButtons', this, buttons);
+ }
+
+ close (ev) {
+ ev.preventDefault();
+ this.model.close();
+ }
+}
+
+api.elements.define('converse-headlines-heading', HeadlinesHeading);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js
new file mode 100644
index 0000000..4252cba
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js
@@ -0,0 +1,34 @@
+/**
+ * @module converse-headlines-view
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../chatview/index.js';
+import './view.js';
+import { HeadlinesFeedsList } from './feed-list.js';
+import { _converse, converse } from '@converse/headless/core';
+
+import './styles/headlines.scss';
+import './styles/headlines-head.scss';
+
+
+converse.plugins.add('converse-headlines-view', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ *
+ * NB: These plugins need to have already been loaded by the bundler
+ */
+ dependencies: ['converse-headlines', 'converse-chatview'],
+
+ initialize () {
+ _converse.HeadlinesFeedsList = HeadlinesFeedsList;
+
+ // Deprecated
+ _converse.HeadlinesPanel = HeadlinesFeedsList;
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss
new file mode 100644
index 0000000..a74af8c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss
@@ -0,0 +1,53 @@
+.conversejs {
+ .chatbox {
+ &.headlines {
+ converse-headlines-heading {
+ &.chat-head {
+ .chatbox-title--no-desc {
+ padding: 0.75rem 1rem;
+ }
+
+ &.chat-head-chatbox {
+ background-color: var(--headlines-head-bg-color);
+ border-bottom: var(--headlines-head-border-bottom);
+ }
+ background-color: var(--headlines-head-bg-color);
+
+ .chatbox-title__text {
+ color: var(--headlines-head-text-color) !important;
+ }
+
+ converse-dropdown {
+ .dropdown-menu {
+ converse-icon {
+ svg {
+ fill: var(--headlines-color);
+ }
+ }
+ }
+ }
+
+ .chatbox-btn {
+ converse-icon {
+ svg {
+ fill: var(--headlines-head-fg-color);
+ }
+ }
+ }
+ }
+ }
+
+ converse-chats {
+ &.converse-fullscreen {
+ .chatbox.headlines {
+ .chat-head {
+ &.chat-head-chatbox {
+ background-color: var(--headlines-head-bg-color);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss
new file mode 100644
index 0000000..517f921
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss
@@ -0,0 +1,51 @@
+.conversejs {
+ .chatbox {
+ &.headlines {
+ .chat-body {
+ background-color: var(--background);
+ .chat-message {
+ color: var(--headline-message-color);
+ }
+ hr {
+ border-bottom: var(--headline-separator-border-bottom);
+ }
+ }
+ .chat-content {
+ height: 100%;
+ }
+ }
+
+ }
+
+ .message {
+ &.chat-msg {
+ &.headline {
+ .chat-msg__body {
+ margin-left: 0;
+ }
+ }
+ }
+ }
+
+ #controlbox {
+ .controlbox-section {
+ .controlbox-heading--headline {
+ color: var(--headlines-head-text-color);
+ }
+ }
+ }
+
+
+ converse-chats {
+ &.converse-fullscreen {
+ .chatbox.headlines {
+ .box-flyout {
+ background-color: var(--headlines-head-text-color);
+ }
+ .flyout {
+ border-color: var(--headlines-head-text-color);
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js
new file mode 100644
index 0000000..bed5e0b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js
@@ -0,0 +1,21 @@
+import { _converse } from '@converse/headless/core';
+import { html } from "lit";
+import { until } from 'lit/directives/until.js';
+import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js';
+
+
+export default (o) => {
+ return html`
+ <div class="chatbox-title ${ o.status ? '' : "chatbox-title--no-desc"}">
+ <div class="chatbox-title--row">
+ ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
+ <div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div>
+ </div>
+ <div class="chatbox-title__buttons row no-gutters">
+ ${ until(getDropdownButtons(o.heading_buttons_promise), '') }
+ ${ until(getStandaloneButtons(o.heading_buttons_promise), '') }
+ </div>
+ </div>
+ ${ o.status ? html`<p class="chat-head__desc">${ o.status }</p>` : '' }
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js
new file mode 100644
index 0000000..9656e20
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js
@@ -0,0 +1,33 @@
+import { __ } from 'i18n';
+import { _converse } from '@converse/headless/core';
+import { html } from "lit";
+
+function tplHeadlinesFeedsListItem (el, feed) {
+ const open_title = __('Click to open this server message');
+ return html`
+ <div class="list-item controlbox-padded d-flex flex-row"
+ data-headline-jid="${feed.get('jid')}">
+ <a class="list-item-link open-headline available-room w-100"
+ data-headline-jid="${feed.get('jid')}"
+ title="${open_title}"
+ @click=${ev => el.openHeadline(ev)}
+ href="#">${feed.get('jid')}</a>
+ </div>
+ `;
+}
+
+export default (el) => {
+ const feeds = el.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE);
+ const heading_headline = __('Announcements');
+ return html`
+ <div class="controlbox-section" id="headline">
+ <div class="d-flex controlbox-padded ${ feeds.length ? '' : 'hidden' }">
+ <span class="w-100 controlbox-heading controlbox-heading--headline">${heading_headline}</span>
+ </div>
+ </div>
+ <div class="list-container list-container--headline ${ feeds.length ? '' : 'hidden' }">
+ <div class="items-list rooms-list headline-list">
+ ${ feeds.map(feed => tplHeadlinesFeedsListItem(el, feed)) }
+ </div>
+ </div>`
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js
new file mode 100644
index 0000000..9b5fa89
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js
@@ -0,0 +1,18 @@
+import '../heading.js';
+import { html } from "lit";
+
+export default (model) => html`
+ <div class="flyout box-flyout">
+ <converse-dragresize></converse-dragresize>
+ ${ model ? html`
+ <converse-headlines-heading jid="${model.get('jid')}" class="chat-head chat-head-chatbox row no-gutters">
+ </converse-headlines-heading>
+ <div class="chat-body">
+ <div class="chat-content" aria-live="polite">
+ <converse-chat-content
+ class="chat-content__messages"
+ jid="${model.get('jid')}"></converse-chat-content>
+ </div>
+ </div>` : '' }
+ </div>
+`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js
new file mode 100644
index 0000000..beb4a4c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js
@@ -0,0 +1,169 @@
+/*global mock, converse, _ */
+
+describe("A headlines box", function () {
+
+ it("will not open nor display non-headline messages",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const { $msg } = converse.env;
+ /* XMPP spam message:
+ *
+ * <message xmlns="jabber:client"
+ * to="romeo@montague.lit"
+ * type="chat"
+ * from="gapowa20102106@rds-rostov.ru/Adium">
+ * <nick xmlns="http://jabber.org/protocol/nick">-wwdmz</nick>
+ * <body>SORRY FOR THIS ADVERT</body
+ * </message
+ */
+ const stanza = $msg({
+ 'xmlns': 'jabber:client',
+ 'to': 'romeo@montague.lit',
+ 'type': 'chat',
+ 'from': 'gapowa20102106@rds-rostov.ru/Adium',
+ })
+ .c('nick', {'xmlns': "http://jabber.org/protocol/nick"}).t("-wwdmz").up()
+ .c('body').t('SORRY FOR THIS ADVERT');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => setTimeout(resolve, 100));
+ const headlines = await _converse.api.headlines.get();
+ expect(headlines.length).toBe(0);
+ }));
+
+ it("will open and display headline messages", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const { u, $msg} = converse.env;
+ /* <message from='notify.example.com'
+ * to='romeo@im.example.com'
+ * type='headline'
+ * xml:lang='en'>
+ * <subject>SIEVE</subject>
+ * <body>&lt;juliet@example.com&gt; You got mail.</body>
+ * <x xmlns='jabber:x:oob'>
+ * <url>
+ * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+ * </url>
+ * </x>
+ * </message>
+ */
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'notify.example.com',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('subject').t('SIEVE').up()
+ .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+ .c('x', {'xmlns': 'jabber:x:oob'})
+ .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com'));
+ const view = _converse.chatboxviews.get('notify.example.com');
+ expect(view.model.get('show_avatar')).toBeFalsy();
+ expect(view.querySelector('img.avatar')).toBe(null);
+ }));
+
+ it("will show headline messages in the controlbox", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ const { u, $msg} = converse.env;
+ /* <message from='notify.example.com'
+ * to='romeo@im.example.com'
+ * type='headline'
+ * xml:lang='en'>
+ * <subject>SIEVE</subject>
+ * <body>&lt;juliet@example.com&gt; You got mail.</body>
+ * <x xmlns='jabber:x:oob'>
+ * <url>
+ * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+ * </url>
+ * </x>
+ * </message>
+ */
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'notify.example.com',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('subject').t('SIEVE').up()
+ .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+ .c('x', {'xmlns': 'jabber:x:oob'})
+ .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const view = _converse.chatboxviews.get('controlbox');
+ await u.waitUntil(() => view.querySelectorAll(".open-headline").length);
+ expect(view.querySelectorAll('.open-headline').length).toBe(1);
+ expect(view.querySelector('.open-headline').text).toBe('notify.example.com');
+ }));
+
+ it("will remove headline messages from the controlbox if closed", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const { u, $msg} = converse.env;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ /* <message from='notify.example.com'
+ * to='romeo@im.example.com'
+ * type='headline'
+ * xml:lang='en'>
+ * <subject>SIEVE</subject>
+ * <body>&lt;juliet@example.com&gt; You got mail.</body>
+ * <x xmlns='jabber:x:oob'>
+ * <url>
+ * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18
+ * </url>
+ * </x>
+ * </message>
+ */
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'notify.example.com',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('subject').t('SIEVE').up()
+ .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+ .c('x', {'xmlns': 'jabber:x:oob'})
+ .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const cbview = _converse.chatboxviews.get('controlbox');
+ await u.waitUntil(() => cbview.querySelectorAll(".open-headline").length);
+ const hlview = _converse.chatboxviews.get('notify.example.com');
+ await u.isVisible(hlview);
+ const close_el = await u.waitUntil(() => hlview.querySelector('.close-chatbox-button'));
+ close_el.click();
+ await u.waitUntil(() => cbview.querySelectorAll(".open-headline").length === 0);
+ expect(cbview.querySelectorAll('.open-headline').length).toBe(0);
+ }));
+
+ it("will not show a headline messages from a full JID if allow_non_roster_messaging is false",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'allow_non_roster_messaging': false}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const { $msg } = converse.env;
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'andre5114@jabber.snc.ru/Spark',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('nick').t('gpocy').up()
+ .c('body').t('Здравствуйте друзья');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js
new file mode 100644
index 0000000..a0c6d43
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js
@@ -0,0 +1,55 @@
+import BaseChatView from 'shared/chat/baseview.js';
+import tplHeadlines from './templates/headlines.js';
+import { _converse, api } from '@converse/headless/core';
+
+
+class HeadlinesFeedView extends BaseChatView {
+
+ async initialize() {
+ _converse.chatboxviews.add(this.jid, this);
+
+ this.model = _converse.chatboxes.get(this.jid);
+ this.model.disable_mam = true; // Don't do MAM queries for this box
+ this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
+ this.listenTo(this.model, 'change:hidden', () => this.afterShown());
+ this.listenTo(this.model, 'destroy', this.remove);
+ this.listenTo(this.model.messages, 'add', () => this.requestUpdate());
+ this.listenTo(this.model.messages, 'remove', () => this.requestUpdate());
+ this.listenTo(this.model.messages, 'reset', () => this.requestUpdate());
+
+ await this.model.messages.fetched;
+ this.model.maybeShow();
+ /**
+ * Triggered once the { @link _converse.HeadlinesFeedView } has been initialized
+ * @event _converse#headlinesBoxViewInitialized
+ * @type { _converse.HeadlinesFeedView }
+ * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... });
+ */
+ api.trigger('headlinesBoxViewInitialized', this);
+ }
+
+ render () {
+ return tplHeadlines(this.model);
+ }
+
+ async close (ev) {
+ ev?.preventDefault?.();
+ if (_converse.router.history.getFragment() === 'converse/chat?jid=' + this.model.get('jid')) {
+ _converse.router.navigate('');
+ }
+ await this.model.close(ev);
+ return this;
+ }
+
+ getNotifications () { // eslint-disable-line class-methods-use-this
+ // Override method in ChatBox. We don't show notifications for
+ // headlines boxes.
+ return [];
+ }
+
+ afterShown () {
+ this.model.clearUnreadMsgCounter();
+ }
+}
+
+api.elements.define('converse-headlines', HeadlinesFeedView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js
new file mode 100644
index 0000000..13359f9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js
@@ -0,0 +1,18 @@
+/**
+ * @description UI code XEP-0313 Message Archive Management
+ * @copyright 2021, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './placeholder.js';
+import { api, converse } from '@converse/headless/core';
+import { fetchMessagesOnScrollUp, getPlaceholderTemplate } from './utils.js';
+
+
+converse.plugins.add('converse-mam-views', {
+ dependencies: ['converse-mam', 'converse-chatview', 'converse-muc-views'],
+
+ initialize () {
+ api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
+ api.listen.on('getMessageTemplate', getPlaceholderTemplate);
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js
new file mode 100644
index 0000000..bf2a0e8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js
@@ -0,0 +1,33 @@
+import { CustomElement } from 'shared/components/element.js';
+import tplPlaceholder from './templates/placeholder.js';
+import { api } from "@converse/headless/core";
+import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils.js';
+
+import './styles/placeholder.scss';
+
+
+class Placeholder extends CustomElement {
+
+ static get properties () {
+ return {
+ 'model': { type: Object }
+ }
+ }
+
+ render () {
+ return tplPlaceholder(this);
+ }
+
+ async fetchMissingMessages (ev) {
+ ev?.preventDefault?.();
+ this.model.set('fetching', true);
+ const options = {
+ 'before': this.model.get('before'),
+ 'start': this.model.get('start')
+ }
+ await fetchArchivedMessages(this.model.collection.chatbox, options);
+ this.model.destroy();
+ }
+}
+
+api.elements.define('converse-mam-placeholder', Placeholder);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss
new file mode 100644
index 0000000..c0adf86
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss
@@ -0,0 +1,31 @@
+converse-mam-placeholder {
+ .mam-placeholder {
+ position: relative;
+ height: 2em;
+ margin: 0.5em 0;
+ &:before,
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ left: 0;
+ right: 0;
+ }
+ &:before {
+ height: 1em;
+ top: 1em;
+ background: linear-gradient(-135deg, lightgray 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, lightgray 0.5em, transparent 0) 0 0.5em;
+ background-position: top left;
+ background-repeat: repeat-x;
+ background-size: 1em 1em;
+ }
+ &:after {
+ height: 1em;
+ top: 0.75em;
+ background: linear-gradient(-135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em;
+ background-position: top left;
+ background-repeat: repeat-x;
+ background-size: 1em 1em;
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js
new file mode 100644
index 0000000..87ca27d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js
@@ -0,0 +1,10 @@
+import tplSpinner from 'templates/spinner.js';
+import { __ } from 'i18n';
+import { html } from 'lit/html.js';
+
+export default (el) => {
+ return el.model.get('fetching') ? tplSpinner({'classes': 'hor_centered'}) :
+ html`<a @click="${(ev) => el.fetchMissingMessages(ev)}" title="${__('Click to load missing messages')}">
+ <div class="message mam-placeholder"></div>
+ </a>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js
new file mode 100644
index 0000000..1c601c2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js
@@ -0,0 +1,1215 @@
+/*global mock, converse */
+
+const Model = converse.env.Model;
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const $msg = converse.env.$msg;
+const dayjs = converse.env.dayjs;
+const u = converse.env.utils;
+const sizzle = converse.env.sizzle;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+// Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
+describe("Message Archive Management", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ describe("The XEP-0313 Archive", function () {
+
+ it("is queried when the user scrolls up",
+ mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+ let msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Meet me at the dance");
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Thrice the brinded cat hath mew'd.");
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ expect(view.model.messages.length).toBe(2);
+
+ while (sent_IQs.length) { sent_IQs.pop(); }
+ _converse.api.trigger('chatBoxScrolledUp', view);
+ stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${stanza.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field><field var="with"><value>mercutio@montague.lit</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before>${view.model.messages.at(0).get('stanza_id romeo@montague.lit')}</before><max>2</max></set></query>`+
+ `</iq>`
+ );
+ }));
+
+ it("is queried when the user enters a new MUC",
+ mock.initConverse(['discoInitialized'],
+ {
+ 'archived_messages_page_size': 2,
+ 'muc_clear_messages_on_leave': false,
+ }, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'orchard@chat.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ let view = _converse.chatboxviews.get(muc_jid);
+ let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>2</max></set>`+
+ `</query>`+
+ `</iq>`);
+
+ let first_msg_id = _converse.connection.getUniqueId();
+ let last_msg_id = _converse.connection.getUniqueId();
+ let message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>2nd Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>3rd Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ // XXX: Even though the count is 3, when fetching messages for
+ // the first time, we don't paginate, so that message
+ // is not fetched. The user needs to manually load older
+ // messages for it to be fetched.
+ // TODO: we need to add a clickable link to load older messages
+ let result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${last_msg_id}</last>
+ <count>3</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 2);
+ view.close();
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ await u.waitUntil(() => _converse.chatboxes.length === 1);
+
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.model.messages.length);
+
+ iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><after>${message.querySelector('result').getAttribute('id')}</after><max>2</max></set>`+
+ `</query>`+
+ `</iq>`);
+
+ first_msg_id = _converse.connection.getUniqueId();
+ last_msg_id = _converse.connection.getUniqueId();
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>4th Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:18:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>5th Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${last_msg_id}</last>
+ <count>5</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 4);
+
+ iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="orchard@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm">`+
+ `<after>${last_msg_id}</after>`+
+ `<max>2</max>`+
+ `</set>`+
+ `</query>`+
+ `</iq>`);
+
+ const msg_id = _converse.connection.getUniqueId();
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:19:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>6th Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns="urn:xmpp:mam:2" complete="true">
+ <set xmlns="http://jabber.org/protocol/rsm">
+ <first index="0">${msg_id}</first>
+ <last>${msg_id}</last>
+ <count>6</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 5);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message", 1000);
+ }));
+
+ it("queries for messages since the most recent cached message in a newly entered MUC",
+ mock.initConverse(['discoInitialized'],
+ {
+ 'archived_messages_page_size': 2,
+ 'muc_nickname_from_jid': false,
+ 'muc_clear_messages_on_leave': false,
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'orchard@chat.shakespeare.lit';
+ const nick = 'romeo';
+ const room_creation_promise = api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ await room_creation_promise;
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+
+ // Create "cached" message to test that only messages newer than the
+ // last cached message with body text will be fetched
+ view.model.messages.create({
+ 'type': 'groupchat',
+ 'to': muc_jid,
+ 'from': `${_converse.bare_jid}/orchard`,
+ 'body': 'Hello world',
+ 'message': 'Hello world',
+ 'time': '2021-02-02T12:00:00Z'
+ });
+ // Hack: Manually set attributes that would otherwise happen in fetchMessages
+ view.model.messages.fetched_flag = true;
+ view.model.afterMessagesFetched(view.model.messages);
+ view.model.messages.fetched.resolve();
+
+ const affs = api.settings.get('muc_fetch_members');
+ const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []);
+ await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations);
+
+ const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `<field var="start"><value>2021-02-02T12:00:00.000Z</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max></set>`+
+ `</query>`+
+ `</iq>`);
+ }));
+ });
+
+ describe("An archived message", function () {
+ describe("when received", function () {
+
+ it("is discarded if it doesn't come from the right sender",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+ let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': 'impersonator@capulet.lit', 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Meet me at the dance");
+ spyOn(converse.env.log, 'warn');
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ expect(converse.env.log.warn).toHaveBeenCalledWith(`Ignoring alleged MAM message from ${msg.nodeTree.getAttribute('from')}`);
+
+ msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Thrice the brinded cat hath mew'd.");
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(el => el.textContent === "Thrice the brinded cat hath mew'd.").length, 1000);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
+ }));
+
+ it("is not discarded if it comes from the right sender",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+ let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': _converse.bare_jid, 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Meet me at the dance");
+ spyOn(converse.env.log, 'warn');
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': _converse.bare_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'from': contact_jid,
+ 'type':'chat'
+ }).c('body').t("Thrice the brinded cat hath mew'd.");
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ expect(view.model.messages.length).toBe(2);
+ expect(view.model.messages.at(0).get('message')).toBe("Meet me at the dance");
+ expect(view.model.messages.at(1).get('message')).toBe("Thrice the brinded cat hath mew'd.");
+ }));
+
+ it("updates the is_archived value of an already cached version",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'romeo');
+
+ const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
+ let stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" type="groupchat" from="trek-radio@conference.lightwitch.org/some1">
+ <body>Hello</body>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('is_archived')).toBe(false);
+ expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="trek-radio@conference.lightwitch.org">
+ <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+ <message from="trek-radio@conference.lightwitch.org/some1" type="groupchat">
+ <body>Hello</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ spyOn(view.model, 'updateMessage').and.callThrough();
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+ const result = view.model.getDuplicateMessage.calls.all()[0].returnValue
+ expect(result instanceof _converse.Message).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ await u.waitUntil(() => view.model.updateMessage.calls.count());
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('is_archived')).toBe(true);
+ expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621');
+ }));
+
+ it("isn't shown as duplicate by comparing its stanza id or archive id",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'jcbrand');
+ const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org');
+ let stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="jcbrand@lightwitch.org/converse.js-73057452" type="groupchat" from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)">
+ <body>negan</body>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ // Not sure whether such a race-condition might pose a problem
+ // in "real-world" situations.
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="jcbrand@lightwitch.org/converse.js-73057452"
+ from="trek-radio@conference.lightwitch.org">
+ <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+ <message from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)" type="groupchat">
+ <body>negan</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+ const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
+ expect(result instanceof _converse.Message).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ }));
+
+ it("isn't shown as duplicate by comparing only the archive id",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'discuss@conference.conversejs.org', 'romeo');
+ const view = _converse.chatboxviews.get('discuss@conference.conversejs.org');
+ let stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
+ <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
+ <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
+ <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
+ </x>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org">
+ <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/>
+ <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel">
+ <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="prezel@blubber.im" role="participant"/>
+ </x>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ expect(view.model.getDuplicateMessage.calls.count()).toBe(1);
+ const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue
+ expect(result instanceof _converse.Message).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ }))
+ });
+ });
+
+ describe("The archive.query API", function () {
+
+ it("can be used to query for all archived messages",
+ mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+
+ const sendIQ = _converse.connection.sendIQ;
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ _converse.api.archive.query();
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client"><query queryid="${queryid}" xmlns="urn:xmpp:mam:2"/></iq>`);
+ }));
+
+ it("can be used to query for all messages to/from a particular JID",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ _converse.api.archive.query({'with':'juliet@capulet.lit'});
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `<field var="with">`+
+ `<value>juliet@capulet.lit</value>`+
+ `</field>`+
+ `</x>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("can be used to query for archived messages from a chat room",
+ mock.initConverse(['statusInitialized'], {}, async function (_converse) {
+
+ const room_jid = 'coven@chat.shakespeare.lit';
+ _converse.api.archive.query({'with': room_jid, 'groupchat': true});
+ await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const stanza = await u.waitUntil(
+ () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `</x>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("checks whether returned MAM messages from a MUC room are from the right JID",
+ mock.initConverse(['statusInitialized'], {}, async function (_converse) {
+
+ const room_jid = 'coven@chat.shakespeare.lit';
+ const promise = _converse.api.archive.query({'with': room_jid, 'groupchat': true, 'max':'10'});
+
+ await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]);
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+
+ /* <message id='iasd207' from='coven@chat.shakespeare.lit' to='hag66@shakespeare.lit/pda'>
+ * <result xmlns='urn:xmpp:mam:2' queryid='g27' id='34482-21985-73620'>
+ * <forwarded xmlns='urn:xmpp:forward:0'>
+ * <delay xmlns='urn:xmpp:delay' stamp='2002-10-13T23:58:37Z'/>
+ * <message xmlns="jabber:client"
+ * from='coven@chat.shakespeare.lit/firstwitch'
+ * id='162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2'
+ * type='groupchat'>
+ * <body>Thrice the brinded cat hath mew'd.</body>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='none'
+ * jid='witch1@shakespeare.lit'
+ * role='participant' />
+ * </x>
+ * </message>
+ * </forwarded>
+ * </result>
+ * </message>
+ */
+ const msg1 = $msg({'id':'iasd207', 'from': 'other@chat.shakespear.lit', 'to': 'romeo@montague.lit'})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'34482-21985-73620'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to':'romeo@montague.lit',
+ 'id':'162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2',
+ 'from':'coven@chat.shakespeare.lit/firstwitch',
+ 'type':'groupchat' })
+ .c('body').t("Thrice the brinded cat hath mew'd.");
+ _converse.connection._dataRecv(mock.createRequest(msg1));
+
+ /* Send an <iq> stanza to indicate the end of the result set.
+ *
+ * <iq type='result' id='juliet1'>
+ * <fin xmlns='urn:xmpp:mam:2'>
+ * <set xmlns='http://jabber.org/protocol/rsm'>
+ * <first index='0'>28482-98726-73623</first>
+ * <last>09af3-cc343-b409f</last>
+ * <count>20</count>
+ * </set>
+ * </iq>
+ */
+ const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const result = await promise;
+ expect(result.messages.length).toBe(0);
+ }));
+
+ it("can be used to query for all messages in a certain timespan",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ const start = '2010-06-07T00:00:00Z';
+ const end = '2010-07-07T13:23:54Z';
+ _converse.api.archive.query({
+ 'start': start,
+ 'end': end
+ });
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `<field var="start">`+
+ `<value>${dayjs(start).toISOString()}</value>`+
+ `</field>`+
+ `<field var="end">`+
+ `<value>${dayjs(end).toISOString()}</value>`+
+ `</field>`+
+ `</x>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("throws a TypeError if an invalid date is provided",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ try {
+ await _converse.api.archive.query({'start': 'not a real date'});
+ } catch (e) {
+ expect(() => {throw e}).toThrow(new TypeError('archive.query: invalid date provided for: start'));
+ }
+ }));
+
+ it("can be used to query for all messages after a certain time",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ if (!_converse.disco_entities.get(_converse.domain).features.findWhere({'var': Strophe.NS.MAM})) {
+ _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM});
+ }
+ const start = '2010-06-07T00:00:00Z';
+ _converse.api.archive.query({'start': start});
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `<field var="start">`+
+ `<value>${dayjs(start).toISOString()}</value>`+
+ `</field>`+
+ `</x>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("can be used to query for a limited set of results",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ const start = '2010-06-07T00:00:00Z';
+ _converse.api.archive.query({'start': start, 'max':10});
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `<field var="start">`+
+ `<value>${dayjs(start).toISOString()}</value>`+
+ `</field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm">`+
+ `<max>10</max>`+
+ `</set>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("can be used to page through results",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ const start = '2010-06-07T00:00:00Z';
+ _converse.api.archive.query({
+ 'start': start,
+ 'after': '09af3-cc343-b409f',
+ 'max':10
+ });
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `<field var="start">`+
+ `<value>${dayjs(start).toISOString()}</value>`+
+ `</field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm">`+
+ `<after>09af3-cc343-b409f</after>`+
+ `<max>10</max>`+
+ `</set>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("accepts \"before\" with an empty string as value to reverse the order",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ _converse.api.archive.query({'before': '', 'max':10});
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>urn:xmpp:mam:2</value>`+
+ `</field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm">`+
+ `<before></before>`+
+ `<max>10</max>`+
+ `</set>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("returns an object which includes the messages and a _converse.RSM object",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
+
+ await u.waitUntil(() => sent_stanza);
+ const queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+
+ /* <message id='aeb213' to='juliet@capulet.lit/chamber'>
+ * <result xmlns='urn:xmpp:mam:2' queryid='f27' id='28482-98726-73623'>
+ * <forwarded xmlns='urn:xmpp:forward:0'>
+ * <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
+ * <message xmlns='jabber:client'
+ * to='juliet@capulet.lit/balcony'
+ * from='romeo@montague.lit/orchard'
+ * type='chat'>
+ * <body>Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.</body>
+ * </message>
+ * </forwarded>
+ * </result>
+ * </message>
+ */
+ const msg1 = $msg({'id':'aeb212', 'to':'juliet@capulet.lit/chamber'})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to':'juliet@capulet.lit/balcony',
+ 'from':'romeo@montague.lit/orchard',
+ 'type':'chat' })
+ .c('body').t("Call me but love, and I'll be new baptized;");
+ _converse.connection._dataRecv(mock.createRequest(msg1));
+
+ const msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73624'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to':'juliet@capulet.lit/balcony',
+ 'from':'romeo@montague.lit/orchard',
+ 'type':'chat' })
+ .c('body').t("Henceforth I never will be Romeo.");
+ _converse.connection._dataRecv(mock.createRequest(msg2));
+
+ /* Send an <iq> stanza to indicate the end of the result set.
+ *
+ * <iq type='result' id='juliet1'>
+ * <fin xmlns='urn:xmpp:mam:2'>
+ * <set xmlns='http://jabber.org/protocol/rsm'>
+ * <first index='0'>28482-98726-73623</first>
+ * <last>09af3-cc343-b409f</last>
+ * <count>20</count>
+ * </set>
+ * </iq>
+ */
+ const stanza = $iq({'type': 'result', 'id': IQ_id})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const result = await promise;
+ expect(result.messages.length).toBe(2);
+ expect(result.messages[0].outerHTML).toBe(msg1.nodeTree.outerHTML);
+ expect(result.messages[1].outerHTML).toBe(msg2.nodeTree.outerHTML);
+ expect(result.rsm.query.max).toBe('10');
+ expect(result.rsm.result.count).toBe(16);
+ expect(result.rsm.result.first).toBe('23452-4534-1');
+ expect(result.rsm.result.last).toBe('09af3-cc343-b409f');
+ }));
+ });
+
+ describe("The default preference", function () {
+
+ it("is set once server support for MAM has been confirmed",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { api } = _converse;
+
+ const entity = await _converse.api.disco.entities.get(_converse.domain);
+ spyOn(_converse, 'onMAMPreferences').and.callThrough();
+ api.settings.set('message_archiving', 'never');
+
+ const feature = new Model({
+ 'var': Strophe.NS.MAM
+ });
+ spyOn(feature, 'save').and.callFake(feature.set); // Save will complain about a url not being set
+
+ entity.onFeatureAdded(feature);
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="get"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<prefs xmlns="urn:xmpp:mam:2"/>`+
+ `</iq>`);
+
+ /* Example 20. Server responds with current preferences
+ *
+ * <iq type='result' id='juliet2'>
+ * <prefs xmlns='urn:xmpp:mam:0' default='roster'>
+ * <always/>
+ * <never/>
+ * </prefs>
+ * </iq>
+ */
+ let stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+ .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'roster'})
+ .c('always').c('jid').t('romeo@montague.lit').up().up()
+ .c('never').c('jid').t('montague@montague.lit');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => _converse.onMAMPreferences.calls.count());
+ expect(_converse.onMAMPreferences).toHaveBeenCalled();
+
+ sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="set"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<prefs default="never" xmlns="urn:xmpp:mam:2">`+
+ `<always><jid>romeo@montague.lit</jid></always>`+
+ `<never><jid>montague@montague.lit</jid></never>`+
+ `</prefs>`+
+ `</iq>`
+ );
+
+ expect(feature.get('preference')).toBe(undefined);
+ /* <iq type='result' id='juliet3'>
+ * <prefs xmlns='urn:xmpp:mam:0' default='always'>
+ * <always>
+ * <jid>romeo@montague.lit</jid>
+ * </always>
+ * <never>
+ * <jid>montague@montague.lit</jid>
+ * </never>
+ * </prefs>
+ * </iq>
+ */
+ stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+ .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'})
+ .c('always').up()
+ .c('never');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => feature.save.calls.count());
+ expect(feature.save).toHaveBeenCalled();
+ expect(feature.get('preferences')['default']).toBe('never'); // eslint-disable-line dot-notation
+ }));
+ });
+});
+
+describe("Chatboxes", function () {
+ describe("A Chatbox", function () {
+
+ it("will fetch archived messages once it's opened",
+ mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+
+ let sent_stanza, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_stanza = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ await u.waitUntil(() => sent_stanza);
+ const stanza_el = sent_stanza;
+ const queryid = stanza_el.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${stanza_el.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `<field var="with"><value>mercutio@montague.lit</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+
+ `</query>`+
+ `</iq>`
+ );
+ const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': contact_jid,
+ 'from': _converse.bare_jid,
+ 'type':'chat' })
+ .c('body').t("Call me but love, and I'll be new baptized;");
+ _converse.connection._dataRecv(mock.createRequest(msg1));
+ const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73624'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': contact_jid,
+ 'from': _converse.bare_jid,
+ 'type':'chat' })
+ .c('body').t("Henceforth I never will be Romeo.");
+ _converse.connection._dataRecv(mock.createRequest(msg2));
+ const stanza = $iq({'type': 'result', 'id': IQ_id})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('23452-4534-1').up()
+ .c('last').t('09af3-cc343-b409f').up()
+ .c('count').t('16');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ }));
+
+ it("will show an error message if the MAM query times out",
+ mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+
+ const sendIQ = _converse.connection.sendIQ;
+
+ let timeout_happened = false;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sendIQ.bind(this)(iq, callback, errback);
+ if (!timeout_happened) {
+ if (typeof(iq.tree) === "function") {
+ iq = iq.tree();
+ }
+ if (sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length) {
+ // We emulate a timeout event
+ callback(null);
+ timeout_happened = true;
+ }
+ }
+ });
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
+ let queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `<field var="with"><value>mercutio@montague.lit</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+
+ `</query>`+
+ `</iq>`);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(30000);
+ expect(view.model.messages.at(0).get('type')).toBe('error');
+ expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
+
+ let err_message = await u.waitUntil(() => view.querySelector('.message.chat-error'));
+ err_message.querySelector('.retry').click();
+
+ while (_converse.connection.IQ_stanzas.length) {
+ _converse.connection.IQ_stanzas.pop();
+ }
+ sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop());
+ queryid = sent_stanza.querySelector('query').getAttribute('queryid');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `<field var="with"><value>mercutio@montague.lit</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+
+ `</query>`+
+ `</iq>`);
+
+ const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73623'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': contact_jid,
+ 'from': _converse.bare_jid,
+ 'type':'chat' })
+ .c('body').t("Call me but love, and I'll be new baptized;");
+ _converse.connection._dataRecv(mock.createRequest(msg1));
+
+ const msg2 = $msg({'id':'aeb213', 'to': contact_jid})
+ .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73624'})
+ .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})
+ .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:18:25Z'}).up()
+ .c('message', {
+ 'xmlns':'jabber:client',
+ 'to': contact_jid,
+ 'from': _converse.bare_jid,
+ 'type':'chat' })
+ .c('body').t("Henceforth I never will be Romeo.");
+ _converse.connection._dataRecv(mock.createRequest(msg2));
+
+ const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2', 'complete': true})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t('28482-98726-73623').up()
+ .c('last').t('28482-98726-73624').up()
+ .c('count').t('2');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length === 2, 500);
+ err_message = view.querySelector('.message.chat-error');
+ expect(err_message).toBe(null);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js
new file mode 100644
index 0000000..b71dba3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js
@@ -0,0 +1,217 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+describe("Message Archive Management", function () {
+
+ describe("A placeholder message", function () {
+
+ it("is created to indicate a gap in the history",
+ mock.initConverse(
+ ['discoInitialized'],
+ {
+ 'archived_messages_page_size': 2,
+ 'persistent_store': 'localStorage',
+ 'mam_request_all_pages': false
+ },
+ async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'orchard@chat.shakespeare.lit';
+ const msgid = u.getUniqueId();
+
+ // We put an already cached message in localStorage
+ const key_prefix = `converse-test-persistent/${_converse.bare_jid}`;
+ let key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}`;
+ localStorage.setItem(key, `["converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}"]`);
+
+ key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}`;
+ const msgtxt = "existing cached message";
+ localStorage.setItem(key, `{
+ "body": "${msgtxt}",
+ "message": "${msgtxt}",
+ "editable":true,
+ "from": "${muc_jid}/romeo",
+ "fullname": "Romeo",
+ "id": "${msgid}",
+ "is_archived": false,
+ "is_only_emojis": false,
+ "nick": "jc",
+ "origin_id": "${msgid}",
+ "received": "2021-06-15T11:17:15.451Z",
+ "sender": "me",
+ "stanza_id ${muc_jid}": "1e1c2355-c5b8-4d48-9e33-1310724578c2",
+ "time": "2021-06-15T11:17:15.424Z",
+ "type": "groupchat",
+ "msgid": "${msgid}"
+ }`);
+
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const first_msg_id = _converse.connection.getUniqueId();
+ const second_msg_id = _converse.connection.getUniqueId();
+ const third_msg_id = _converse.connection.getUniqueId();
+ let message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${second_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>2nd MAM Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${third_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T12:16:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>3rd MAM Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ let result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${second_msg_id}</first>
+ <last>${third_msg_id}</last>
+ <count>3</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 4);
+
+ const msg = view.model.messages.at(1);
+ expect(msg instanceof _converse.MAMPlaceholderMessage).toBe(true);
+ expect(msg.get('time')).toBe('2021-06-15T11:18:22.999Z');
+
+ const placeholder_el = view.querySelector('converse-mam-placeholder');
+ placeholder_el.firstElementChild.click();
+ await u.waitUntil(() => view.querySelector('converse-mam-placeholder .spinner'));
+
+ iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `<field var="start"><value>2021-06-15T11:17:15.424Z</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before>${view.model.messages.at(2).get(`stanza_id ${muc_jid}`)}</before>`+
+ `<max>2</max>`+
+ `</set>`+
+ `</query>`+
+ `</iq>`);
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:20Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>1st MAM Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2' complete='true'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${first_msg_id}</last>
+ <count>1</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 4);
+ await u.waitUntil(() => view.querySelector('converse-mam-placeholder') === null);
+ }));
+
+ it("is not created when there isn't a gap because the cached history is empty",
+ mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2},
+ async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'orchard@chat.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+
+ const first_msg_id = _converse.connection.getUniqueId();
+ const last_msg_id = _converse.connection.getUniqueId();
+ let message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>2nd Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>3rd Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ // Clear so that we don't match the older query
+ while (sent_IQs.length) { sent_IQs.pop(); }
+
+ const result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${last_msg_id}</last>
+ <count>3</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 2);
+ expect(true).toBe(true);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js
new file mode 100644
index 0000000..666a189
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js
@@ -0,0 +1,44 @@
+import MAMPlaceholderMessage from '@converse/headless/plugins/mam/placeholder.js';
+import log from '@converse/headless/log.js';
+import { _converse, api } from '@converse/headless/core';
+import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
+import { html } from 'lit/html.js';
+
+
+export function getPlaceholderTemplate (message, tpl) {
+ if (message instanceof MAMPlaceholderMessage) {
+ return html`<converse-mam-placeholder .model=${message}></converse-mam-placeholder>`;
+ } else {
+ return tpl;
+ }
+}
+
+export async function fetchMessagesOnScrollUp (view) {
+ if (view.model.ui.get('chat-content-spinner-top')) {
+ return;
+ }
+ if (view.model.messages.length) {
+ const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE;
+ const oldest_message = view.model.getOldestMessage();
+ if (oldest_message) {
+ const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
+ const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
+ view.model.ui.set('chat-content-spinner-top', true);
+ try {
+ if (stanza_id) {
+ await fetchArchivedMessages(view.model, { 'before': stanza_id });
+ } else {
+ await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
+ }
+ } catch (e) {
+ log.error(e);
+ view.model.ui.set('chat-content-spinner-top', false);
+ return;
+ }
+ if (api.settings.get('allow_url_history_change')) {
+ _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
+ }
+ setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js
new file mode 100644
index 0000000..972eb11
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js
@@ -0,0 +1,40 @@
+import tplTrimmedChat from "../templates/trimmed_chat.js";
+import { CustomElement } from 'shared/components/element.js';
+import { api } from "@converse/headless/core";
+import { maximize } from '../utils.js';
+
+
+export default class MinimizedChat extends CustomElement {
+
+ static get properties () {
+ return {
+ model: { type: Object },
+ title: { type: String },
+ type: { type: String },
+ num_unread: { type: Number }
+ }
+ }
+
+ render () {
+ const data = {
+ 'close': ev => this.close(ev),
+ 'num_unread': this.num_unread,
+ 'restore': ev => this.restore(ev),
+ 'title': this.title,
+ 'type': this.type
+ };
+ return tplTrimmedChat(data);
+ }
+
+ close (ev) {
+ ev?.preventDefault();
+ this.model.close();
+ }
+
+ restore (ev) {
+ ev?.preventDefault();
+ maximize(this.model);
+ }
+}
+
+api.elements.define('converse-minimized-chat', MinimizedChat);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js
new file mode 100644
index 0000000..702662c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js
@@ -0,0 +1,118 @@
+/**
+ * @module converse-minimize
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './view.js';
+import './components/minimized-chat.js';
+import debounce from 'lodash-es/debounce';
+import MinimizedChatsToggle from './toggle.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import {
+ addMinimizeButtonToChat,
+ addMinimizeButtonToMUC,
+ initializeChat,
+ maximize,
+ minimize,
+ onMinimizedChanged,
+ trimChats
+} from './utils.js';
+
+import './styles/minimize.scss';
+
+
+converse.plugins.add('converse-minimize', {
+ /* Optional dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin. They are called "optional" because they might not be
+ * available, in which case any overrides applicable to them will be
+ * ignored.
+ *
+ * It's possible however to make optional dependencies non-optional.
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found.
+ */
+ dependencies: [
+ "converse-chatview",
+ "converse-controlbox",
+ "converse-muc-views",
+ "converse-headlines-view",
+ "converse-dragresize"
+ ],
+
+ enabled (_converse) {
+ return _converse.api.settings.get("view_mode") === 'overlayed';
+ },
+
+ // Overrides mentioned here will be picked up by converse.js's
+ // plugin architecture they will replace existing methods on the
+ // relevant objects or classes.
+ // New functions which don't exist yet can also be added.
+ overrides: {
+ ChatBox: {
+ maybeShow (force) {
+ if (!force && this.get('minimized')) {
+ // Must return the chatbox
+ return this;
+ }
+ return this.__super__.maybeShow.apply(this, arguments);
+ },
+
+ isHidden () {
+ return this.__super__.isHidden.call(this) || this.get('minimized');
+ }
+ },
+
+ ChatBoxView: {
+ isNewMessageHidden () {
+ return this.model.get('minimized') ||
+ this.__super__.isNewMessageHidden.apply(this, arguments);
+ },
+
+ setChatBoxHeight (height) {
+ if (!this.model.get('minimized')) {
+ return this.__super__.setChatBoxHeight.call(this, height);
+ }
+ },
+
+ setChatBoxWidth (width) {
+ if (!this.model.get('minimized')) {
+ return this.__super__.setChatBoxWidth.call(this, width);
+ }
+ }
+ }
+ },
+
+
+ initialize () {
+ api.settings.extend({'no_trimming': false});
+
+ api.promises.add('minimizedChatsInitialized');
+
+ _converse.MinimizedChatsToggle = MinimizedChatsToggle;
+ _converse.minimize = { trimChats, minimize, maximize };
+
+ function onChatInitialized (model) {
+ initializeChat(model);
+ model.on( 'change:minimized', () => onMinimizedChanged(model));
+ }
+
+ api.listen.on('chatBoxViewInitialized', view => _converse.minimize.trimChats(view));
+ api.listen.on('chatRoomViewInitialized', view => _converse.minimize.trimChats(view));
+ api.listen.on('controlBoxOpened', view => _converse.minimize.trimChats(view));
+ api.listen.on('chatBoxInitialized', onChatInitialized);
+ api.listen.on('chatRoomInitialized', onChatInitialized);
+
+ api.listen.on('getHeadingButtons', (view, buttons) => {
+ if (view.model.get('type') === _converse.CHATROOMS_TYPE) {
+ return addMinimizeButtonToMUC(view, buttons);
+ } else {
+ return addMinimizeButtonToChat(view, buttons);
+ }
+ });
+
+ const debouncedTrimChats = debounce(() => _converse.minimize.trimChats(), 250);
+ api.listen.on('registeredGlobalEventHandlers', () => window.addEventListener("resize", debouncedTrimChats));
+ api.listen.on('unregisteredGlobalEventHandlers', () => window.removeEventListener("resize", debouncedTrimChats));
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss b/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss
new file mode 100644
index 0000000..fe06bd0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss
@@ -0,0 +1,111 @@
+.conversejs {
+ converse-chats {
+ &.converse-overlayed {
+ converse-minimized-chats {
+ order: 100;
+ }
+
+ #minimized-chats {
+
+ width: var(--minimized-chats-width);
+ margin-bottom: 0;
+ border-top-left-radius: var(--chatbox-border-radius);
+ border-top-right-radius: var(--chatbox-border-radius);
+ color: var(--inverse-link-color);
+ margin-right: var(--chat-gutter);
+ padding: 0;
+
+ .badge {
+ bottom: 8px;
+ border: 1px solid var(--overlayed-badge-color);
+ }
+
+ #toggle-minimized-chats {
+ border-top-left-radius: var(--chatbox-border-radius);
+ border-top-right-radius: var(--chatbox-border-radius);
+ background-color: var(--subdued-color);
+ padding: 1em 0 0 0;
+ text-align: center;
+ color: white;
+ white-space: nowrap;
+ overflow-y: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ height: 45px;
+ width: 9em;
+ }
+
+ a.restore-chat {
+ cursor: pointer;
+ padding: 1px 0 1px 5px;
+ color: var(--chat-head-text-color);
+ line-height: 15px;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ a.restore-chat:visited {
+ color: var(--chat-head-text-color);
+ }
+
+ .minimized-chats-flyout {
+ flex-direction: column-reverse;
+ bottom: 45px;
+ width: var(--minimized-chats-width);
+
+ .chat-head {
+ min-height: 0;
+ padding: 0.3em;
+ border-radius: var(--chatbox-border-radius);
+ height: 35px;
+ margin-bottom: 0.2em;
+ width: 100%;
+ max-width: 9em;
+ flex-wrap: nowrap;
+ background-color: var(--chat-head-color);
+ }
+ .chat-head-chatroom {
+ background-color: var(--chatroom-head-bg-color);
+ a.restore-chat {
+ color: var(--chatroom-head-color);
+ }
+ }
+ .chat-head-headline {
+ background-color: var(--headlines-head-bg-color);
+ a.restore-chat {
+ color: var(--headlines-head-text-color);
+ }
+ }
+
+ &.minimized {
+ height: auto;
+ }
+ }
+
+ .unread-message-count {
+ font-weight: bold;
+ background-color: white;
+ border: 1px solid;
+ text-shadow: 1px 1px 0 var(--text-shadow-color);
+ color: var(--warning-color);
+ border-radius: 5px;
+ padding: 2px 4px;
+ font-size: 16px;
+ text-align: center;
+ position: absolute;
+ right: 116px;
+ bottom: 10px;
+ }
+ .unread-message-count-hidden,
+ .chat-head-message-count-hidden {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js
new file mode 100644
index 0000000..95e0537
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js
@@ -0,0 +1,18 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+export default (o) =>
+ html`<div id="minimized-chats" class="${o.chats.length ? '' : 'hidden'}">
+ <a id="toggle-minimized-chats" class="row no-gutters" @click=${o.toggle}>
+ ${o.num_minimized} ${__('Minimized')}
+ <span class="unread-message-count ${!o.num_unread ? 'unread-message-count-hidden' : ''}" href="#">${o.num_unread}</span>
+ </a>
+ <div class="flyout minimized-chats-flyout row no-gutters ${o.collapsed ? 'hidden' : ''}">
+ ${o.chats.map(chat =>
+ html`<converse-minimized-chat
+ .model=${chat}
+ title=${chat.getDisplayName()}
+ type=${chat.get('type')}
+ num_unread=${chat.get('num_unread')}></converse-minimized-chat>`)}
+ </div>
+ </div>`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js
new file mode 100644
index 0000000..7d8e067
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js
@@ -0,0 +1,26 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (o) => {
+ const i18n_tooltip = __('Click to restore this chat');
+ let close_color;
+ if (o.type === 'chatroom') {
+ close_color = "var(--chatroom-head-color)";
+ } else if (o.type === 'headline') {
+ close_color = "var(--headlines-head-text-color)";
+ } else {
+ close_color = "var(--chat-head-text-color)";
+ }
+
+ return html`
+ <div class="chat-head-${o.type} chat-head row no-gutters">
+ <a class="restore-chat w-100 align-self-center" title="${i18n_tooltip}" @click=${o.restore}>
+ ${o.num_unread ? html`<span class="message-count badge badge-light">${o.num_unread}</span>` : '' }
+ ${o.title}
+ </a>
+ <a class="chatbox-btn close-chatbox-button" @click=${o.close}>
+ <converse-icon color=${close_color} class="fas fa-times" @click=${o.close} size="1em"></converse-icon>
+ </a>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js
new file mode 100644
index 0000000..b3c8e61
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js
@@ -0,0 +1,365 @@
+/*global mock, converse */
+
+const { $msg, u } = converse.env;
+
+
+describe("A chat message", function () {
+
+ it("received for a minimized chat box will increment a counter on its header",
+ mock.initConverse(['chatBoxesFetched'], {'view_mode': 'overlayed'}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_name = mock.cur_names[0];
+ const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ expect(u.isVisible(chatview)).toBeTruthy();
+ expect(chatview.model.get('minimized')).toBeFalsy();
+ chatview.querySelector('.toggle-chatbox-button').click();
+ expect(chatview.model.get('minimized')).toBeTruthy();
+ var message = 'This message is sent to a minimized chatbox';
+ var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ var msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ await u.waitUntil(() => chatview.model.messages.length);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ let count = document.querySelector('converse-minimized-chat .message-count');
+ expect(u.isVisible(chatview)).toBeFalsy();
+ expect(chatview.model.get('minimized')).toBeTruthy();
+
+ expect(u.isVisible(count)).toBeTruthy();
+ expect(count.textContent).toBe('1');
+ _converse.handleMessageStanza(
+ $msg({
+ from: mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('This message is also sent to a minimized chatbox').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+
+ await u.waitUntil(() => (chatview.model.messages.length > 1));
+ expect(u.isVisible(chatview)).toBeFalsy();
+ expect(chatview.model.get('minimized')).toBeTruthy();
+ count = document.querySelector('converse-minimized-chat .message-count');
+ expect(u.isVisible(count)).toBeTruthy();
+ expect(count.textContent).toBe('2');
+ document.querySelector("converse-minimized-chat a.restore-chat").click();
+ expect(_converse.chatboxes.filter('minimized').length).toBe(0);
+ }));
+
+});
+
+describe("A Groupchat", function () {
+
+ it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@conference.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const button = await u.waitUntil(() => view.querySelector('.toggle-chatbox-button'));
+ button.click();
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
+ await u.waitUntil(() => !u.isVisible(view));
+ expect(view.model.get('minimized')).toBeTruthy();
+ const el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat"));
+ el.click();
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
+ expect(view.model.get('minimized')).toBeFalsy();
+ expect(_converse.api.trigger.calls.count(), 3);
+ }));
+});
+
+
+describe("A Chatbox", function () {
+
+ it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ chatview.querySelector('.toggle-chatbox-button').click();
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
+ expect(_converse.api.trigger.calls.count(), 2);
+ await u.waitUntil(() => !u.isVisible(chatview));
+ expect(chatview.model.get('minimized')).toBeTruthy();
+ const restore_el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat"));
+ restore_el.click();
+ await u.waitUntil(() => _converse.chatboxviews.keys().length);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object));
+ expect(chatview.model.get('minimized')).toBeFalsy();
+ }));
+
+
+ it("can be opened in minimized mode initially", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false);
+ await _converse.api.chats.create(sender_jid, {'minimized': true});
+ await u.waitUntil(() => _converse.chatboxes.length > 1);
+ expect(_converse.chatboxviews.get(sender_jid)).toBe(undefined);
+ expect(u.isVisible(minimized_chats.firstElementChild)).toBe(true);
+ expect(minimized_chats.firstElementChild.querySelectorAll('converse-minimized-chat').length).toBe(1);
+ expect(_converse.chatboxes.filter('minimized').length).toBe(1);
+ }));
+
+
+ it("can be trimmed to conserve space", mock.initConverse([], {}, async function (_converse) {
+ spyOn(_converse.minimize, 'trimChats');
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // openControlBox was called earlier, so the controlbox is
+ // visible, but no other chat boxes have been created.
+ expect(_converse.chatboxes.length).toEqual(1);
+ expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length);
+ // Test that they can be maximized again
+ const online_contacts = rosterview.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
+ expect(online_contacts.length).toBe(17);
+ let i;
+ for (i=0; i<online_contacts.length; i++) {
+ const el = online_contacts[i];
+ el.click();
+ }
+ await u.waitUntil(() => _converse.chatboxes.length == 16);
+ expect(_converse.minimize.trimChats.calls.count()).toBe(16);
+
+ for (i=0; i<online_contacts.length; i++) {
+ const el = online_contacts[i];
+ const jid = el.textContent.trim().replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const model = _converse.chatboxes.get(jid);
+ model.set({'minimized': true});
+ }
+ await u.waitUntil(() => _converse.chatboxviews.keys().length === 1);
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ minimized_chats.querySelector("a.restore-chat").click();
+ expect(_converse.minimize.trimChats.calls.count()).toBe(16);
+ }));
+});
+
+
+describe("A Minimized ChatBoxView's Unread Message Count", function () {
+
+ it("is displayed when scrolled up chatbox is minimized after receiving unread messages",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => chatbox.messages.length);
+ await u.waitUntil(() => chatbox.get('num_unread') === 1);
+ _converse.minimize.minimize(chatbox);
+
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count');
+ expect(u.isVisible(unread_count)).toBeTruthy();
+ expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1');
+ }));
+
+ it("is incremented when message is received and windows is not focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, sender_jid)
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ _converse.minimize.minimize(view.model);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => view.model.messages.length);
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count');
+ expect(u.isVisible(unread_count)).toBeTruthy();
+ expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1');
+ }));
+});
+
+
+describe("The Minimized Chats Widget", function () {
+
+ it("shows chats that have been minimized",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ minimized_chats.initToggle();
+
+ let contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ let chatview = _converse.chatboxviews.get(contact_jid);
+ expect(chatview.model.get('minimized')).toBeFalsy();
+ expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false);
+ chatview.querySelector('.toggle-chatbox-button').click();
+ expect(chatview.model.get('minimized')).toBeTruthy();
+ expect(u.isVisible(minimized_chats)).toBe(true);
+ expect(_converse.chatboxes.filter('minimized').length).toBe(1);
+ expect(_converse.chatboxes.models.filter(c => c.get('minimized')).pop().get('jid')).toBe(contact_jid);
+
+ contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ chatview = _converse.chatboxviews.get(contact_jid);
+ expect(chatview.model.get('minimized')).toBeFalsy();
+ chatview.querySelector('.toggle-chatbox-button').click();
+ expect(chatview.model.get('minimized')).toBeTruthy();
+ expect(u.isVisible(minimized_chats)).toBe(true);
+ expect(_converse.chatboxes.filter('minimized').length).toBe(2);
+ expect(_converse.chatboxes.filter('minimized').map(c => c.get('jid')).includes(contact_jid)).toBeTruthy();
+ }));
+
+ it("can be toggled to hide or show minimized chats",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ let minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ minimized_chats.initToggle();
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false);
+
+ chatview.model.set({'minimized': true});
+ expect(u.isVisible(minimized_chats)).toBeTruthy();
+ expect(_converse.chatboxes.filter('minimized').length).toBe(1);
+ expect(_converse.chatboxes.models.filter(c => c.get('minimized')).pop().get('jid')).toBe(contact_jid);
+
+ minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ expect(u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout'))).toBeTruthy();
+ expect(minimized_chats.minchats.get('collapsed')).toBeFalsy();
+ minimized_chats.querySelector('#toggle-minimized-chats').click();
+ await u.waitUntil(() => u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout')));
+ expect(minimized_chats.minchats.get('collapsed')).toBeTruthy();
+ }));
+
+ it("shows the number messages received to minimized chats",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 4);
+ await mock.openControlBox(_converse);
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ minimized_chats.initToggle();
+ minimized_chats.minchats.set({'collapsed': true});
+
+ const unread_el = minimized_chats.querySelector('.unread-message-count');
+ expect(u.isVisible(unread_el)).toBe(false);
+
+ const promises = [];
+ let i, contact_jid;
+ for (i=0; i<3; i++) {
+ contact_jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ promises.push(mock.openChatBoxFor(_converse, contact_jid));
+ }
+ await Promise.all(promises);
+ await u.waitUntil(() => _converse.chatboxes.length == 4);
+
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ chatview.model.set({'minimized': true});
+ for (i=0; i<3; i++) {
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('This message is sent to a minimized chatbox').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ _converse.handleMessageStanza(msg);
+ }
+ await u.waitUntil(() => chatview.model.messages.length === 3, 500);
+
+
+ expect(u.isVisible(minimized_chats.querySelector('.unread-message-count'))).toBeTruthy();
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((3).toString());
+ // Chat state notifications don't increment the unread messages counter
+ // <composing> state
+ _converse.handleMessageStanza($msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+ // <paused> state
+ _converse.handleMessageStanza($msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+ // <gone> state
+ _converse.handleMessageStanza($msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString());
+
+ // <inactive> state
+ _converse.handleMessageStanza($msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString());
+ }));
+
+ it("shows the number messages received to minimized groupchats",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'kitchen@conference.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, 'kitchen@conference.shakespeare.lit', 'fires');
+ const view = _converse.chatboxviews.get(muc_jid);
+ view.model.set({'minimized': true});
+ const message = 'fires: Your attention is required';
+ const nick = mock.chatroom_names[0];
+ const msg = $msg({
+ from: muc_jid+'/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(message).tree();
+ view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.length);
+ const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats"));
+ expect(u.isVisible(minimized_chats.querySelector('.unread-message-count'))).toBeTruthy();
+ expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe('1');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js
new file mode 100644
index 0000000..896ec02
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js
@@ -0,0 +1,9 @@
+import { Model } from '@converse/skeletor/src/model.js';
+
+const MinimizedChatsToggle = Model.extend({
+ defaults: {
+ 'collapsed': false
+ }
+});
+
+export default MinimizedChatsToggle;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js
new file mode 100644
index 0000000..3cb4bf5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js
@@ -0,0 +1,211 @@
+import { _converse, api, converse } from '@converse/headless/core';
+import { __ } from 'i18n';
+
+const { dayjs, u } = converse.env;
+
+export function initializeChat (chat) {
+ chat.on('change:hidden', m => !m.get('hidden') && maximize(chat), chat);
+
+ if (chat.get('id') === 'controlbox') {
+ return;
+ }
+ chat.save({
+ 'minimized': chat.get('minimized') || false,
+ 'time_minimized': chat.get('time_minimized') || dayjs(),
+ });
+}
+
+function getChatBoxWidth (view) {
+ if (view.model.get('id') === 'controlbox') {
+ // We return the width of the controlbox or its toggle,
+ // depending on which is visible.
+ if (u.isVisible(view)) {
+ return u.getOuterWidth(view, true);
+ } else {
+ const toggle = document.querySelector('converse-controlbox-toggle');
+ return toggle ? u.getOuterWidth(toggle, true) : 0;
+ }
+ } else if (!view.model.get('minimized') && u.isVisible(view)) {
+ return u.getOuterWidth(view, true);
+ }
+ return 0;
+}
+
+function getShownChats () {
+ return _converse.chatboxviews.filter(el =>
+ // The controlbox can take a while to close,
+ // so we need to check its state. That's why we checked the 'closed' state.
+ !el.model.get('minimized') && !el.model.get('closed') && u.isVisible(el)
+ );
+}
+
+function getMinimizedWidth () {
+ const minimized_el = document.querySelector('converse-minimized-chats');
+ return _converse.chatboxes.pluck('minimized').includes(true) ? u.getOuterWidth(minimized_el, true) : 0;
+}
+
+function getBoxesWidth (newchat) {
+ const new_id = newchat ? newchat.model.get('id') : null;
+ const newchat_width = newchat ? u.getOuterWidth(newchat, true) : 0;
+ return Object.values(_converse.chatboxviews.xget(new_id))
+ .reduce((memo, view) => memo + getChatBoxWidth(view), newchat_width);
+}
+
+/**
+ * This method is called when a newly created chat box will be shown.
+ * It checks whether there is enough space on the page to show
+ * another chat box. Otherwise it minimizes the oldest chat box
+ * to create space.
+ * @private
+ * @method _converse.ChatBoxViews#trimChats
+ * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesFeedView } [newchat]
+ */
+export function trimChats (newchat) {
+ if (_converse.isTestEnv() || api.settings.get('no_trimming') || api.settings.get("view_mode") !== 'overlayed') {
+ return;
+ }
+ const shown_chats = getShownChats();
+ if (shown_chats.length <= 1) {
+ return;
+ }
+ const body_width = u.getOuterWidth(document.querySelector('body'), true);
+ if (getChatBoxWidth(shown_chats[0]) === body_width) {
+ // If the chats shown are the same width as the body,
+ // then we're in responsive mode and the chats are
+ // fullscreen. In this case we don't trim.
+ return;
+ }
+ const minimized_el = document.querySelector('converse-minimized-chats');
+ if (minimized_el) {
+ while ((getMinimizedWidth() + getBoxesWidth(newchat)) > body_width) {
+ const new_id = newchat ? newchat.model.get('id') : null;
+ const oldest_chat = getOldestMaximizedChat([new_id]);
+ if (oldest_chat) {
+ const model = _converse.chatboxes.get(oldest_chat.get('id'));
+ model?.save('hidden', true);
+ minimize(oldest_chat);
+ } else {
+ break;
+ }
+ }
+ }
+}
+
+function getOldestMaximizedChat (exclude_ids) {
+ // Get oldest view (if its id is not excluded)
+ exclude_ids.push('controlbox');
+ let i = 0;
+ let model = _converse.chatboxes.sort().at(i);
+ while (exclude_ids.includes(model.get('id')) || model.get('minimized') === true) {
+ i++;
+ model = _converse.chatboxes.at(i);
+ if (!model) {
+ return null;
+ }
+ }
+ return model;
+}
+
+export function addMinimizeButtonToChat (view, buttons) {
+ const data = {
+ 'a_class': 'toggle-chatbox-button',
+ 'handler': ev => minimize(ev, view.model),
+ 'i18n_text': __('Minimize'),
+ 'i18n_title': __('Minimize this chat'),
+ 'icon_class': "fa-minus",
+ 'name': 'minimize',
+ 'standalone': _converse.api.settings.get("view_mode") === 'overlayed'
+ }
+ const names = buttons.map(t => t.name);
+ const idx = names.indexOf('close');
+ return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
+}
+
+export function addMinimizeButtonToMUC (view, buttons) {
+ const data = {
+ 'a_class': 'toggle-chatbox-button',
+ 'handler': ev => minimize(ev, view.model),
+ 'i18n_text': __('Minimize'),
+ 'i18n_title': __('Minimize this groupchat'),
+ 'icon_class': "fa-minus",
+ 'name': 'minimize',
+ 'standalone': _converse.api.settings.get("view_mode") === 'overlayed'
+ }
+ const names = buttons.map(t => t.name);
+ const idx = names.indexOf('signout');
+ return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons];
+}
+
+
+export function maximize (ev, chatbox) {
+ if (ev?.preventDefault) {
+ ev.preventDefault();
+ } else {
+ chatbox = ev;
+ }
+ u.safeSave(chatbox, {
+ 'hidden': false,
+ 'minimized': false,
+ 'time_opened': new Date().getTime()
+ });
+}
+
+export function minimize (ev, model) {
+ if (ev?.preventDefault) {
+ ev.preventDefault();
+ } else {
+ model = ev;
+ }
+ model.setChatState(_converse.INACTIVE);
+ u.safeSave(model, {
+ 'hidden': true,
+ 'minimized': true,
+ 'time_minimized': new Date().toISOString()
+ });
+}
+
+/**
+ * Handler which gets called when a {@link _converse#ChatBox} has it's
+ * `minimized` property set to false.
+ *
+ * Will trigger {@link _converse#chatBoxMaximized}
+ * @returns {_converse.ChatBoxView|_converse.ChatRoomView}
+ */
+function onMaximized (model) {
+ if (!model.isScrolledUp()) {
+ model.clearUnreadMsgCounter();
+ }
+ model.setChatState(_converse.ACTIVE);
+ /**
+ * Triggered when a previously minimized chat gets maximized
+ * @event _converse#chatBoxMaximized
+ * @type { _converse.ChatBoxView }
+ * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
+ */
+ api.trigger('chatBoxMaximized', model);
+}
+
+/**
+ * Handler which gets called when a {@link _converse#ChatBox} has it's
+ * `minimized` property set to true.
+ *
+ * Will trigger {@link _converse#chatBoxMinimized}
+ * @returns {_converse.ChatBoxView|_converse.ChatRoomView}
+ */
+function onMinimized (model) {
+ /**
+ * Triggered when a previously maximized chat gets Minimized
+ * @event _converse#chatBoxMinimized
+ * @type { _converse.ChatBoxView }
+ * @example _converse.api.listen.on('chatBoxMinimized', view => { ... });
+ */
+ api.trigger('chatBoxMinimized', model);
+}
+
+export function onMinimizedChanged (model) {
+ if (model.get('minimized')) {
+ onMinimized(model);
+ } else {
+ onMaximized(model);
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js
new file mode 100644
index 0000000..a8e690a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js
@@ -0,0 +1,50 @@
+import MinimizedChatsToggle from './toggle.js';
+import tplChatsPanel from './templates/chats-panel.js';
+import { CustomElement } from 'shared/components/element';
+import { _converse, api } from '@converse/headless/core';
+import { initStorage } from '@converse/headless/utils/storage.js';
+
+
+export default class MinimizedChats extends CustomElement {
+
+ async initialize () {
+ this.model = _converse.chatboxes;
+ await this.initToggle();
+ this.listenTo(this.minchats, 'change:collapsed', () => this.requestUpdate())
+ this.listenTo(this.model, 'add', () => this.requestUpdate())
+ this.listenTo(this.model, 'change:fullname', () => this.requestUpdate())
+ this.listenTo(this.model, 'change:jid', () => this.requestUpdate())
+ this.listenTo(this.model, 'change:minimized', () => this.requestUpdate())
+ this.listenTo(this.model, 'change:name', () => this.requestUpdate())
+ this.listenTo(this.model, 'change:num_unread', () => this.requestUpdate())
+ this.listenTo(this.model, 'remove', () => this.requestUpdate())
+
+ this.listenTo(_converse, 'connected', () => this.requestUpdate());
+ this.listenTo(_converse, 'reconnected', () => this.requestUpdate());
+ this.listenTo(_converse, 'disconnected', () => this.requestUpdate());
+ }
+
+ render () {
+ const chats = this.model.where({'minimized': true});
+ const num_unread = chats.reduce((acc, chat) => (acc + chat.get('num_unread')), 0);
+ const num_minimized = chats.reduce((acc, chat) => (acc + (chat.get('minimized') ? 1 : 0)), 0);
+ const collapsed = this.minchats.get('collapsed');
+ const data = { chats, num_unread, num_minimized, collapsed };
+ data.toggle = ev => this.toggle(ev);
+ return tplChatsPanel(data);
+ }
+
+ async initToggle () {
+ const id = `converse.minchatstoggle-${_converse.bare_jid}`;
+ this.minchats = new MinimizedChatsToggle({id});
+ initStorage(this.minchats, id, 'session');
+ await new Promise(resolve => this.minchats.fetch({'success': resolve, 'error': resolve}));
+ }
+
+ toggle (ev) {
+ ev?.preventDefault();
+ this.minchats.save({'collapsed': !this.minchats.get('collapsed')});
+ }
+}
+
+api.elements.define('converse-minimized-chats', MinimizedChats);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js
new file mode 100644
index 0000000..b112b60
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js
@@ -0,0 +1,23 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplAlertModal from "./templates/alert.js";
+import { api } from "@converse/headless/core";
+
+
+export default class Alert extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render())
+ this.addEventListener('hide.bs.modal', () => this.remove(), false);
+ }
+
+ renderModal () {
+ return tplAlertModal(this.model.toJSON());
+ }
+
+ getModalTitle () {
+ return this.model.get('title');
+ }
+}
+
+api.elements.define('converse-alert-modal', Alert);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js
new file mode 100644
index 0000000..9b6512c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js
@@ -0,0 +1,188 @@
+import './alert.js';
+import Confirm from './confirm.js';
+import { Model } from '@converse/skeletor/src/model.js';
+
+let modals = [];
+let modals_map = {};
+
+const modal_api = {
+ /**
+ * API namespace for methods relating to modals
+ * @namespace _converse.api.modal
+ * @memberOf _converse.api
+ */
+ modal: {
+ /**
+ * Shows a modal of type `ModalClass` to the user.
+ * Will create a new instance of that class if an existing one isn't
+ * found.
+ * @param { Class } ModalClass
+ * @param { Object } [properties] - Optional properties that will be set on a newly created modal instance.
+ * @param { Event } [event] - The DOM event that causes the modal to be shown.
+ */
+ show (name, properties, ev) {
+ let modal;
+ if (typeof name === 'string') {
+ modal = this.get(name) ?? this.create(name, properties);
+ Object.assign(modal, properties);
+ } else {
+ // Legacy...
+ const ModalClass = name;
+ const id = ModalClass.id ?? properties.id;
+ modal = this.get(id) ?? this.create(ModalClass, properties);
+ }
+ modal.show(ev);
+ return modal;
+ },
+
+ /**
+ * Return a modal with the passed-in identifier, if it exists.
+ * @param { String } id
+ */
+ get (id) {
+ return modals_map[id] ?? modals.filter(m => m.id == id).pop();
+ },
+
+ /**
+ * Create a modal of the passed-in type.
+ * @param { String } name
+ * @param { Object } [properties] - Optional properties that will be
+ * set on the modal instance.
+ */
+ create (name, properties) {
+ let modal;
+ if (typeof name === 'string') {
+ const ModalClass = customElements.get(name);
+ modal = modals_map[name] = new ModalClass(properties);
+ } else {
+ // Legacy...
+ const ModalClass = name;
+ modal = new ModalClass(properties);
+ modals.push(modal);
+ }
+ return modal;
+ },
+
+ /**
+ * Remove a particular modal
+ * @param { String } name
+ */
+ remove (name) {
+ let modal;
+ if (typeof name === 'string') {
+ modal = modals_map[name];
+ delete modals_map[name];
+ } else {
+ // Legacy...
+ modal = name;
+ modals = modals.filter(m => m !== modal);
+ }
+ modal?.remove();
+ },
+
+ /**
+ * Remove all modals
+ */
+ removeAll () {
+ modals.forEach(m => m.remove());
+ modals = [];
+ modals_map = {};
+ }
+ },
+
+ /**
+ * Show a confirm modal to the user.
+ * @method _converse.api.confirm
+ * @param { String } title - The header text for the confirmation dialog
+ * @param { (Array<String>|String) } messages - The text to show to the user
+ * @param { Array<Field> } fields - An object representing a fields presented to the user.
+ * @property { String } Field.label - The form label for the input field.
+ * @property { String } Field.name - The name for the input field.
+ * @property { String } [Field.challenge] - A challenge value that must be provided by the user.
+ * @property { String } [Field.placeholder] - The placeholder for the input field.
+ * @property { Boolean} [Field.required] - Whether the field is required or not
+ * @returns { Promise<Array|false> } A promise which resolves with an array of
+ * filled in fields or `false` if the confirm dialog was closed or canceled.
+ */
+ async confirm (title, messages=[], fields=[]) {
+ if (typeof messages === 'string') {
+ messages = [messages];
+ }
+ const model = new Model({title, messages, fields, 'type': 'confirm'})
+ const confirm = new Confirm({model});
+ confirm.show();
+ let result;
+ try {
+ result = await confirm.confirmation;
+ } catch (e) {
+ result = false;
+ }
+ confirm.remove();
+ return result;
+ },
+
+ /**
+ * Show a prompt modal to the user.
+ * @method _converse.api.prompt
+ * @param { String } title - The header text for the prompt
+ * @param { (Array<String>|String) } messages - The prompt text to show to the user
+ * @param { String } placeholder - The placeholder text for the prompt input
+ * @returns { Promise<String|false> } A promise which resolves with the text provided by the
+ * user or `false` if the user canceled the prompt.
+ */
+ async prompt (title, messages=[], placeholder='') {
+ if (typeof messages === 'string') {
+ messages = [messages];
+ }
+ const model = new Model({
+ title,
+ messages,
+ 'fields': [{
+ 'name': 'reason',
+ 'placeholder': placeholder,
+ }],
+ 'type': 'prompt'
+ })
+ const prompt = new Confirm({model});
+ prompt.show();
+ let result;
+ try {
+ result = (await prompt.confirmation).pop()?.value;
+ } catch (e) {
+ result = false;
+ }
+ prompt.remove();
+ return result;
+ },
+
+ /**
+ * Show an alert modal to the user.
+ * @method _converse.api.alert
+ * @param { ('info'|'warn'|'error') } type - The type of alert.
+ * @param { String } title - The header text for the alert.
+ * @param { (Array<String>|String) } messages - The alert text to show to the user.
+ */
+ alert (type, title, messages) {
+ if (typeof messages === 'string') {
+ messages = [messages];
+ }
+ let level;
+ if (type === 'error') {
+ level = 'alert-danger';
+ } else if (type === 'info') {
+ level = 'alert-info';
+ } else if (type === 'warn') {
+ level = 'alert-warning';
+ }
+
+ const model = new Model({
+ 'title': title,
+ 'messages': messages,
+ 'level': level,
+ 'type': 'alert'
+ })
+ modal_api.modal.show('converse-alert-modal', { model });
+ }
+}
+
+export default modal_api;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js
new file mode 100644
index 0000000..8006462
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js
@@ -0,0 +1,92 @@
+import api from "@converse/headless/shared/api/index.js";
+import bootstrap from "bootstrap.native";
+import log from "@converse/headless/log";
+import sizzle from 'sizzle';
+import tplAlertComponent from "./templates/modal-alert.js";
+import { View } from '@converse/skeletor/src/view.js';
+import { hasClass, addClass, removeElement, removeClass } from '../../utils/html.js';
+import { render } from 'lit';
+
+import './styles/_modal.scss';
+
+
+
+const BaseModal = View.extend({
+ className: "modal",
+ persistent: false, // Whether this modal should persist in the DOM once it's been closed
+ events: {
+ 'click .nav-item .nav-link': 'switchTab'
+ },
+
+ initialize (options) {
+ if (!this.id) {
+ throw new Error("Each modal class must have a unique id attribute");
+ }
+ // Allow properties to be set via passed in options
+ Object.assign(this, options);
+
+ this.render()
+
+ this.el.setAttribute('tabindex', '-1');
+ this.el.setAttribute('role', 'dialog');
+ this.el.setAttribute('aria-hidden', 'true');
+ const label_id = this.el.querySelector('.modal-title').getAttribute('id');
+ label_id && this.el.setAttribute('aria-labelledby', label_id);
+
+ this.insertIntoDOM();
+ const Modal = bootstrap.Modal;
+ this.modal = new Modal(this.el, {
+ backdrop: true,
+ keyboard: true
+ });
+ this.el.addEventListener('hide.bs.modal', () => this.onHide(), false);
+ },
+
+ onHide () {
+ removeClass('selected', this.trigger_el);
+ !this.persistent && api.modal.remove(this);
+ },
+
+ insertIntoDOM () {
+ const container_el = document.querySelector("#converse-modals");
+ container_el.insertAdjacentElement('beforeEnd', this.el);
+ },
+
+ switchTab (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ sizzle('.nav-link.active', this.el).forEach(el => {
+ removeClass('active', this.el.querySelector(el.getAttribute('href')));
+ removeClass('active', el);
+ });
+ addClass('active', ev.target);
+ addClass('active', this.el.querySelector(ev.target.getAttribute('href')))
+ },
+
+ alert (message, type='primary') {
+ const body = this.el.querySelector('.modal-alert');
+ if (body === null) {
+ log.error("Could not find a .modal-alert element in the modal to show an alert message in!");
+ return;
+ }
+ // FIXME: Instead of adding the alert imperatively, we should
+ // find a way to let the modal rerender with an alert message
+ render(tplAlertComponent({'type': `alert-${type}`, 'message': message}), body);
+ const el = body.firstElementChild;
+ setTimeout(() => {
+ addClass('fade-out', el);
+ setTimeout(() => removeElement(el), 600);
+ }, 5000);
+ },
+
+ show (ev) {
+ if (ev) {
+ ev.preventDefault();
+ this.trigger_el = ev.target;
+ !hasClass('chat-image', this.trigger_el) && addClass('selected', this.trigger_el);
+ }
+ this.modal.show();
+ }
+});
+
+export default BaseModal;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js
new file mode 100644
index 0000000..8f775f0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js
@@ -0,0 +1,59 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplPrompt from "./templates/prompt.js";
+import { getOpenPromise } from '@converse/openpromise';
+import { api } from "@converse/headless/core";
+
+export default class Confirm extends BaseModal {
+
+ constructor (options) {
+ super(options);
+ this.confirmation = getOpenPromise();
+ }
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render())
+ this.addEventListener('hide.bs.modal', () => {
+ if (!this.confirmation.isResolved) {
+ this.confirmation.reject()
+ }
+ }, false);
+ }
+
+ renderModal () {
+ return tplPrompt(this);
+ }
+
+ getModalTitle () {
+ return this.model.get('title');
+ }
+
+ onConfimation (ev) {
+ ev.preventDefault();
+ const form_data = new FormData(ev.target);
+ const fields = (this.model.get('fields') || [])
+ .map(field => {
+ const value = form_data.get(field.name).trim();
+ field.value = value;
+ if (field.challenge) {
+ field.challenge_failed = (value !== field.challenge);
+ }
+ return field;
+ });
+
+ if (fields.filter(c => c.challenge_failed).length) {
+ this.model.set('fields', fields);
+ // Setting an array doesn't trigger a change event
+ this.model.trigger('change');
+ return;
+ }
+ this.confirmation.resolve(fields);
+ this.modal.hide();
+ }
+
+ renderModalFooter () { // eslint-disable-line class-methods-use-this
+ return '';
+ }
+}
+
+api.elements.define('converse-confirm-modal', Confirm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js
new file mode 100644
index 0000000..93816b3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js
@@ -0,0 +1,26 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import BootstrapModal from './base.js';
+import modal_api from './api.js';
+import { _converse, api, converse } from "@converse/headless/core";
+
+converse.env.BootstrapModal = BootstrapModal; // expose to plugins
+
+
+converse.plugins.add('converse-modal', {
+
+ initialize () {
+ api.listen.on('disconnect', () => {
+ const container = document.querySelector("#converse-modals");
+ if (container) {
+ container.innerHTML = '';
+ }
+ });
+
+ api.listen.on('clearSession', () => api.modal.removeAll());
+
+ Object.assign(_converse.api, modal_api);
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js
new file mode 100644
index 0000000..d785c5a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js
@@ -0,0 +1,71 @@
+import bootstrap from "bootstrap.native";
+import tplModal from './templates/modal.js';
+import { ElementView } from '@converse/skeletor/src/element.js';
+import { getOpenPromise } from '@converse/openpromise';
+
+
+import './styles/_modal.scss';
+
+class BaseModal extends ElementView {
+
+ constructor (options) {
+ super();
+ this.className = 'modal';
+ this.initialized = getOpenPromise();
+
+ // Allow properties to be set via passed in options
+ Object.assign(this, options);
+ setTimeout(() => this.insertIntoDOM());
+
+ this.addEventListener('hide.bs.modal', () => this.onHide(), false);
+ }
+
+ initialize () {
+ this.modal = new bootstrap.Modal(this, {
+ backdrop: true,
+ keyboard: true
+ });
+ this.initialized.resolve();
+ this.render()
+ }
+
+ toHTML () {
+ return tplModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ // Intended to be overwritten
+ return '';
+ }
+
+ switchTab (ev) {
+ ev?.stopPropagation();
+ ev?.preventDefault();
+ this.tab = ev.target.getAttribute('data-name');
+ this.render();
+ }
+
+ onHide () {
+ this.modal.hide();
+ }
+
+ insertIntoDOM () {
+ const container_el = document.querySelector("#converse-modals");
+ container_el.insertAdjacentElement('beforeEnd', this);
+ }
+
+ alert (message, type='primary') {
+ this.model.set('alert', { message, type });
+ setTimeout(() => {
+ this.model.set('alert', undefined);
+ }, 5000);
+ }
+
+ async show () {
+ await this.initialized;
+ this.modal.show();
+ this.render();
+ }
+}
+
+export default BaseModal;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss
new file mode 100644
index 0000000..24aefd6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss
@@ -0,0 +1,119 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+
+.conversejs {
+ @import "bootstrap/scss/modal";
+
+ .modal-header {
+ &.alert-danger {
+ background-color: var(--error-color);
+ color: var(--background);
+ border-bottom: none;
+
+ .close {
+ color: var(--background);
+ }
+ }
+ }
+
+ .modal-content {
+ background-color: var(--modal-background-color);
+ }
+
+ .modal-body {
+ .row {
+ margin-left: 0;
+ margin-right: 0;
+ }
+ }
+
+ .occupant-details {
+ li {
+ margin-bottom: 1em;
+ }
+ }
+
+ #converse-modals {
+ .modal {
+ .nav-item {
+ margin: 0.25em;
+ .nav-link {
+ &.active {
+ color: var(--background);
+ }
+ }
+ &:hover {
+ .nav-link {
+ color: var(--foreground);
+ background-color: var(--primary-color-light);
+ &.active {
+ color: var(--background);
+ background-color: var(--primary-color);
+ }
+ }
+ }
+ }
+
+ .modal-content {
+ box-shadow: var(--raised-el-shadow);
+ }
+
+ .modal-body {
+ overflow-y: auto;
+ max-height: 75vh;
+ margin-bottom: 2em;
+ p {
+ padding: 0.25rem 0;
+ }
+ .confirm {
+ .form-group {
+ p:first-child {
+ font-size: 110%;
+ font-weight: bold;
+ }
+ }
+ }
+ &.fit-content {
+ box-sizing: content-box;
+
+ img {
+ max-width: 90vw;
+ }
+ }
+ }
+ .modal-footer {
+ justify-content: flex-start;
+ }
+ .roomid-policy-error {
+ color: var(--error-color);
+ font-size: var(--font-size-small);
+ float: right;
+ }
+ }
+
+ .scrollable-container {
+ max-height: 45vh;
+ overflow-y: auto;
+ }
+
+ .role-form, .affiliation-form {
+ padding: 2em 0 1em 0;
+ }
+
+ .set-xmpp-status {
+ margin: 1em;
+ .custom-control-label {
+ padding-top: 0.25em;
+ }
+ }
+
+ #omemo-tabpanel {
+ margin-top: 1em;
+ }
+
+ .btn {
+ font-weight: normal;
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js
new file mode 100644
index 0000000..0b35eed
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js
@@ -0,0 +1,8 @@
+import { html } from "lit";
+
+
+export default (o) => html`
+ <div class="modal-body">
+ <span class="modal-alert"></span>
+ ${ o.messages.map(message => html`<p>${message}</p>`) }
+ </div>`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js
new file mode 100644
index 0000000..73dee7f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js
@@ -0,0 +1,9 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+
+
+export const modal_close_button =
+ html`<button type="button" class="btn btn-secondary" data-dismiss="modal">${__('Close')}</button>`;
+
+export const modal_header_close_button =
+ html`<button type="button" class="close" data-dismiss="modal" aria-label="${__('Close')}"><span aria-hidden="true">×</span></button>`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js
new file mode 100644
index 0000000..06dd9ca
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js
@@ -0,0 +1,3 @@
+import { html } from "lit";
+
+export default (o) => html`<div class="alert ${o.type}" role="alert"><p>${o.message}</p></div>`
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js
new file mode 100644
index 0000000..daa2c29
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js
@@ -0,0 +1,26 @@
+import tplAlertComponent from "./modal-alert.js";
+import { html } from "lit";
+import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js";
+
+
+export default (el) => {
+ const alert = el.model?.get('alert');
+ const level = el.model?.get('level') ?? '';
+ return html`
+ <div class="modal-dialog" role="document" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-content">
+ <div class="modal-header ${level}">
+ <h5 class="modal-title">${el.getModalTitle()}</h5>
+ ${modal_header_close_button}
+ </div>
+ <div class="modal-body">
+ <span class="modal-alert">
+ ${ alert ? tplAlertComponent({'type': `alert-${alert.type}`, 'message': alert.message}) : ''}
+ </span>
+ ${ el.renderModal?.() ?? '' }
+ </div>
+ ${ el.renderModalFooter?.() ?? html`<div class="modal-footer">${ modal_close_button }</div>` }
+ </div>
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js
new file mode 100644
index 0000000..2c0ad21
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js
@@ -0,0 +1,30 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+const tplField = (f) => html`
+ <div class="form-group">
+ <label>
+ ${f.label || ''}
+ <input type="text"
+ name="${f.name}"
+ class="${(f.challenge_failed) ? 'error' : ''} form-control form-control--labeled"
+ ?required="${f.required}"
+ placeholder="${f.placeholder}" />
+ </label>
+ </div>
+`;
+
+export default (el) => {
+ return html`
+ <form class="converse-form converse-form--modal confirm" action="#" @submit=${ev => el.onConfimation(ev)}>
+ <div class="form-group">
+ ${ el.model.get('messages')?.map(message => html`<p>${message}</p>`) }
+ </div>
+ ${ el.model.get('fields')?.map(f => tplField(f)) }
+ <div class="form-group">
+ <button type="submit" class="btn btn-primary">${__('OK')}</button>
+ <input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
+ </div>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js
new file mode 100644
index 0000000..65b12d0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js
@@ -0,0 +1,69 @@
+import log from '@converse/headless/log';
+import tplAffiliationForm from './templates/affiliation-form.js';
+import { CustomElement } from 'shared/components/element';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core';
+import { setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js';
+
+const { Strophe, sizzle } = converse.env;
+
+class AffiliationForm extends CustomElement {
+ static get properties () {
+ return {
+ muc: { type: Object },
+ jid: { type: String },
+ affiliation: { type: String },
+ alert_message: { type: String, attribute: false },
+ alert_type: { type: String, attribute: false },
+ };
+ }
+
+ render () {
+ return tplAffiliationForm(this);
+ }
+
+ alert (message, type) {
+ this.alert_message = message;
+ this.alert_type = type;
+ }
+
+ async assignAffiliation (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.alert(); // clear alert messages
+
+ const data = new FormData(ev.target);
+ const affiliation = data.get('affiliation');
+ const attrs = {
+ jid: this.jid,
+ reason: data.get('reason'),
+ };
+ const muc_jid = this.muc.get('jid');
+ try {
+ await setAffiliation(affiliation, muc_jid, [attrs]);
+ } catch (e) {
+ if (e === null) {
+ this.alert(__('Timeout error while trying to set the affiliation'), 'danger');
+ } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+ this.alert(__("Sorry, you're not allowed to make that change"), 'danger');
+ } else {
+ this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger');
+ }
+ log.error(e);
+ return;
+ }
+
+ await this.muc.occupants.fetchMembers();
+
+ /**
+ * @event affiliationChanged
+ * @example
+ * const el = document.querySelector('converse-muc-affiliation-form');
+ * el.addEventListener('affiliationChanged', () => { ... });
+ */
+ const event = new CustomEvent('affiliationChanged', { bubbles: true });
+ this.dispatchEvent(event);
+ }
+}
+
+api.elements.define('converse-muc-affiliation-form', AffiliationForm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js
new file mode 100644
index 0000000..c8e9412
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js
@@ -0,0 +1,53 @@
+import 'shared/autocomplete/index.js';
+import BottomPanel from 'plugins/chatview/bottom-panel.js';
+import tplMUCBottomPanel from './templates/muc-bottom-panel.js';
+import { _converse, api, converse } from "@converse/headless/core";
+import { render } from 'lit';
+
+import './styles/muc-bottom-panel.scss';
+
+
+export default class MUCBottomPanel extends BottomPanel {
+
+ events = {
+ 'click .hide-occupants': 'hideOccupants',
+ 'click .send-button': 'sendButtonClicked',
+ }
+
+ async initialize () {
+ await super.initialize();
+ this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender);
+ this.listenTo(this.model, 'change:num_unread_general', this.debouncedRender)
+ this.listenTo(this.model.features, 'change:moderated', this.debouncedRender);
+ this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant)
+ this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant);
+ this.listenTo(this.model.session, 'change:connection_status', this.debouncedRender);
+ }
+
+ render () {
+ const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
+ const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
+ render(tplMUCBottomPanel({
+ can_edit, entered,
+ 'model': this.model,
+ 'is_groupchat': true,
+ 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+ }), this);
+ }
+
+ renderIfOwnOccupant (o) {
+ (o.get('jid') === _converse.bare_jid) && this.debouncedRender();
+ }
+
+ sendButtonClicked (ev) {
+ this.querySelector('converse-muc-message-form')?.onFormSubmitted(ev);
+ }
+
+ hideOccupants (ev) {
+ ev?.preventDefault?.();
+ ev?.stopPropagation?.();
+ this.model.save({ 'hidden_occupants': true });
+ }
+}
+
+api.elements.define('converse-muc-bottom-panel', MUCBottomPanel);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js
new file mode 100644
index 0000000..ef665d9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js
@@ -0,0 +1,157 @@
+import tplMUCChatarea from './templates/muc-chatarea.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core';
+
+
+const { u } = converse.env;
+
+
+export default class MUCChatArea extends CustomElement {
+
+ static get properties () {
+ return {
+ jid: { type: String },
+ show_help_messages: { type: Boolean },
+ type: { type: String },
+ }
+ }
+
+ async initialize () {
+ this.model = await api.rooms.get(this.jid);
+ this.listenTo(this.model, 'change:show_help_messages', () => this.requestUpdate());
+ this.listenTo(this.model, 'change:hidden_occupants', () => this.requestUpdate());
+ this.listenTo(this.model.session, 'change:connection_status', () => this.requestUpdate());
+
+ // Bind so that we can pass it to addEventListener and removeEventListener
+ this.onMouseMove = this._onMouseMove.bind(this);
+ this.onMouseUp = this._onMouseUp.bind(this);
+
+ this.requestUpdate(); // Make sure we render again after the model has been attached
+ }
+
+ render () {
+ return tplMUCChatarea({
+ 'getHelpMessages': () => this.getHelpMessages(),
+ 'jid': this.jid,
+ 'model': this.model,
+ 'onMousedown': ev => this.onMousedown(ev),
+ 'show_send_button': api.settings.get('show_send_button'),
+ 'shouldShowSidebar': () => this.shouldShowSidebar(),
+ 'type': this.type,
+ });
+ }
+
+ shouldShowSidebar () {
+ return (
+ !this.model.get('hidden_occupants') &&
+ this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED
+ );
+ }
+
+ getHelpMessages () {
+ const setting = api.settings.get('muc_disable_slash_commands');
+ const disabled_commands = Array.isArray(setting) ? setting : [];
+ return [
+ `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
+ `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
+ `<strong>/clear</strong>: ${__('Clear the chat area')}`,
+ `<strong>/close</strong>: ${__('Close this groupchat')}`,
+ `<strong>/deop</strong>: ${__('Change user role to participant')}`,
+ `<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
+ `<strong>/help</strong>: ${__('Show this menu')}`,
+ `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
+ `<strong>/me</strong>: ${__('Write in 3rd person')}`,
+ `<strong>/member</strong>: ${__('Grant membership to a user')}`,
+ `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
+ `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
+ `<strong>/nick</strong>: ${__('Change your nickname')}`,
+ `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
+ `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
+ `<strong>/register</strong>: ${__('Register your nickname')}`,
+ `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
+ `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
+ `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
+ `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
+ ]
+ .filter(line => disabled_commands.every(c => !line.startsWith(c + '<', 9)))
+ .filter(line => this.model.getAllowedCommands().some(c => line.startsWith(c + '<', 9)));
+ }
+
+ onMousedown (ev) {
+ if (u.hasClass('dragresize-occupants-left', ev.target)) {
+ this.onStartResizeOccupants(ev);
+ }
+ }
+
+ onStartResizeOccupants (ev) {
+ this.resizing = true;
+ this.addEventListener('mousemove', this.onMouseMove);
+ this.addEventListener('mouseup', this.onMouseUp);
+
+ const sidebar_el = this.querySelector('converse-muc-sidebar');
+ const style = window.getComputedStyle(sidebar_el);
+ this.width = parseInt(style.width.replace(/px$/, ''), 10);
+ this.prev_pageX = ev.pageX;
+ }
+
+ _onMouseMove (ev) {
+ if (this.resizing) {
+ ev.preventDefault();
+ const delta = this.prev_pageX - ev.pageX;
+ this.resizeSidebarView(delta, ev.pageX);
+ this.prev_pageX = ev.pageX;
+ }
+ }
+
+ _onMouseUp (ev) {
+ if (this.resizing) {
+ ev.preventDefault();
+ this.resizing = false;
+ this.removeEventListener('mousemove', this.onMouseMove);
+ this.removeEventListener('mouseup', this.onMouseUp);
+ const sidebar_el = this.querySelector('converse-muc-sidebar');
+ const element_position = sidebar_el.getBoundingClientRect();
+ const occupants_width = this.calculateSidebarWidth(element_position, 0);
+ u.safeSave(this.model, { occupants_width });
+ }
+ }
+
+ calculateSidebarWidth (element_position, delta) {
+ let occupants_width = element_position.width + delta;
+ const room_width = this.clientWidth;
+ // keeping display in boundaries
+ if (occupants_width < room_width * 0.2) {
+ // set pixel to 20% width
+ occupants_width = room_width * 0.2;
+ this.is_minimum = true;
+ } else if (occupants_width > room_width * 0.75) {
+ // set pixel to 75% width
+ occupants_width = room_width * 0.75;
+ this.is_maximum = true;
+ } else if (room_width - occupants_width < 250) {
+ // resize occupants if chat-area becomes smaller than 250px (min-width property set in css)
+ occupants_width = room_width - 250;
+ this.is_maximum = true;
+ } else {
+ this.is_maximum = false;
+ this.is_minimum = false;
+ }
+ return occupants_width;
+ }
+
+ resizeSidebarView (delta, current_mouse_position) {
+ const sidebar_el = this.querySelector('converse-muc-sidebar');
+ const element_position = sidebar_el.getBoundingClientRect();
+ if (this.is_minimum) {
+ this.is_minimum = element_position.left < current_mouse_position;
+ } else if (this.is_maximum) {
+ this.is_maximum = element_position.left > current_mouse_position;
+ } else {
+ const occupants_width = this.calculateSidebarWidth(element_position, delta);
+ sidebar_el.style.flex = '0 0 ' + occupants_width + 'px';
+ }
+ }
+}
+
+api.elements.define('converse-muc-chatarea', MUCChatArea);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js
new file mode 100644
index 0000000..338a5fe
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js
@@ -0,0 +1,65 @@
+import log from "@converse/headless/log";
+import tplMUCConfigForm from "./templates/muc-config-form.js";
+import { CustomElement } from 'shared/components/element';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+class MUCConfigForm extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ this.listenTo(this.model.features, 'change:passwordprotected', () => this.requestUpdate());
+ this.listenTo(this.model.session, 'change:config_stanza', () => this.requestUpdate());
+ this.getConfig();
+ }
+
+ render () {
+ return tplMUCConfigForm({
+ 'model': this.model,
+ 'closeConfigForm': ev => this.closeForm(ev),
+ 'submitConfigForm': ev => this.submitConfigForm(ev),
+ });
+ }
+
+ async getConfig () {
+ const iq = await this.model.fetchRoomConfiguration();
+ this.model.session.set('config_stanza', iq.outerHTML);
+ }
+
+ async submitConfigForm (ev) {
+ ev.preventDefault();
+ const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
+ const config_array = inputs.map(u.webForm2xForm).filter(f => f);
+ try {
+ await this.model.sendConfiguration(config_array);
+ } catch (e) {
+ log.error(e);
+ const message =
+ __("Sorry, an error occurred while trying to submit the config form.") + " " +
+ __("Check your browser's developer console for details.");
+ api.alert('error', __('Error'), message);
+ }
+ await this.model.refreshDiscoInfo();
+ this.closeForm();
+ }
+
+ closeForm (ev) {
+ ev?.preventDefault?.();
+ this.model.session.set('view', null);
+ }
+}
+
+api.elements.define('converse-muc-config-form', MUCConfigForm);
+
+export default MUCConfigForm
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js
new file mode 100644
index 0000000..2d7b2b9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js
@@ -0,0 +1,9 @@
+export const PRETTY_CHAT_STATUS = {
+ 'offline': 'Offline',
+ 'unavailable': 'Unavailable',
+ 'xa': 'Extended Away',
+ 'away': 'Away',
+ 'dnd': 'Do not disturb',
+ 'chat': 'Chattty',
+ 'online': 'Online'
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js
new file mode 100644
index 0000000..6316a3d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js
@@ -0,0 +1,38 @@
+import tplMUCDestroyed from './templates/muc-destroyed.js';
+import { CustomElement } from 'shared/components/element';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCDestroyed extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ }
+
+ render () {
+ const reason = this.model.get('destroyed_reason');
+ const moved_jid = this.model.get('moved_jid');
+ return tplMUCDestroyed({
+ moved_jid,
+ reason,
+ 'onSwitch': ev => this.onSwitch(ev)
+ });
+ }
+
+ async onSwitch (ev) {
+ ev.preventDefault();
+ const moved_jid = this.model.get('moved_jid');
+ const room = await api.rooms.get(moved_jid, {}, true);
+ room.maybeShow(true);
+ this.model.destroy();
+ }
+}
+
+api.elements.define('converse-muc-destroyed', MUCDestroyed);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js
new file mode 100644
index 0000000..15e89a8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js
@@ -0,0 +1,38 @@
+import tplMUCDisconnect from './templates/muc-disconnect.js';
+import { CustomElement } from 'shared/components/element';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCDisconnected extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ }
+
+ render () {
+ const message = this.model.session.get('disconnection_message');
+ if (!message) {
+ return;
+ }
+ const messages = [message];
+ const actor = this.model.session.get('disconnection_actor');
+ if (actor) {
+ messages.push(__('This action was done by %1$s.', actor));
+ }
+ const reason = this.model.session.get('disconnection_reason');
+ if (reason) {
+ messages.push(__('The reason given is: "%1$s".', reason));
+ }
+ return tplMUCDisconnect(messages);
+ }
+}
+
+api.elements.define('converse-muc-disconnected', MUCDisconnected);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js
new file mode 100644
index 0000000..c2c7db6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js
@@ -0,0 +1,190 @@
+import './modals/muc-details.js';
+import './modals/muc-invite.js';
+import './modals/nickname.js';
+import tplMUCHead from './templates/muc-head.js';
+import { CustomElement } from 'shared/components/element.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core.js";
+import { destroyMUC, showModeratorToolsModal } from './utils.js';
+
+import './styles/muc-head.scss';
+
+
+export default class MUCHeading extends CustomElement {
+
+ async initialize () {
+ this.model = _converse.chatboxes.get(this.getAttribute('jid'));
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:add', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
+
+ this.user_settings = await _converse.api.user.settings.getModel();
+ this.listenTo(this.user_settings, 'change:mucs_with_hidden_subject', () => this.requestUpdate());
+
+ await this.model.initialized;
+ this.listenTo(this.model.features, 'change:open', () => this.requestUpdate());
+ this.model.occupants.forEach(o => this.onOccupantAdded(o));
+ this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
+ this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
+ this.requestUpdate();
+ }
+
+ render () {
+ return (this.model && this.user_settings) ? tplMUCHead(this) : '';
+ }
+
+ onOccupantAdded (occupant) {
+ if (occupant.get('jid') === _converse.bare_jid) {
+ this.requestUpdate();
+ }
+ }
+
+ onOccupantAffiliationChanged (occupant) {
+ if (occupant.get('jid') === _converse.bare_jid) {
+ this.requestUpdate();
+ }
+ }
+
+ showRoomDetailsModal (ev) {
+ ev.preventDefault();
+ api.modal.show('converse-muc-details-modal', { 'model': this.model }, ev);
+ }
+
+ showInviteModal (ev) {
+ ev.preventDefault();
+ api.modal.show('converse-muc-invite-modal', { 'model': new Model(), 'chatroomview': this }, ev);
+ }
+
+ toggleTopic (ev) {
+ ev?.preventDefault?.();
+ this.model.toggleSubjectHiddenState();
+ }
+
+ getAndRenderConfigurationForm () {
+ this.model.session.set('view', converse.MUC.VIEWS.CONFIG);
+ }
+
+ close (ev) {
+ ev.preventDefault();
+ this.model.close();
+ }
+
+ destroy (ev) {
+ ev.preventDefault();
+ destroyMUC(this.model);
+ }
+
+ /**
+ * Returns a list of objects which represent buttons for the groupchat header.
+ * @emits _converse#getHeadingButtons
+ */
+ getHeadingButtons (subject_hidden) {
+ const buttons = [];
+ buttons.push({
+ 'i18n_text': __('Details'),
+ 'i18n_title': __('Show more information about this groupchat'),
+ 'handler': ev => this.showRoomDetailsModal(ev),
+ 'a_class': 'show-muc-details-modal',
+ 'icon_class': 'fa-info-circle',
+ 'name': 'details'
+ });
+
+ if (this.model.getOwnAffiliation() === 'owner') {
+ buttons.push({
+ 'i18n_text': __('Configure'),
+ 'i18n_title': __('Configure this groupchat'),
+ 'handler': () => this.getAndRenderConfigurationForm(),
+ 'a_class': 'configure-chatroom-button',
+ 'icon_class': 'fa-wrench',
+ 'name': 'configure'
+ });
+ }
+
+ buttons.push({
+ 'i18n_text': __('Nickname'),
+ 'i18n_title': __("Change the nickname you're using in this groupchat"),
+ 'handler': ev => api.modal.show('converse-muc-nickname-modal', { 'model': this.model }, ev),
+ 'a_class': 'open-nickname-modal',
+ 'icon_class': 'fa-smile',
+ 'name': 'nickname'
+ });
+
+ if (this.model.invitesAllowed()) {
+ buttons.push({
+ 'i18n_text': __('Invite'),
+ 'i18n_title': __('Invite someone to join this groupchat'),
+ 'handler': ev => this.showInviteModal(ev),
+ 'a_class': 'open-invite-modal',
+ 'icon_class': 'fa-user-plus',
+ 'name': 'invite'
+ });
+ }
+
+ const subject = this.model.get('subject');
+ if (subject && subject.text) {
+ buttons.push({
+ 'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'),
+ 'i18n_title': subject_hidden
+ ? __('Show the topic message in the heading')
+ : __('Hide the topic in the heading'),
+ 'handler': ev => this.toggleTopic(ev),
+ 'a_class': 'hide-topic',
+ 'icon_class': 'fa-minus-square',
+ 'name': 'toggle-topic'
+ });
+ }
+
+ const conn_status = this.model.session.get('connection_status');
+ if (conn_status === converse.ROOMSTATUS.ENTERED) {
+ const allowed_commands = this.model.getAllowedCommands();
+ if (allowed_commands.includes('modtools')) {
+ buttons.push({
+ 'i18n_text': __('Moderate'),
+ 'i18n_title': __('Moderate this groupchat'),
+ 'handler': () => showModeratorToolsModal(this.model),
+ 'a_class': 'moderate-chatroom-button',
+ 'icon_class': 'fa-user-cog',
+ 'name': 'moderate'
+ });
+ }
+ if (allowed_commands.includes('destroy')) {
+ buttons.push({
+ 'i18n_text': __('Destroy'),
+ 'i18n_title': __('Remove this groupchat'),
+ 'handler': ev => this.destroy(ev),
+ 'a_class': 'destroy-chatroom-button',
+ 'icon_class': 'fa-trash',
+ 'name': 'destroy'
+ });
+ }
+ }
+
+ if (!api.settings.get('singleton')) {
+ buttons.push({
+ 'i18n_text': __('Leave'),
+ 'i18n_title': __('Leave and close this groupchat'),
+ 'handler': async ev => {
+ ev.stopPropagation();
+ const messages = [__('Are you sure you want to leave this groupchat?')];
+ const result = await api.confirm(__('Confirm'), messages);
+ result && this.close(ev);
+ },
+ 'a_class': 'close-chatbox-button',
+ 'standalone': api.settings.get('view_mode') === 'overlayed',
+ 'icon_class': 'fa-sign-out-alt',
+ 'name': 'signout'
+ });
+ }
+
+ const el = _converse.chatboxviews.get(this.getAttribute('jid'));
+ if (el) {
+ // This hook is described in src/plugins/chatview/heading.js
+ return _converse.api.hook('getHeadingButtons', el, buttons);
+ } else {
+ return Promise.resolve(buttons); // Happens during tests
+ }
+ }
+}
+
+api.elements.define('converse-muc-heading', MUCHeading);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js
new file mode 100644
index 0000000..d7543c1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js
@@ -0,0 +1,97 @@
+/**
+ * @copyright The Converse.js developers
+ * @description XEP-0045 Multi-User Chat Views
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../chatboxviews/index.js';
+import './affiliation-form.js';
+import './role-form.js';
+import MUCView from './muc.js';
+import { api, converse } from '@converse/headless/core.js';
+import { clearHistory, confirmDirectMUCInvitation, parseMessageForMUCCommands } from './utils.js';
+
+const { Strophe } = converse.env;
+
+import './styles/index.scss';
+
+converse.MUC.VIEWS = {
+ CONFIG: 'config-form',
+}
+
+converse.plugins.add('converse-muc-views', {
+ /* Dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin. They are "optional" because they might not be
+ * available, in which case any overrides applicable to them will be
+ * ignored.
+ *
+ * NB: These plugins need to have already been loaded via require.js.
+ *
+ * It's possible to make these dependencies "non-optional".
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found.
+ */
+ dependencies: ['converse-modal', 'converse-controlbox', 'converse-chatview'],
+
+ initialize () {
+ const { _converse } = this;
+
+ // Configuration values for this plugin
+ // ====================================
+ // Refer to docs/source/configuration.rst for explanations of these
+ // configuration settings.
+ api.settings.extend({
+ 'auto_list_rooms': false,
+ 'cache_muc_messages': true,
+ 'locked_muc_nickname': false,
+ 'modtools_disable_query': [],
+ 'muc_disable_slash_commands': false,
+ 'muc_mention_autocomplete_filter': 'contains',
+ 'muc_mention_autocomplete_min_chars': 0,
+ 'muc_mention_autocomplete_show_avatar': true,
+ 'muc_roomid_policy': null,
+ 'muc_roomid_policy_hint': null,
+ 'roomconfig_whitelist': [],
+ 'show_retraction_warning': true,
+ 'visible_toolbar_buttons': {
+ 'toggle_occupants': true
+ }
+ });
+
+ _converse.ChatRoomView = MUCView;
+
+ if (!api.settings.get('muc_domain')) {
+ // Use service discovery to get the default MUC domain
+ api.listen.on('serviceDiscovered', async (feature) => {
+ if (feature?.get('var') === Strophe.NS.MUC) {
+ if (feature.entity.get('jid').includes('@')) {
+ // Ignore full JIDs, we're only looking for a MUC service, not a room
+ return;
+ }
+ const identity = await feature.entity.getIdentity('conference', 'text');
+ if (identity) {
+ api.settings.set('muc_domain', Strophe.getDomainFromJid(feature.get('from')));
+ }
+ }
+ });
+ }
+
+ api.listen.on('clearsession', () => {
+ const view = _converse.chatboxviews.get('controlbox');
+ if (view && view.roomspanel) {
+ view.roomspanel.model.destroy();
+ view.roomspanel.remove();
+ delete view.roomspanel;
+ }
+ });
+
+ api.listen.on('chatBoxClosed', (model) => {
+ if (model.get('type') === _converse.CHATROOMS_TYPE) {
+ clearHistory(model.get('jid'));
+ }
+ });
+
+ api.listen.on('parseMessageForCommands', parseMessageForMUCCommands);
+ api.listen.on('confirmDirectMUCInvitation', confirmDirectMUCInvitation);
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js
new file mode 100644
index 0000000..3561414
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js
@@ -0,0 +1,72 @@
+import MessageForm from 'plugins/chatview/message-form.js';
+import tplMUCMessageForm from './templates/message-form.js';
+import { _converse, api, converse } from "@converse/headless/core";
+import { getAutoCompleteListItem } from './utils.js';
+
+
+export default class MUCMessageForm extends MessageForm {
+
+ async connectedCallback () {
+ super.connectedCallback();
+ await this.model.initialized;
+ }
+
+ toHTML () {
+ return tplMUCMessageForm(
+ Object.assign(this.model.toJSON(), {
+ 'hint_value': this.querySelector('.spoiler-hint')?.value,
+ 'message_value': this.querySelector('.chat-textarea')?.value,
+ 'onChange': ev => this.model.set({'draft': ev.target.value}),
+ 'onDrop': ev => this.onDrop(ev),
+ 'onKeyDown': ev => this.onKeyDown(ev),
+ 'onKeyUp': ev => this.onKeyUp(ev),
+ 'onPaste': ev => this.onPaste(ev),
+ 'scrolled': this.model.ui.get('scrolled'),
+ 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+ }));
+ }
+
+ afterRender () {
+ const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
+ const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
+ if (entered && can_edit) {
+ this.initMentionAutoComplete();
+ }
+ }
+
+ initMentionAutoComplete () {
+ this.mention_auto_complete = new _converse.AutoComplete(this, {
+ 'auto_first': true,
+ 'auto_evaluate': false,
+ 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
+ 'match_current_word': true,
+ 'list': () => this.getAutoCompleteList(),
+ 'filter':
+ api.settings.get('muc_mention_autocomplete_filter') == 'contains'
+ ? _converse.FILTER_CONTAINS
+ : _converse.FILTER_STARTSWITH,
+ 'ac_triggers': ['Tab', '@'],
+ 'include_triggers': [],
+ 'item': getAutoCompleteListItem
+ });
+ this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+ }
+
+ getAutoCompleteList () {
+ return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` }));
+ }
+
+ onKeyDown (ev) {
+ if (this.mention_auto_complete.onKeyDown(ev)) {
+ return;
+ }
+ super.onKeyDown(ev);
+ }
+
+ onKeyUp (ev) {
+ this.mention_auto_complete.evaluate(ev);
+ super.onKeyUp(ev);
+ }
+}
+
+api.elements.define('converse-muc-message-form', MUCMessageForm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js
new file mode 100644
index 0000000..0cc94bf
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js
@@ -0,0 +1,92 @@
+import tplAddMuc from "./templates/add-muc.js";
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+import '../styles/add-muc-modal.scss';
+
+const u = converse.env.utils;
+const { Strophe } = converse.env;
+
+
+export default class AddMUCModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change:muc_domain', () => this.render());
+ this.muc_roomid_policy_error_msg = null;
+ this.render();
+ this.addEventListener('shown.bs.modal', () => {
+ this.querySelector('input[name="chatroom"]').focus();
+ }, false);
+ }
+
+ renderModal () {
+ return tplAddMuc(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Enter a new Groupchat');
+ }
+
+ parseRoomDataFromEvent (form) { // eslint-disable-line class-methods-use-this
+ const data = new FormData(form);
+ const jid = data.get('chatroom')?.trim();
+ let nick;
+ if (api.settings.get('locked_muc_nickname')) {
+ nick = _converse.getDefaultMUCNickname();
+ if (!nick) {
+ throw new Error("Using locked_muc_nickname but no nickname found!");
+ }
+ } else {
+ nick = data.get('nickname').trim();
+ }
+ return {
+ 'jid': jid,
+ 'nick': nick
+ }
+ }
+
+ openChatRoom (ev) {
+ ev.preventDefault();
+ if (this.checkRoomidPolicy()) return;
+
+ const data = this.parseRoomDataFromEvent(ev.target);
+ if (data.nick === "") {
+ // Make sure defaults apply if no nick is provided.
+ data.nick = undefined;
+ }
+ let jid;
+ if (api.settings.get('locked_muc_domain') || (api.settings.get('muc_domain') && !u.isValidJID(data.jid))) {
+ jid = `${Strophe.escapeNode(data.jid)}@${api.settings.get('muc_domain')}`;
+ } else {
+ jid = data.jid
+ this.model.setDomain(jid);
+ }
+
+ api.rooms.open(jid, Object.assign(data, {jid}), true);
+ ev.target.reset();
+ this.modal.hide();
+ }
+
+ checkRoomidPolicy () {
+ if (api.settings.get('muc_roomid_policy') && api.settings.get('muc_domain')) {
+ let jid = this.querySelector('converse-autocomplete input').value;
+ if (api.settings.get('locked_muc_domain') || !u.isValidJID(jid)) {
+ jid = `${Strophe.escapeNode(jid)}@${api.settings.get('muc_domain')}`;
+ }
+ const roomid = Strophe.getNodeFromJid(jid);
+ const roomdomain = Strophe.getDomainFromJid(jid);
+ if (api.settings.get('muc_domain') !== roomdomain ||
+ api.settings.get('muc_roomid_policy').test(roomid)) {
+ this.muc_roomid_policy_error_msg = null;
+ } else {
+ this.muc_roomid_policy_error_msg = __('Groupchat id is invalid.');
+ return true;
+ }
+ this.render();
+ }
+ }
+}
+
+api.elements.define('converse-add-muc-modal', AddMUCModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js
new file mode 100644
index 0000000..25b12ff
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js
@@ -0,0 +1,24 @@
+import '../modtools.js';
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from 'lit';
+
+export default class ModeratorToolsModal extends BaseModal {
+
+ constructor (options) {
+ super(options);
+ this.id = "converse-modtools-modal";
+ }
+
+ renderModal () {
+ return html`<converse-modtools jid=${this.jid} affiliation=${this.affiliation}></converse-modtools>`;
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Moderator Tools');
+ }
+
+}
+
+api.elements.define('converse-modtools-modal', ModeratorToolsModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js
new file mode 100644
index 0000000..a180232
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js
@@ -0,0 +1,29 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplMUCDetails from "./templates/muc-details.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+
+import '../styles/muc-details-modal.scss';
+
+
+export default class MUCDetailsModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render());
+ this.listenTo(this.model.features, 'change', () => this.render());
+ this.listenTo(this.model.occupants, 'add', () => this.render());
+ this.listenTo(this.model.occupants, 'change', () => this.render());
+ }
+
+ renderModal () {
+ return tplMUCDetails(this.model);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Groupchat info for %1$s', this.model.getDisplayName());
+ }
+
+}
+
+api.elements.define('converse-muc-details-modal', MUCDetailsModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js
new file mode 100644
index 0000000..944391f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js
@@ -0,0 +1,44 @@
+import 'shared/autocomplete/index.js';
+import BaseModal from "plugins/modal/modal.js";
+import tplMUCInviteModal from "./templates/muc-invite.js";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const u = converse.env.utils;
+
+export default class MUCInviteModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render());
+ }
+
+ renderModal () {
+ return tplMUCInviteModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Invite someone to this groupchat');
+ }
+
+ getAutoCompleteList () { // eslint-disable-line class-methods-use-this
+ return _converse.roster.map(i => ({'label': i.getDisplayName(), 'value': i.get('jid')}));
+ }
+
+ submitInviteForm (ev) {
+ ev.preventDefault();
+ // TODO: Add support for sending an invite to multiple JIDs
+ const data = new FormData(ev.target);
+ const jid = data.get('invitee_jids')?.trim();
+ const reason = data.get('reason');
+ if (u.isValidJID(jid)) {
+ // TODO: Create and use API here
+ this.chatroomview.model.directInvite(jid, reason);
+ this.modal.hide();
+ } else {
+ this.model.set({'invalid_invite_jid': true});
+ }
+ }
+}
+
+api.elements.define('converse-muc-invite-modal', MUCInviteModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js
new file mode 100644
index 0000000..b257f4b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js
@@ -0,0 +1,177 @@
+import BaseModal from "plugins/modal/modal.js";
+import head from "lodash-es/head";
+import log from "@converse/headless/log";
+import tplMUCDescription from "../templates/muc-description.js";
+import tplMUCList from "../templates/muc-list.js";
+import tplSpinner from "templates/spinner.js";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { getAttributes } from '@converse/headless/shared/parsers';
+
+const { Strophe, $iq, sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+/* Insert groupchat info (based on returned #disco IQ stanza)
+ * @function insertRoomInfo
+ * @param { HTMLElement } el - The HTML DOM element that contains the info.
+ * @param { Element } stanza - The IQ stanza containing the groupchat info.
+ */
+function insertRoomInfo (el, stanza) {
+ // All MUC features found here: https://xmpp.org/registrar/disco-features.html
+ el.querySelector('span.spinner').remove();
+ el.querySelector('a.room-info').classList.add('selected');
+ el.insertAdjacentHTML(
+ 'beforeEnd',
+ u.getElementFromTemplateResult(tplMUCDescription({
+ 'jid': stanza.getAttribute('from'),
+ 'desc': head(sizzle('field[var="muc#roominfo_description"] value', stanza))?.textContent,
+ 'occ': head(sizzle('field[var="muc#roominfo_occupants"] value', stanza))?.textContent,
+ 'hidden': sizzle('feature[var="muc_hidden"]', stanza).length,
+ 'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length,
+ 'moderated': sizzle('feature[var="muc_moderated"]', stanza).length,
+ 'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length,
+ 'open': sizzle('feature[var="muc_open"]', stanza).length,
+ 'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length,
+ 'persistent': sizzle('feature[var="muc_persistent"]', stanza).length,
+ 'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length,
+ 'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length,
+ 'temporary': sizzle('feature[var="muc_temporary"]', stanza).length,
+ 'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length
+ })));
+}
+
+
+/**
+ * Show/hide extra information about a groupchat in a listing.
+ * @function toggleRoomInfo
+ * @param { Event }
+ */
+function toggleRoomInfo (ev) {
+ const parent_el = u.ancestor(ev.target, '.room-item');
+ const div_el = parent_el.querySelector('div.room-info');
+ if (div_el) {
+ u.slideIn(div_el).then(u.removeElement)
+ parent_el.querySelector('a.room-info').classList.remove('selected');
+ } else {
+ parent_el.insertAdjacentElement(
+ 'beforeend',
+ u.getElementFromTemplateResult(tplSpinner())
+ );
+ api.disco.info(ev.target.getAttribute('data-room-jid'), null)
+ .then(stanza => insertRoomInfo(parent_el, stanza))
+ .catch(e => log.error(e));
+ }
+}
+
+
+export default class MUCListModal extends BaseModal {
+
+ constructor (options) {
+ super(options);
+ this.items = [];
+ this.loading_items = false;
+ }
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change:muc_domain', this.onDomainChange);
+ this.listenTo(this.model, 'change:feedback_text', () => this.render());
+
+ this.addEventListener('shown.bs.modal', () => api.settings.get('locked_muc_domain') && this.updateRoomsList());
+
+ this.model.save('feedback_text', '');
+ }
+
+ renderModal () {
+ return tplMUCList(
+ Object.assign(this.model.toJSON(), {
+ 'show_form': !api.settings.get('locked_muc_domain'),
+ 'server_placeholder': this.model.get('muc_domain') || __('conference.example.org'),
+ 'items': this.items,
+ 'loading_items': this.loading_items,
+ 'openRoom': ev => this.openRoom(ev),
+ 'setDomainFromEvent': ev => this.setDomainFromEvent(ev),
+ 'submitForm': ev => this.showRooms(ev),
+ 'toggleRoomInfo': ev => this.toggleRoomInfo(ev)
+ }));
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Query for Groupchats');
+ }
+
+ openRoom (ev) {
+ ev.preventDefault();
+ const jid = ev.target.getAttribute('data-room-jid');
+ const name = ev.target.getAttribute('data-room-name');
+ this.modal.hide();
+ api.rooms.open(jid, {'name': name}, true);
+ }
+
+ toggleRoomInfo (ev) { // eslint-disable-line
+ ev.preventDefault();
+ toggleRoomInfo(ev);
+ }
+
+ onDomainChange () {
+ api.settings.get('auto_list_rooms') && this.updateRoomsList();
+ }
+
+ /**
+ * Handle the IQ stanza returned from the server, containing
+ * all its public groupchats.
+ * @private
+ * @method _converse.ChatRoomView#onRoomsFound
+ * @param { HTMLElement } iq
+ */
+ onRoomsFound (iq) {
+ this.loading_items = false;
+ const rooms = iq ? sizzle('query item', iq) : [];
+ if (rooms.length) {
+ this.model.set({'feedback_text': __('Groupchats found')}, {'silent': true});
+ this.items = rooms.map(getAttributes);
+ } else {
+ this.items = [];
+ this.model.set({'feedback_text': __('No groupchats found')}, {'silent': true});
+ }
+ this.render();
+ return true;
+ }
+
+ /**
+ * Send an IQ stanza to the server asking for all groupchats
+ * @private
+ * @method _converse.ChatRoomView#updateRoomsList
+ */
+ updateRoomsList () {
+ const iq = $iq({
+ 'to': this.model.get('muc_domain'),
+ 'from': _converse.connection.jid,
+ 'type': "get"
+ }).c("query", {xmlns: Strophe.NS.DISCO_ITEMS});
+ api.sendIQ(iq)
+ .then(iq => this.onRoomsFound(iq))
+ .catch(() => this.onRoomsFound())
+ }
+
+ showRooms (ev) {
+ ev.preventDefault();
+ this.loading_items = true;
+ this.render();
+
+ const data = new FormData(ev.target);
+ this.model.setDomain(data.get('server'));
+ this.updateRoomsList();
+ }
+
+ setDomainFromEvent (ev) {
+ this.model.setDomain(ev.target.value);
+ }
+
+ setNick (ev) {
+ this.model.save({nick: ev.target.value});
+ }
+}
+
+api.elements.define('converse-muc-list-modal', MUCListModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js
new file mode 100644
index 0000000..3284e1e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js
@@ -0,0 +1,17 @@
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core.js";
+import { html } from 'lit';
+
+export default class MUCNicknameModal extends BaseModal {
+
+ renderModal () {
+ return html`<converse-muc-nickname-form jid="${this.model.get('jid')}"></converse-muc-nickname-form>`;
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Change your nickname');
+ }
+}
+
+api.elements.define('converse-muc-nickname-modal', MUCNicknameModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js
new file mode 100644
index 0000000..65988e9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js
@@ -0,0 +1,67 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplOccupantModal from "./templates/occupant.js";
+import { Model } from '@converse/skeletor/src/model.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { u } = converse.env;
+
+export default class OccupantModal extends BaseModal {
+
+ constructor () {
+ super();
+ this.addEventListener("affiliationChanged", () => this.alert(__('Affiliation changed')));
+ this.addEventListener("roleChanged", () => this.alert(__('role changed')));
+ }
+
+ initialize () {
+ super.initialize()
+ const model = this.model ?? this.message;
+ this.listenTo(model, 'change', () => this.render());
+ /**
+ * Triggered once the OccupantModal has been initialized
+ * @event _converse#occupantModalInitialized
+ * @type { Object }
+ * @example _converse.api.listen.on('occupantModalInitialized', data);
+ */
+ api.trigger('occupantModalInitialized', { 'model': this.model, 'message': this.message });
+ }
+
+ getVcard () {
+ const model = this.model ?? this.message;
+ if (model.vcard) {
+ return model.vcard;
+ }
+ const jid = model?.get('jid') || model?.get('from');
+ return jid ? _converse.vcards.get(jid) : null;
+ }
+
+ renderModal () {
+ return tplOccupantModal(this);
+ }
+
+ getModalTitle () {
+ const model = this.model ?? this.message;
+ return model?.getDisplayName();
+ }
+
+ addToContacts () {
+ const model = this.model ?? this.message;
+ const jid = model.get('jid');
+ if (jid) api.modal.show('converse-add-contact-modal', {'model': new Model({ jid })});
+ }
+
+ toggleForm (ev) {
+ const toggle = u.ancestor(ev.target, '.toggle-form');
+ const form = toggle.getAttribute('data-form');
+
+ if (form === 'row-form') {
+ this.show_role_form = !this.show_role_form;
+ } else {
+ this.show_affiliation_form = !this.show_affiliation_form;
+ }
+ this.render();
+ }
+}
+
+api.elements.define('converse-muc-occupant-modal', OccupantModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js
new file mode 100644
index 0000000..2469707
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js
@@ -0,0 +1,57 @@
+import DOMPurify from 'dompurify';
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core.js';
+import { html } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { getAutoCompleteList } from "../../search.js";
+
+
+const nickname_input = (el) => {
+ const i18n_nickname = __('Nickname');
+ const i18n_required_field = __('This field is required');
+ return html`
+ <div class="form-group" >
+ <label for="nickname">${i18n_nickname}:</label>
+ <input type="text"
+ title="${i18n_required_field}"
+ required="required"
+ name="nickname"
+ value="${el.model.get('nick') || ''}"
+ class="form-control"/>
+ </div>
+ `;
+}
+
+export default (el) => {
+ const i18n_join = __('Join');
+ const muc_domain = el.model.get('muc_domain') || api.settings.get('muc_domain');
+
+ let placeholder = '';
+ if (!api.settings.get('locked_muc_domain')) {
+ placeholder = muc_domain ? `name@${muc_domain}` : __('name@conference.example.org');
+ }
+
+ const label_room_address = muc_domain ? __('Groupchat name') : __('Groupchat address');
+ const muc_roomid_policy_error_msg = el.muc_roomid_policy_error_msg;
+ const muc_roomid_policy_hint = api.settings.get('muc_roomid_policy_hint');
+ return html`
+ <form class="converse-form add-chatroom" @submit=${(ev) => el.openChatRoom(ev)}>
+ <div class="form-group">
+ <label for="chatroom">${label_room_address}:</label>
+ ${ (muc_roomid_policy_error_msg) ? html`<label class="roomid-policy-error">${muc_roomid_policy_error_msg}</label>` : '' }
+ <converse-autocomplete
+ .getAutoCompleteList=${getAutoCompleteList}
+ ?autofocus=${true}
+ min_chars="3"
+ position="below"
+ placeholder="${placeholder}"
+ class="add-muc-autocomplete"
+ name="chatroom">
+ </converse-autocomplete>
+ </div>
+ ${ muc_roomid_policy_hint ? html`<div class="form-group">${unsafeHTML(DOMPurify.sanitize(muc_roomid_policy_hint, {'ALLOWED_TAGS': ['b', 'br', 'em']}))}</div>` : '' }
+ ${ !api.settings.get('locked_muc_nickname') ? nickname_input(el) : '' }
+ <input type="submit" class="btn btn-primary" name="join" value="${i18n_join || ''}" ?disabled=${muc_roomid_policy_error_msg}/>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js
new file mode 100644
index 0000000..ed5ca67
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js
@@ -0,0 +1,78 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+
+
+const subject = (o) => {
+ const i18n_topic = __('Topic');
+ const i18n_topic_author = __('Topic author');
+ return html`
+ <p class="room-info"><strong>${i18n_topic}</strong>: <converse-rich-text text=${o.subject.text} render_styling></converse-rich-text></p>
+ <p class="room-info"><strong>${i18n_topic_author}</strong>: ${o.subject && o.subject.author}</p>
+ `;
+}
+
+
+export default (model) => {
+ const o = model.toJSON();
+ const config = model.config.toJSON();
+ const features = model.features.toJSON();
+ const num_occupants = model.occupants.filter(o => o.get('show') !== 'offline').length;
+
+ const i18n_address = __('XMPP address');
+ const i18n_archiving = __('Message archiving');
+ const i18n_archiving_help = __('Messages are archived on the server');
+ const i18n_desc = __('Description');
+ const i18n_features = __('Features');
+ const i18n_hidden = __('Hidden');
+ const i18n_hidden_help = __('This groupchat is not publicly searchable');
+ const i18n_members_help = __('This groupchat is restricted to members only');
+ const i18n_members_only = __('Members only');
+ const i18n_moderated = __('Moderated');
+ const i18n_moderated_help = __('Participants entering this groupchat need to request permission to write');
+ const i18n_name = __('Name');
+ const i18n_no_pass_help = __('This groupchat does not require a password upon entry');
+ const i18n_no_password_required = __('No password required');
+ const i18n_not_anonymous = __('Not anonymous');
+ const i18n_not_anonymous_help = __('All other groupchat participants can see your XMPP address');
+ const i18n_not_moderated = __('Not moderated');
+ const i18n_not_moderated_help = __('Participants entering this groupchat can write right away');
+ const i18n_online_users = __('Online users');
+ const i18n_open = __('Open');
+ const i18n_open_help = __('Anyone can join this groupchat');
+ const i18n_password_help = __('This groupchat requires a password before entry');
+ const i18n_password_protected = __('Password protected');
+ const i18n_persistent = __('Persistent');
+ const i18n_persistent_help = __('This groupchat persists even if it\'s unoccupied');
+ const i18n_public = __('Public');
+ const i18n_semi_anon = __('Semi-anonymous');
+ const i18n_semi_anon_help = __('Only moderators can see your XMPP address');
+ const i18n_temporary = __('Temporary');
+ const i18n_temporary_help = __('This groupchat will disappear once the last person leaves');
+ return html`
+ <div class="room-info">
+ <p class="room-info"><strong>${i18n_name}</strong>: ${o.name}</p>
+ <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${o.jid}?join"></converse-rich-text></p>
+ <p class="room-info"><strong>${i18n_desc}</strong>: <converse-rich-text text="${config.description}" render_styling></converse-rich-text></p>
+ ${ (o.subject) ? subject(o) : '' }
+ <p class="room-info"><strong>${i18n_online_users}</strong>: ${num_occupants}</p>
+ <p class="room-info"><strong>${i18n_features}</strong>:
+ <div class="chatroom-features">
+ <ul class="features-list">
+ ${ features.passwordprotected ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' }
+ ${ features.unsecured ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' }
+ ${ features.hidden ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' }
+ ${ features.public_room ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${o.__('This groupchat is publicly searchable') }</em></li>` : '' }
+ ${ features.membersonly ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' }
+ ${ features.open ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' }
+ ${ features.persistent ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' }
+ ${ features.temporary ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-snowflake"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' }
+ ${ features.nonanonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' }
+ ${ features.semianonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' }
+ ${ features.moderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' }
+ ${ features.unmoderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' }
+ ${ features.mam_enabled ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' }
+ </ul>
+ </div>
+ </p>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js
new file mode 100644
index 0000000..78e64ae
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js
@@ -0,0 +1,35 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+export default (el) => {
+ const i18n_invite = __('Invite');
+ const i18n_jid_placeholder = __('user@example.org');
+ const i18n_error_message = __('Please enter a valid XMPP address');
+ const i18n_invite_label = __('XMPP Address');
+ const i18n_reason = __('Optional reason for the invitation');
+ return html`
+ <form class="converse-form" @submit=${(ev) => el.submitInviteForm(ev)}>
+ <div class="form-group">
+ <label class="clearfix" for="invitee_jids">${i18n_invite_label}:</label>
+ ${ el.model.get('invalid_invite_jid') ? html`<div class="error error-feedback">${i18n_error_message}</div>` : '' }
+ <converse-autocomplete
+ .getAutoCompleteList=${() => el.getAutoCompleteList()}
+ ?autofocus=${true}
+ min_chars="1"
+ position="below"
+ required="required"
+ name="invitee_jids"
+ id="invitee_jids"
+ placeholder="${i18n_jid_placeholder}">
+ </converse-autocomplete>
+ </div>
+ <div class="form-group">
+ <label>${i18n_reason}:</label>
+ <textarea class="form-control" name="reason"></textarea>
+ </div>
+ <div class="form-group">
+ <input type="submit" class="btn btn-primary" value="${i18n_invite}"/>
+ </div>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js
new file mode 100644
index 0000000..3ccc87a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js
@@ -0,0 +1,87 @@
+import 'shared/avatar/avatar.js';
+import { __ } from 'i18n';
+import { html } from "lit";
+import { until } from 'lit/directives/until.js';
+import { _converse, api } from "@converse/headless/core";
+
+
+export default (el) => {
+ const model = el.model ?? el.message;
+ const jid = model?.get('jid');
+ const vcard = el.getVcard();
+ const nick = model.get('nick');
+ const occupant_id = model.get('occupant_id');
+ const role = el.model?.get('role');
+ const affiliation = el.model?.get('affiliation');
+ const hats = el.model?.get('hats')?.length ? el.model.get('hats') : null;
+ const muc = el.model.collection.chatroom;
+
+ const allowed_commands = muc.getAllowedCommands();
+ const may_moderate = allowed_commands.includes('modtools');
+
+ const i18n_add_to_contacts = __('Add to Contacts');
+
+ const can_see_real_jids = muc.features.get('nonanonymous') || muc.getOwnRole() === 'moderator';
+ const not_me = jid != _converse.bare_jid;
+
+ const add_to_contacts = api.contacts.get(jid)
+ .then(contact => !contact && not_me && can_see_real_jids)
+ .then(add => add ? html`<li><button class="btn btn-primary" type="button" @click=${() => el.addToContacts()}>${i18n_add_to_contacts}</button></li>` : '');
+
+ return html`
+ <div class="row">
+ <div class="col-auto">
+ <converse-avatar
+ class="avatar modal-avatar"
+ .data=${vcard?.attributes}
+ nonce=${vcard?.get('vcard_updated')}
+ height="120" width="120"></converse-avatar>
+ </div>
+ <div class="col">
+ <ul class="occupant-details">
+ <li>
+ ${ nick ? html`<div class="row"><strong>${__('Nickname')}:</strong></div><div class="row">${nick}</div>` : '' }
+ </li>
+ <li>
+ ${ jid ? html`<div class="row"><strong>${__('XMPP Address')}:</strong></div><div class="row">${jid}</div>` : '' }
+ </li>
+ <li>
+ <div class="row"><strong>${__('Affiliation')}:</strong></div>
+ <div class="row">${affiliation}&nbsp;
+ ${ may_moderate ? html`
+ <a href="#"
+ data-form="affiliation-form"
+ class="toggle-form right"
+ color="var(--subdued-color)"
+ @click=${(ev) => el.toggleForm(ev)}><converse-icon class="fa fa-wrench" size="1em"></converse-icon>
+ </a>
+ ${ el.show_affiliation_form ? html`<converse-muc-affiliation-form jid=${jid} .muc=${muc} affiliation=${affiliation}></converse-muc-affiliation-form>` : '' }` : ''
+ }
+ </div>
+ </li>
+ <li>
+ <div class="row"><strong>${__('Role')}:</strong></div>
+ <div class="row">${role}&nbsp;
+ ${ may_moderate && role ? html`
+ <a href="#"
+ data-form="row-form"
+ class="toggle-form right"
+ color="var(--subdued-color)"
+ @click=${(ev) => el.toggleForm(ev)}><converse-icon class="fa fa-wrench" size="1em"></converse-icon>
+ </a>
+ ${ el.show_role_form ? html`<converse-muc-role-form jid=${jid} .muc=${muc} role=${role}></converse-muc-role-form>` : '' }` : ''
+ }
+ </div>
+ </li>
+ <li>
+ ${ hats ? html`<div class="row"><strong>${__('Hats')}:</strong></div><div class="row">${hats}</div>` : '' }
+ </li>
+ <li>
+ ${ occupant_id ? html`<div class="row"><strong>${__('Occupant Id')}:</strong></div><div class="row">${occupant_id}</div>` : '' }
+ </li>
+ ${ until(add_to_contacts, '') }
+ </ul>
+ </div>
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js
new file mode 100644
index 0000000..b9b6377
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js
@@ -0,0 +1,200 @@
+import tplModeratorTools from './templates/moderator-tools.js';
+import { AFFILIATIONS, ROLES } from '@converse/headless/plugins/muc/constants.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core.js';
+import { getAffiliationList, getAssignableAffiliations } from '@converse/headless/plugins/muc/affiliations/utils.js';
+import { getAssignableRoles, getAutoFetchedAffiliationLists } from '@converse/headless/plugins/muc/utils.js';
+import { getOpenPromise } from '@converse/openpromise';
+
+import './styles/moderator-tools.scss';
+
+const { u } = converse.env;
+
+export default class ModeratorTools extends CustomElement {
+ static get properties () {
+ return {
+ affiliation: { type: String },
+ affiliations_filter: { type: String, attribute: false },
+ alert_message: { type: String, attribute: false },
+ alert_type: { type: String, attribute: false },
+ jid: { type: String },
+ muc: { type: Object, attribute: false },
+ role: { type: String },
+ roles_filter: { type: String, attribute: false },
+ tab: { type: String },
+ users_with_affiliation: { type: Array, attribute: false },
+ users_with_role: { type: Array, attribute: false },
+ };
+ }
+
+ constructor () {
+ super();
+ this.tab = 'affiliations';
+ this.affiliation = '';
+ this.affiliations_filter = '';
+ this.role = '';
+ this.roles_filter = '';
+
+ this.addEventListener("affiliationChanged", () => {
+ this.alert(__('Affiliation changed'), 'primary');
+ this.onSearchAffiliationChange();
+ this.requestUpdate()
+ });
+
+ this.addEventListener("roleChanged", () => {
+ this.alert(__('Role changed'), 'primary');
+ this.requestUpdate()
+ });
+ }
+
+ updated (changed) {
+ changed.has('role') && this.onSearchRoleChange();
+ changed.has('affiliation') && this.onSearchAffiliationChange();
+ changed.has('jid') && changed.get('jid') && this.initialize();
+ }
+
+ async initialize () {
+ this.initialized = getOpenPromise();
+ const muc = await api.rooms.get(this.jid);
+ await muc.initialized;
+ this.muc = muc;
+ this.initialized.resolve();
+ }
+
+ render () {
+ if (this.muc?.occupants) {
+ const occupant = this.muc.occupants.getOwnOccupant();
+ return tplModeratorTools(this, {
+ 'affiliations_filter': this.affiliations_filter,
+ 'alert_message': this.alert_message,
+ 'alert_type': this.alert_type,
+ 'assignRole': ev => this.assignRole(ev),
+ 'assignable_affiliations': getAssignableAffiliations(occupant),
+ 'assignable_roles': getAssignableRoles(occupant),
+ 'filterAffiliationResults': ev => this.filterAffiliationResults(ev),
+ 'filterRoleResults': ev => this.filterRoleResults(ev),
+ 'loading_users_with_affiliation': this.loading_users_with_affiliation,
+ 'queryAffiliation': ev => this.queryAffiliation(ev),
+ 'queryRole': ev => this.queryRole(ev),
+ 'queryable_affiliations': AFFILIATIONS.filter(
+ a => !api.settings.get('modtools_disable_query').includes(a)
+ ),
+ 'queryable_roles': ROLES.filter(a => !api.settings.get('modtools_disable_query').includes(a)),
+ 'roles_filter': this.roles_filter,
+ 'switchTab': ev => this.switchTab(ev),
+ 'tab': this.tab,
+ 'toggleForm': ev => this.toggleForm(ev),
+ 'users_with_affiliation': this.users_with_affiliation,
+ 'users_with_role': this.users_with_role,
+ });
+ } else {
+ return '';
+ }
+ }
+
+ switchTab (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.tab = ev.target.getAttribute('data-name');
+ this.requestUpdate();
+ }
+
+ async onSearchAffiliationChange () {
+ if (!this.affiliation) return;
+
+ await this.initialized;
+ this.clearAlert();
+ this.loading_users_with_affiliation = true;
+ this.users_with_affiliation = null;
+
+ if (this.shouldFetchAffiliationsList()) {
+ const result = await getAffiliationList(this.affiliation, this.jid);
+ if (result instanceof Error) {
+ this.alert(result.message, 'danger');
+ this.users_with_affiliation = [];
+ } else {
+ this.users_with_affiliation = result;
+ }
+ } else {
+ this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(this.affiliation);
+ }
+ this.loading_users_with_affiliation = false;
+ }
+
+ async onSearchRoleChange () {
+ if (!this.role) {
+ return;
+ }
+ await this.initialized;
+ this.clearAlert();
+ this.users_with_role = this.muc.getOccupantsWithRole(this.role);
+ }
+
+ shouldFetchAffiliationsList () {
+ const affiliation = this.affiliation;
+ if (affiliation === 'none') {
+ return false;
+ }
+ const auto_fetched_affs = getAutoFetchedAffiliationLists();
+ if (auto_fetched_affs.includes(affiliation)) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ toggleForm (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const toggle = u.ancestor(ev.target, '.toggle-form');
+ const sel = toggle.getAttribute('data-form');
+ const form = u.ancestor(toggle, '.list-group-item').querySelector(sel);
+ if (u.hasClass('hidden', form)) {
+ u.removeClass('hidden', form);
+ } else {
+ u.addClass('hidden', form);
+ }
+ }
+
+ filterRoleResults (ev) {
+ this.roles_filter = ev.target.value;
+ this.render();
+ }
+
+ filterAffiliationResults (ev) {
+ this.affiliations_filter = ev.target.value;
+ }
+
+ queryRole (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const data = new FormData(ev.target);
+ const role = data.get('role');
+ this.role = null;
+ this.role = role;
+ }
+
+ queryAffiliation (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const data = new FormData(ev.target);
+ const affiliation = data.get('affiliation');
+ this.affiliation = null;
+ this.affiliation = affiliation;
+ }
+
+ alert (message, type) {
+ this.alert_message = message;
+ this.alert_type = type;
+ }
+
+ clearAlert () {
+ this.alert_message = undefined;
+ this.alert_type = undefined;
+ }
+
+}
+
+api.elements.define('converse-modtools', ModeratorTools);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js
new file mode 100644
index 0000000..7d5dd72
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js
@@ -0,0 +1,53 @@
+import BaseChatView from 'shared/chat/baseview.js';
+import tplMuc from './templates/muc.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+
+export default class MUCView extends BaseChatView {
+ length = 300
+ is_chatroom = true
+
+ async initialize () {
+ this.model = await api.rooms.get(this.jid);
+ _converse.chatboxviews.add(this.jid, this);
+ this.setAttribute('id', this.model.get('box_id'));
+
+ this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
+ this.listenTo(this.model, 'change:composing_spoiler', this.requestUpdateMessageForm);
+ this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
+ this.listenTo(this.model.session, 'change:view', () => this.requestUpdate());
+
+ this.onConnectionStatusChanged();
+ this.model.maybeShow();
+ /**
+ * Triggered once a {@link _converse.ChatRoomView} has been opened
+ * @event _converse#chatRoomViewInitialized
+ * @type { _converse.ChatRoomView }
+ * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... });
+ */
+ api.trigger('chatRoomViewInitialized', this);
+ }
+
+ render () {
+ return tplMuc({ 'model': this.model });
+ }
+
+ onConnectionStatusChanged () {
+ const conn_status = this.model.session.get('connection_status');
+ if (conn_status === converse.ROOMSTATUS.CONNECTING) {
+ this.model.session.save({
+ 'disconnection_actor': undefined,
+ 'disconnection_message': undefined,
+ 'disconnection_reason': undefined,
+ });
+ this.model.save({
+ 'moved_jid': undefined,
+ 'password_validation_message': undefined,
+ 'reason': undefined,
+ });
+ }
+ this.requestUpdate();
+ }
+}
+
+api.elements.define('converse-muc', MUCView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js
new file mode 100644
index 0000000..6f244e3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js
@@ -0,0 +1,48 @@
+import tplMUCNicknameForm from './templates/muc-nickname-form.js';
+import { CustomElement } from 'shared/components/element';
+import { _converse, api } from "@converse/headless/core";
+
+import './styles/nickname-form.scss';
+
+
+class MUCNicknameForm extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ }
+
+ render () {
+ return tplMUCNicknameForm(this);
+ }
+
+ submitNickname (ev) {
+ ev.preventDefault();
+ const nick = ev.target.nick.value.trim();
+ if (!nick) {
+ return;
+ }
+ if (this.model.isEntered()) {
+ this.model.setNickname(nick);
+ this.closeModal();
+ } else {
+ this.model.join(nick);
+ }
+ }
+
+ closeModal () {
+ const evt = document.createEvent('Event');
+ evt.initEvent('hide.bs.modal', true, true);
+ this.dispatchEvent(evt);
+ }
+}
+
+api.elements.define('converse-muc-nickname-form', MUCNicknameForm);
+
+export default MUCNicknameForm;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js
new file mode 100644
index 0000000..25f81d0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js
@@ -0,0 +1,39 @@
+import tplMUCPasswordForm from "./templates/muc-password-form.js";
+import { CustomElement } from 'shared/components/element';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCPasswordForm extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ this.listenTo(this.model, 'change:password_validation_message', this.render);
+ this.render();
+ }
+
+ render () {
+ return tplMUCPasswordForm({
+ 'jid': this.model.get('jid'),
+ 'submitPassword': ev => this.submitPassword(ev),
+ 'validation_message': this.model.get('password_validation_message')
+ });
+ }
+
+ submitPassword (ev) {
+ ev.preventDefault();
+ const password = this.querySelector('input[type=password]').value;
+ this.model.join(this.model.get('nick'), password);
+ this.model.set('password_validation_message', null);
+ }
+}
+
+api.elements.define('converse-muc-password-form', MUCPasswordForm);
+
+export default MUCPasswordForm;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js
new file mode 100644
index 0000000..d845ae6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js
@@ -0,0 +1,68 @@
+import log from '@converse/headless/log';
+import tplRoleForm from './templates/role-form.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { api, converse } from '@converse/headless/core.js';
+import { isErrorObject } from '@converse/headless/utils/core.js';
+
+const { Strophe, sizzle } = converse.env;
+
+class RoleForm extends CustomElement {
+ static get properties () {
+ return {
+ muc: { type: Object },
+ jid: { type: String },
+ role: { type: String },
+ alert_message: { type: String, attribute: false },
+ alert_type: { type: String, attribute: false },
+ };
+ }
+
+ render () {
+ return tplRoleForm(this);
+ }
+
+ alert (message, type) {
+ this.alert_message = message;
+ this.alert_type = type;
+ }
+
+ assignRole (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.alert(); // clear alert
+
+ const data = new FormData(ev.target);
+ const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick'));
+ const role = data.get('role');
+ const reason = data.get('reason');
+
+ this.muc.setRole(
+ occupant,
+ role,
+ reason,
+ () => {
+ /**
+ * @event roleChanged
+ * @example
+ * const el = document.querySelector('converse-muc-role-form');
+ * el.addEventListener('roleChanged', () => { ... });
+ */
+ const event = new CustomEvent('roleChanged', { bubbles: true });
+ this.dispatchEvent(event);
+
+ },
+ e => {
+ if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+ this.alert(__("You're not allowed to make that change"), 'danger');
+ } else {
+ this.alert(__('Sorry, something went wrong while trying to set the role'), 'danger');
+ if (isErrorObject(e)) log.error(e);
+ }
+ }
+ );
+
+ }
+}
+
+api.elements.define('converse-muc-role-form', RoleForm);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js
new file mode 100644
index 0000000..6277273
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js
@@ -0,0 +1,58 @@
+import log from "@converse/headless/log";
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { Strophe, $iq, sizzle } = converse.env;
+
+Strophe.addNamespace('MUCSEARCH', 'https://xmlns.zombofant.net/muclumbus/search/1.0');
+
+const rooms_cache = {};
+
+async function searchRooms (query) {
+ const iq = $iq({
+ 'type': 'get',
+ 'from': _converse.bare_jid,
+ 'to': 'api@search.jabber.network'
+ }).c('search', { 'xmlns': Strophe.NS.MUCSEARCH })
+ .c('set', { 'xmlns': Strophe.NS.RSM })
+ .c('max').t(10).up().up()
+ .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
+ .c('field', { 'var': 'FORM_TYPE', 'type': 'hidden' })
+ .c('value').t('https://xmlns.zombofant.net/muclumbus/search/1.0#params').up().up()
+ .c('field', { 'var': 'q', 'type': 'text-single' })
+ .c('value').t(query).up().up()
+ .c('field', { 'var': 'sinname', 'type': 'boolean' })
+ .c('value').t('true').up().up()
+ .c('field', { 'var': 'sindescription', 'type': 'boolean' })
+ .c('value').t('false').up().up()
+ .c('field', { 'var': 'sinaddr', 'type': 'boolean' })
+ .c('value').t('true').up().up()
+ .c('field', { 'var': 'min_users', 'type': 'text-single' })
+ .c('value').t('1').up().up()
+ .c('field', { 'var': 'key', 'type': 'list-single' })
+ .c('value').t('address').up()
+ .c('option').c('value').t('nusers').up().up()
+ .c('option').c('value').t('address')
+
+ let iq_result;
+ try {
+ iq_result = await api.sendIQ(iq);
+ } catch (e) {
+ log.error(e);
+ return [];
+ }
+ const s = `result[xmlns="${Strophe.NS.MUCSEARCH}"] item`;
+ return sizzle(s, iq_result).map(i => {
+ const jid = i.getAttribute('address');
+ return {
+ 'label': `${i.querySelector('name')?.textContent} (${jid})`,
+ 'value': jid
+ }
+ });
+}
+
+export function getAutoCompleteList (query) {
+ if (!rooms_cache[query]) {
+ rooms_cache[query] = searchRooms(query);
+ }
+ return rooms_cache[query];
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js
new file mode 100644
index 0000000..16688c0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js
@@ -0,0 +1,54 @@
+import 'shared/autocomplete/index.js';
+import tplMUCSidebar from "./templates/muc-sidebar.js";
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api, converse } from "@converse/headless/core";
+
+import 'shared/styles/status.scss';
+import './styles/muc-occupants.scss';
+
+const { u } = converse.env;
+
+export default class MUCSidebar extends CustomElement {
+
+ static get properties () {
+ return {
+ jid: { type: String }
+ }
+ }
+
+ connectedCallback () {
+ super.connectedCallback();
+ this.model = _converse.chatboxes.get(this.jid);
+ this.listenTo(this.model.occupants, 'add', () => this.requestUpdate());
+ this.listenTo(this.model.occupants, 'remove', () => this.requestUpdate());
+ this.listenTo(this.model.occupants, 'change', () => this.requestUpdate());
+ this.listenTo(this.model.occupants, 'vcard:change', () => this.requestUpdate());
+ this.listenTo(this.model.occupants, 'vcard:add', () => this.requestUpdate());
+ this.model.initialized.then(() => this.requestUpdate());
+ }
+
+ render () {
+ const tpl = tplMUCSidebar(Object.assign(
+ this.model.toJSON(), {
+ 'occupants': [...this.model.occupants.models],
+ 'closeSidebar': ev => this.closeSidebar(ev),
+ 'onOccupantClicked': ev => this.onOccupantClicked(ev),
+ }
+ ));
+ return tpl;
+ }
+
+ closeSidebar(ev) {
+ ev?.preventDefault?.();
+ ev?.stopPropagation?.();
+ u.safeSave(this.model, { 'hidden_occupants': true });
+ }
+
+ onOccupantClicked (ev) {
+ ev?.preventDefault?.();
+ const view = _converse.chatboxviews.get(this.getAttribute('jid'));
+ view?.getMessageForm().insertIntoTextArea(`@${ev.target.textContent}`);
+ }
+}
+
+api.elements.define('converse-muc-sidebar', MUCSidebar);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss
new file mode 100644
index 0000000..4e8e550
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss
@@ -0,0 +1,14 @@
+converse-add-muc-modal {
+ .add-chatroom {
+ converse-autocomplete {
+ .suggestion-box__results--below {
+ height: 10em;
+ overflow: auto;
+ }
+
+ .suggestion-box ul li {
+ display: block;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss
new file mode 100644
index 0000000..a5b7e26
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss
@@ -0,0 +1,28 @@
+.conversejs {
+ #controlbox {
+ #chatrooms {
+ padding: 0;
+
+ .add-chatroom {
+ input[type=button],
+ input[type=submit],
+ input[type=text] {
+ width: 100%;
+ }
+ margin: 0;
+ padding: 0;
+ }
+ }
+
+ .open-rooms-toggle, .open-rooms-toggle .fa {
+ color: var(--groupchats-header-color) !important;
+ &:hover {
+ color: var(--chatroom-head-bg-color-dark) !important;
+ }
+ }
+
+ .open-rooms-toggle {
+ white-space: nowrap;
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss
new file mode 100644
index 0000000..41cb8a3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss
@@ -0,0 +1,158 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+@import "shared/styles/_variables.scss";
+
+@import "./controlbox.scss";
+@import "./muc.scss";
+
+converse-muc-disconnected,
+converse-muc-destroyed {
+ padding: 2em;
+ width: 100%;
+ height: 100%;
+}
+
+.conversejs.converse-embedded,
+.conversejs {
+ .badge--muc {
+ background-color: var(--groupchats-header-color);
+ }
+
+ .add-chatroom {
+ input[type="submit"],
+ input[type="button"] {
+ margin: 0.3em 0;
+ }
+ }
+}
+
+
+/* ******************* Overlay styles *************************** */
+
+.conversejs {
+ converse-chats {
+ &.converse-overlayed {
+ .chatbox {
+ &.chatroom {
+ min-width: var(--chatroom-width) !important;
+ width: var(--chatroom-width);
+ .box-flyout {
+ min-width: var(--chatroom-width) !important;
+ width: var(--chatroom-width);
+ }
+ .chatbox-title__text {
+ @include make-col(10);
+ }
+ .chatbox-title__buttons {
+ @include make-col(2);
+ }
+
+ .chat-head__desc {
+ font-size: 80%;
+ margin-bottom: 1em;
+ }
+ .chatroom-body {
+ .occupants {
+ .occupants-heading {
+ padding: 0;
+ }
+ .occupant-list {
+ border-bottom: none;
+ }
+ ul {
+ .occupant {
+ .occupant-nick-badge {
+ .occupant-badges {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ .chat-area {
+ min-width: var(--overlayed-chat-width);
+ }
+ }
+ }
+ }
+ }
+
+ &.converse-embedded,
+ &.converse-fullscreen,
+ &.converse-mobile {
+
+ .chatroom {
+ .box-flyout {
+ width: 100%;
+
+ .chatroom-body {
+ .chat-area {
+ &.full {
+ .new-msgs-indicator {
+ max-width: 100%;
+ }
+ }
+ }
+ .occupants {
+ padding: var(--occupants-padding);
+ .occupants-heading {
+ font-size: var(--font-size-large);
+ }
+ ul {
+ &.occupant-list {
+ li {
+ font-size: var(--font-size-small);
+ }
+ }
+ }
+ }
+ }
+ }
+ .room-invite {
+ span {
+ .invited-contact {
+ margin: 0 0 0.5em -1px;
+ }
+ }
+ }
+ }
+ }
+
+ &.converse-embedded {
+ .chatroom {
+ margin: 0;
+ width: 100%;
+ .box-flyout {
+ .occupants-heading {
+ font-size: 120%;
+ }
+ .chat-content {
+ .chat-message {
+ margin: 0.5em;
+ font-size: 120%;
+ }
+ }
+ .sendXMPPMessage {
+ .chat-textarea {
+ padding: 0.5em;
+ font-size: 110%;
+ }
+ }
+ .chatroom-body {
+ height: 100%;
+ .muc-form-container {
+ height: 100%;
+ position: relative;
+ }
+ }
+ .occupants {
+ .occupant-list {
+ padding-left: 0.3em;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss
new file mode 100644
index 0000000..25007f9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss
@@ -0,0 +1,5 @@
+converse-modtools {
+ converse-icon svg {
+ fill: var(--link-color);
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss
new file mode 100644
index 0000000..e9c5569
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss
@@ -0,0 +1,49 @@
+.conversejs {
+ converse-muc.chatroom {
+ converse-muc-bottom-panel.bottom-panel {
+ display: contents;
+ height: 3em;
+ padding: 0.5em;
+ text-align: center;
+ font-size: var(--font-size-small);
+ background-color: var(--chatroom-head-bg-color);
+ color: white;
+
+ &.muc-bottom-panel--muted {
+ height: 4em;
+ width: 100%;
+ }
+
+ &.muc-bottom-panel--nickname {
+ padding: 0;
+ height: 16em;
+
+ .muc-form-container {
+ .chatroom-form {
+ padding-top: 2em;
+ padding-bottom: 0;
+ }
+ }
+ }
+
+ .sendXMPPMessage {
+ .suggestion-box__results--above {
+ bottom: 4.5em;
+ }
+ .chat-textarea, input {
+ &:active, &:focus{
+ outline-color: var(--chatroom-head-bg-color) !important;
+ }
+ &.correcting {
+ background-color: var(--chatroom-correcting-color);
+ }
+ }
+ .chat-textarea {
+ width: 100%;
+ border: none;
+ border-bottom-right-radius: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss
new file mode 100644
index 0000000..559a643
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss
@@ -0,0 +1,29 @@
+converse-muc-bottom-panel {
+ display: contents;
+}
+
+.muc-bottom-panel {
+ height: 3em;
+ padding: 0.5em;
+ text-align: center;
+ font-size: var(--font-size-small);
+ background-color: var(--chatroom-head-bg-color);
+ color: white;
+
+ &.muc-bottom-panel--muted {
+ height: 4em;
+ width: 100%;
+ }
+
+ &.muc-bottom-panel--nickname {
+ padding: 0;
+ height: 16em;
+
+ .muc-form-container {
+ .chatroom-form {
+ padding-top: 2em;
+ padding-bottom: 0;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss
new file mode 100644
index 0000000..83ec98c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss
@@ -0,0 +1,28 @@
+converse-muc-details-modal {
+ .features-list {
+ margin-left: 1em;
+ }
+
+ .room-info {
+ strong {
+ color: var(--muc-color);
+ }
+ }
+
+ .chatroom-features {
+ width: 100%;
+ .features-list {
+ padding-top: 0;
+ .feature {
+ width: 100%;
+ margin-right: 0.5em;
+ padding-right: 0;
+ font-size: 1em;
+ cursor: help;
+ converse-icon {
+ margin-right: 0.5em;
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss
new file mode 100644
index 0000000..6f90433
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss
@@ -0,0 +1,39 @@
+converse-muc-config-form {
+ width: 100%;
+ overflow: auto;
+}
+
+.conversejs {
+ .chatroom {
+ .box-flyout {
+ .muc-form-container {
+ background-color: var(--background);
+ border: 0;
+ color: var(--text-color);
+ font-size: var(--font-size);
+ height: 100%;
+ width: 100%;
+ overflow-y: auto;
+
+ .validation-message {
+ font-size: 90%;
+ color: var(--error-color);
+ }
+ input[type=button],
+ input[type=submit] {
+ margin: 0 0.5em;
+ }
+ .button-primary {
+ background-color: var(--chatroom-head-fg-color);
+ }
+ }
+
+ .chatroom-form {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 2em;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss
new file mode 100644
index 0000000..cca7df3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss
@@ -0,0 +1,86 @@
+.conversejs {
+ converse-muc.chatroom {
+ .chat-head-chatroom {
+
+ converse-controlbox-navback {
+ .fa-arrow-left {
+ svg {
+ fill: var(--chatroom-head-color);
+ }
+ }
+ }
+
+ color: var(--chatroom-head-color);
+ background-color: var(--chatroom-head-bg-color);
+ border-bottom: var(--chatroom-head-border-bottom);
+
+ .chat-head__desc {
+ color: var(--chatroom-head-color);
+ display: var(--chatroom-head-description-display);
+ a {
+ color: var(--chatroom-head-description-link-color);
+ }
+ &:hover {
+ button {
+ display: inline-block;
+ }
+ }
+ }
+
+ .chatbox-title {
+ .btn--transparent {
+ i {
+ color: var(--chatroom-head-color);
+ }
+ }
+ .chatbox-title__text--bookmarked {
+ margin-left: 0.5em;
+ }
+ }
+
+ .chatbox-title__buttons {
+ background-color: var(--chatroom-head-bg-color);
+ }
+
+ a, a:visited, a:hover, a:not([href]):not([tabindex]) {
+ &.chatbox-btn {
+ &.fa {
+ color: var(--chatroom-head-color);
+ &.button-on:before {
+ color: var(--chatroom-head-fg-color);
+ }
+ }
+ }
+ }
+
+ converse-dropdown {
+ .dropdown-menu {
+ converse-icon {
+ svg {
+ fill: var(--chatroom-color);
+ }
+ }
+ }
+ }
+
+ .chatbox-btn {
+ converse-icon {
+ svg {
+ fill: var(--chatroom-head-fg-color);
+ }
+ }
+ }
+ .chatbox-title__text {
+ color: var(--chatroom-head-color);
+ display: var(--heading-display);
+ font-weight: var(--chatroom-head-title-font-weight);
+ margin: auto 0;
+ padding-right: var(--chatroom-head-title-padding-right);
+ white-space: nowrap;
+ .chatroom-jid {
+ font-size: var(--font-size-small);
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss
new file mode 100644
index 0000000..dab5cf1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss
@@ -0,0 +1,122 @@
+.conversejs {
+ converse-muc.chatroom {
+
+ .chat-status--avatar {
+ background: var(--occupants-background-color);
+ border: 1px solid var(--occupants-background-color);
+ }
+
+ .badge-groupchat {
+ background-color: var(--groupchats-header-color);
+ }
+
+ .box-flyout {
+ .occupants {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ vertical-align: top;
+ background-color: var(--occupants-background-color);
+ border-left: var(--occupants-border-left);
+ padding: 0.5em;
+ max-width: 75%;
+ min-width: 20%;
+ flex: 0 0 25%;
+
+ .occupants-header--title {
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 0.5em;
+
+ .hide-occupants {
+ align-self: flex-end;
+ cursor: pointer;
+ font-size: var(--font-size-small);
+ }
+ }
+
+ .fa-user-plus {
+ margin-right: 0.25em;
+ }
+
+ .occupants-heading {
+ width: 100%;
+ font-family: var(--heading-font);
+ color: var(--groupchats-header-color-dark);
+ padding-left: 0;
+ margin-right: 1em;
+ }
+ .suggestion-box{
+ ul {
+ padding: 0;
+ li {
+ padding: 0.5em;
+ }
+ }
+ }
+ ul {
+ padding: 0;
+ margin-bottom: 0.5em;
+ overflow-x: hidden;
+ overflow-y: auto;
+ list-style: none;
+
+ &.occupant-list {
+ overflow-y: auto;
+ flex-basis: 0;
+ flex-grow: 1;
+ }
+ li {
+ cursor: default;
+ display: block;
+ font-size: var(--font-size-small);
+ overflow: hidden;
+ padding: 0.25em 0.25em 0.25em 0;
+ text-overflow: ellipsis;
+ .fa {
+ margin-right: 0.5em;
+ }
+ &.feature {
+ font-size: var(--font-size-tiny);
+ }
+ &.occupant {
+ cursor: pointer;
+ color: var(--link-color);
+ &:hover {
+ color: var(--link-hover-color);
+ }
+
+ .occupant-nick-badge {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+
+ .occupant-badges {
+ display: flex;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ flex-direction: row;
+
+ span {
+ height: 1.6em;
+ margin-right: 0.25rem;
+ }
+ }
+ }
+
+ div.row.no-gutters {
+ flex-wrap: nowrap;
+ min-height: 1.5em;
+ }
+ .badge {
+ margin-bottom: 0.125rem;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss
new file mode 100644
index 0000000..4dd9a39
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss
@@ -0,0 +1,119 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "shared/styles/_variables.scss";
+@import "plugins/chatview/styles/chatbox.scss";
+@import "./muc-forms.scss";
+
+.conversejs {
+ .chatroom {
+ width: var(--chatroom-width);
+ @media screen and (max-height: $mobile-landscape-height){
+ width: var(--mobile-chat-width);
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ width: var(--mobile-chat-width);
+ }
+
+ .box-flyout {
+ background-color: var(--chatroom-head-bg-color);
+ overflow-y: hidden;
+ width: var(--chatroom-width);
+
+ @media screen and (max-height: $mobile-landscape-height) {
+ height: var(--mobile-chat-height);
+ width: var(--mobile-chat-width);
+ height: var(--fullpage-chat-height);
+ }
+ @media screen and (max-width: $mobile-portrait-length) {
+ height: var(--mobile-chat-height);
+ width: var(--mobile-chat-width);
+ height: var(--fullpage-chat-height);
+ }
+
+ .empty-history-feedback {
+ position: relative;
+ span {
+ width: 100%;
+ text-align: center;
+ position: absolute;
+ margin-top: 50%;
+ }
+ }
+
+ .chatroom-body {
+ flex-direction: row;
+ flex-flow: nowrap;
+ background-color: var(--background);
+ border-top: 0;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+
+ converse-muc-chatarea {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ flex-flow: nowrap;
+ }
+
+ .row {
+ flex-direction: row;
+ }
+ .chat-topic {
+ font-weight: bold;
+ color: var(--chatroom-head-bg-color);
+ }
+ .chat-info {
+ color: var(--chat-info-color);
+ line-height: normal;
+ &.badge {
+ color: var(--chat-head-text-color);
+ }
+ &.chat-msg--retracted {
+ color: var(--subdued-color);
+ }
+ }
+ .disconnect-container {
+ margin: 1em;
+ width: 100%;
+ h3.disconnect-msg {
+ padding-bottom: 1em;
+ }
+ }
+ .chat-area {
+ display: flex;
+ flex-direction: column;
+ flex: 0 1 100%;
+ justify-content: flex-end;
+ min-width: 25%;
+ word-wrap: break-word;
+ .new-msgs-indicator {
+ background-color: var(--chatroom-color);
+ }
+ .chat-content {
+ height: 100%;
+ }
+ .chat-content__help {
+ converse-chat-help {
+ border-top: 1px solid var(--chatroom-color);
+ }
+ .close-chat-help {
+ svg {
+ fill: var(--chatroom-color);
+ }
+ }
+ }
+ }
+
+ }
+ }
+
+ .room-invite {
+ .invited-contact {
+ margin: -1px 0 0 -1px;
+ width: 100%;
+ border: 1px solid #999;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss
new file mode 100644
index 0000000..39b63e7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss
@@ -0,0 +1,3 @@
+converse-muc-nickname-form {
+ width: 100%;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js
new file mode 100644
index 0000000..6261cdc
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js
@@ -0,0 +1,36 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { getAssignableAffiliations } from '@converse/headless/plugins/muc/affiliations/utils.js';
+
+export default (el) => {
+ const i18n_change_affiliation = __('Change affiliation');
+ const i18n_new_affiliation = __('New affiliation');
+ const i18n_reason = __('Reason');
+ const occupant = el.muc.getOwnOccupant();
+ const assignable_affiliations = getAssignableAffiliations(occupant);
+
+ return html`
+ <form class="affiliation-form" @submit=${ev => el.assignAffiliation(ev)}>
+ ${el.alert_message ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert_message}</div>` : '' }
+ <div class="form-group">
+ <div class="row">
+ <div class="col">
+ <label><strong>${i18n_new_affiliation}:</strong></label>
+ <select class="custom-select select-affiliation" name="affiliation">
+ ${ assignable_affiliations.map(aff => html`<option value="${aff}" ?selected=${aff === el.affiliation}>${aff}</option>`) }
+ </select>
+ </div>
+ <div class="col">
+ <label><strong>${i18n_reason}:</strong></label>
+ <input class="form-control" type="text" name="reason"/>
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <div class="col">
+ <input type="submit" class="btn btn-primary" name="change" value="${i18n_change_affiliation}"/>
+ </div>
+ </div>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js
new file mode 100644
index 0000000..9fca6b7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js
@@ -0,0 +1,33 @@
+import { converse } from '@converse/headless/core';
+import { html } from 'lit';
+
+const { dayjs } = converse.env;
+
+export default (el) => {
+ const isodate = dayjs(el.model.get('time')).toISOString();
+ return html`
+ <div class="message chat-info message--mep ${ el.getExtraMessageClasses() }"
+ data-isodate="${isodate}"
+ data-type="${el.data_name}"
+ data-value="${el.data_value}">
+
+ <div class="chat-msg__content">
+ <div class="chat-msg__body chat-msg__body--${el.model.get('type')} ${el.model.get('is_delayed') ? 'chat-msg__body--delayed' : '' }">
+ <div class="chat-info__message">
+ ${ el.isRetracted() ? el.renderRetraction() : html`
+ <converse-rich-text
+ .mentions=${el.model.get('references')}
+ render_styling
+ text=${el.model.getMessageText()}>
+ </converse-rich-text>
+ ${ el.model.get('reason') ?
+ html`<q class="reason"><converse-rich-text text=${el.model.get('reason')}></converse-rich-text></q>` : `` }
+ `}
+ </div>
+ <converse-message-actions
+ ?is_retracted=${el.isRetracted()}
+ .model=${el.model}></converse-message-actions>
+ </div>
+ </div>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js
new file mode 100644
index 0000000..2ac1894
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js
@@ -0,0 +1,35 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+import { resetElementHeight } from 'plugins/chatview/utils.js';
+
+
+export default (o) => {
+ const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
+ const label_spoiler_hint = __('Optional hint');
+ const show_send_button = api.settings.get('show_send_button');
+ return html`
+ <form class="setNicknameButtonForm hidden">
+ <input type="submit" class="btn btn-primary" name="join" value="Join"/>
+ </form>
+ <form class="sendXMPPMessage">
+ <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
+ <div class="suggestion-box">
+ <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+ <textarea
+ autofocus
+ type="text"
+ @drop=${o.onDrop}
+ @input=${resetElementHeight}
+ @keydown=${o.onKeyDown}
+ @keyup=${o.onKeyUp}
+ @paste=${o.onPaste}
+ @change=${o.onChange}
+ class="chat-textarea suggestion-box__input
+ ${ show_send_button ? 'chat-textarea-send-button' : '' }
+ ${ o.composing_spoiler ? 'spoiler' : '' }"
+ placeholder="${label_message}">${ o.message_value || '' }</textarea>
+ <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+ </div>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js
new file mode 100644
index 0000000..837efb6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js
@@ -0,0 +1,218 @@
+import spinner from "templates/spinner.js";
+import { __ } from 'i18n';
+import { html } from "lit";
+
+
+function getRoleHelpText (role) {
+ if (role === 'moderator') {
+ return __("Moderators are privileged users who can change the roles of other users (except those with admin or owner affiliations.");
+ } else if (role === 'participant') {
+ return __("The default role, implies that you can read and write messages.");
+ } else if (role == 'visitor') {
+ return __("Visitors aren't allowed to write messages in a moderated multi-user chat.");
+ }
+}
+
+function getAffiliationHelpText (aff) {
+ if (aff === 'owner') {
+ return __("Owner is the highest affiliation. Owners can modify roles and affiliations of all other users.");
+ } else if (aff === 'admin') {
+ return __("Admin is the 2nd highest affiliation. Admins can modify roles and affiliations of all other users except owners.");
+ } else if (aff === 'outcast') {
+ return __("To ban a user, you give them the affiliation of \"outcast\".");
+ }
+}
+
+
+const role_option = (o) => html`
+ <option value="${o.item || ''}"
+ ?selected=${o.item === o.role}
+ title="${getRoleHelpText(o.item)}">${o.item}</option>
+`;
+
+
+const affiliation_option = (o) => html`
+ <option value="${o.item || ''}"
+ ?selected=${o.item === o.affiliation}
+ title="${getAffiliationHelpText(o.item)}">${o.item}</option>
+`;
+
+
+const tplRoleFormToggle = (o) => html`
+ <a href="#" data-form="converse-muc-role-form" class="toggle-form right" color="var(--subdued-color)" @click=${o.toggleForm}>
+ <converse-icon class="fa fa-wrench" size="1em"></converse-icon>
+ </a>`;
+
+
+const tplRoleListItem = (el, o) => html`
+ <li class="list-group-item" data-nick="${o.item.nick}">
+ <ul class="list-group">
+ <li class="list-group-item active">
+ <div><strong>JID:</strong> ${o.item.jid}</div>
+ </li>
+ <li class="list-group-item">
+ <div><strong>Nickname:</strong> ${o.item.nick}</div>
+ </li>
+ <li class="list-group-item">
+ <div><strong>Role:</strong> ${o.item.role} ${o.assignable_roles.length ? tplRoleFormToggle(o) : ''}</div>
+ ${o.assignable_roles.length ?
+ html`<converse-muc-role-form class="hidden" .muc=${el.muc} jid=${o.item.jid} role=${o.item.role}></converse-muc-role-form>` : ''
+ }
+ </li>
+ </ul>
+ </li>
+`;
+
+
+const affiliation_form_toggle = (o) => html`
+ <a href="#" data-form="converse-muc-affiliation-form" class="toggle-form right" color="var(--subdued-color)" @click=${o.toggleForm}>
+ <converse-icon class="fa fa-wrench" size="1em"></converse-icon>
+ </a>`;
+
+
+const affiliation_list_item = (el, o) => html`
+ <li class="list-group-item" data-nick="${o.item.nick}">
+ <ul class="list-group">
+ <li class="list-group-item active">
+ <div><strong>JID:</strong> ${o.item.jid}</div>
+ </li>
+ <li class="list-group-item">
+ <div><strong>Nickname:</strong> ${o.item.nick}</div>
+ </li>
+ <li class="list-group-item">
+ <div><strong>Affiliation:</strong> ${o.item.affiliation} ${o.assignable_affiliations.length ? affiliation_form_toggle(o) : ''}</div>
+ ${o.assignable_affiliations.length ?
+ html`<converse-muc-affiliation-form class="hidden" .muc=${el.muc} jid=${o.item.jid} affiliation=${o.item.affiliation}></converse-muc-affiliation-form>` : ''
+ }
+ </li>
+ </ul>
+ </li>
+`;
+
+
+const tplNavigation = (o) => html`
+ <ul class="nav nav-pills justify-content-center">
+ <li role="presentation" class="nav-item">
+ <a class="nav-link ${o.tab === "affiliations" ? "active" : ""}"
+ id="affiliations-tab"
+ href="#affiliations-tabpanel"
+ aria-controls="affiliations-tabpanel"
+ role="tab"
+ data-name="affiliations"
+ @click=${o.switchTab}>Affiliations</a>
+ </li>
+ <li role="presentation" class="nav-item">
+ <a class="nav-link ${o.tab === "roles" ? "active" : ""}"
+ id="roles-tab"
+ href="#roles-tabpanel"
+ aria-controls="roles-tabpanel"
+ role="tab"
+ data-name="roles"
+ @click=${o.switchTab}>Roles</a>
+ </li>
+ </ul>
+`;
+
+
+export default (el, o) => {
+ const i18n_affiliation = __('Affiliation');
+ const i18n_no_users_with_aff = __('No users with that affiliation found.')
+ const i18n_no_users_with_role = __('No users with that role found.');
+ const i18n_filter = __('Type here to filter the search results');
+ const i18n_role = __('Role');
+ const i18n_show_users = __('Show users');
+ const i18n_helptext_role = __(
+ "Roles are assigned to users to grant or deny them certain abilities in a multi-user chat. "+
+ "They're assigned either explicitly or implicitly as part of an affiliation. "+
+ "A role that's not due to an affiliation, is only valid for the duration of the user's session."
+ );
+ const i18n_helptext_affiliation = __(
+ "An affiliation is a long-lived entitlement which typically implies a certain role and which "+
+ "grants privileges and responsibilities. For example admins and owners automatically have the "+
+ "moderator role."
+ );
+ const show_both_tabs = o.queryable_roles.length && o.queryable_affiliations.length;
+ return html`
+ ${o.alert_message ? html`<div class="alert alert-${o.alert_type}" role="alert">${o.alert_message}</div>` : '' }
+ ${ show_both_tabs ? tplNavigation(o) : '' }
+
+ <div class="tab-content">
+
+ ${ o.queryable_affiliations.length ? html`
+ <div class="tab-pane tab-pane--columns ${ o.tab === 'affiliations' ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
+ <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}>
+ <p class="helptext pb-3">${i18n_helptext_affiliation}</p>
+ <div class="form-group">
+ <label for="affiliation">
+ <strong>${i18n_affiliation}:</strong>
+ </label>
+ <div class="row">
+ <div class="col">
+ <select class="custom-select select-affiliation" name="affiliation">
+ ${o.queryable_affiliations.map(item => affiliation_option(Object.assign({item}, o)))}
+ </select>
+ </div>
+ <div class="col">
+ <input type="submit" class="btn btn-primary" name="users_with_affiliation" value="${i18n_show_users}"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col mt-3">
+ ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length > 5) ?
+ html`<input class="form-control" .value="${o.affiliations_filter}" @keyup=${o.filterAffiliationResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
+ </div>
+ </div>
+
+ ${ getAffiliationHelpText(o.affiliation) ?
+ html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getAffiliationHelpText(o.affiliation)}</p></div></div>` : '' }
+ </div>
+ </form>
+ <div class="scrollable-container">
+ <ul class="list-group list-group--users">
+ ${ (o.loading_users_with_affiliation) ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
+ ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length === 0) ?
+ html`<li class="list-group-item">${i18n_no_users_with_aff}</li>` : '' }
+
+ ${ (o.users_with_affiliation instanceof Error) ?
+ html`<li class="list-group-item">${o.users_with_affiliation.message}</li>` :
+ (o.users_with_affiliation || []).map(item => ((item.nick || item.jid).match(new RegExp(o.affiliations_filter, 'i')) ? affiliation_list_item(el, Object.assign({item}, o)) : '')) }
+ </ul>
+ </div>
+ </div>` : '' }
+
+ ${ o.queryable_roles.length ? html`
+ <div class="tab-pane tab-pane--columns ${ o.tab === 'roles' ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
+ <form class="converse-form query-role" @submit=${o.queryRole}>
+ <p class="helptext pb-3">${i18n_helptext_role}</p>
+ <div class="form-group">
+ <label for="role"><strong>${i18n_role}:</strong></label>
+ <div class="row">
+ <div class="col">
+ <select class="custom-select select-role" name="role">
+ ${o.queryable_roles.map(item => role_option(Object.assign({item}, o)))}
+ </select>
+ </div>
+ <div class="col">
+ <input type="submit" class="btn btn-primary" name="users_with_role" value="${i18n_show_users}"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col mt-3">
+ ${ (Array.isArray(o.users_with_role) && o.users_with_role.length > 5) ?
+ html`<input class="form-control" .value="${o.roles_filter}" @keyup=${o.filterRoleResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
+ </div>
+ </div>
+
+ ${ getRoleHelpText(o.role) ? html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getRoleHelpText(o.role)}</p></div></div>` : ''}
+ </div>
+ </form>
+ <div class="scrollable-container">
+ <ul class="list-group list-group--users">
+ ${ o.loading_users_with_role ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
+ ${ (o.users_with_role && o.users_with_role.length === 0) ? html`<li class="list-group-item">${i18n_no_users_with_role}</li>` : '' }
+ ${ (o.users_with_role || []).map(item => (item.nick.match(o.roles_filter) ? tplRoleListItem(el, Object.assign({item}, o)) : '')) }
+ </ul>
+ </div>
+ </div>`: '' }
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js
new file mode 100644
index 0000000..941daa9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js
@@ -0,0 +1,54 @@
+import '../message-form.js';
+import '../nickname-form.js';
+import 'shared/chat/toolbar.js';
+import { __ } from 'i18n';
+import { api, converse } from "@converse/headless/core";
+import { html } from "lit";
+
+
+const tplCanEdit = (o) => {
+ const unread_msgs = __('You have unread messages');
+ const message_limit = api.settings.get('message_limit');
+ const show_call_button = api.settings.get('visible_toolbar_buttons').call;
+ const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
+ const show_send_button = api.settings.get('show_send_button');
+ const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
+ const show_toolbar = api.settings.get('show_toolbar');
+ return html`
+ ${ (o.model.ui.get('scrolled') && o.model.get('num_unread')) ?
+ html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+ ${show_toolbar ? html`
+ <converse-chat-toolbar
+ class="chat-toolbar no-text-select"
+ .model=${o.model}
+ ?hidden_occupants="${o.model.get('hidden_occupants')}"
+ ?is_groupchat="${o.is_groupchat}"
+ ?show_call_button="${show_call_button}"
+ ?show_emoji_button="${show_emoji_button}"
+ ?show_send_button="${show_send_button}"
+ ?show_spoiler_button="${show_spoiler_button}"
+ ?show_toolbar="${show_toolbar}"
+ message_limit="${message_limit}"></converse-chat-toolbar>` : '' }
+ <converse-muc-message-form jid=${o.model.get('jid')}></converse-muc-message-form>`;
+}
+
+
+export default (o) => {
+ const unread_msgs = __('You have unread messages');
+ const conn_status = o.model.session.get('connection_status');
+ const i18n_not_allowed = __("You're not allowed to send messages in this room");
+ if (conn_status === converse.ROOMSTATUS.ENTERED) {
+ return html`
+ ${ o.model.ui.get('scrolled') && o.model.get('num_unread_general') ?
+ html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+ ${(o.can_edit) ? tplCanEdit(o) : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`}`;
+ } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+ if (api.settings.get('muc_show_logs_before_join')) {
+ return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">
+ <converse-muc-nickname-form jid="${o.model.get('jid')}"></converse-muc-nickname-form>
+ </span>`;
+ }
+ } else {
+ return '';
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js
new file mode 100644
index 0000000..3ef241b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js
@@ -0,0 +1,33 @@
+import '../bottom-panel.js';
+import '../sidebar.js';
+import 'shared/chat/chat-content.js';
+import 'shared/chat/help-messages.js';
+import { _converse } from '@converse/headless/core';
+import { html } from "lit";
+
+export default (o) => html`
+ <div class="chat-area">
+ <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
+ <converse-chat-content
+ class="chat-content__messages"
+ jid="${o.jid}"></converse-chat-content>
+
+ ${(o.model?.get('show_help_messages')) ?
+ html`<div class="chat-content__help">
+ <converse-chat-help
+ .model=${o.model}
+ .messages=${o.getHelpMessages()}
+ type="info"
+ chat_type="${_converse.CHATROOMS_TYPE}"
+ ></converse-chat-help></div>` : '' }
+ </div>
+ <converse-muc-bottom-panel jid="${o.jid}" class="bottom-panel"></converse-muc-bottom-panel>
+ </div>
+ <div class="disconnect-container hidden"></div>
+ ${o.model ? html`
+ <converse-muc-sidebar
+ class="occupants col-md-3 col-4 ${o.shouldShowSidebar() ? '' : 'hidden' }"
+ style="flex: 0 0 ${o.model.get('occupants_width')}px"
+ jid=${o.jid}
+ @mousedown=${o.onMousedown}></converse-muc-sidebar>` : '' }
+`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js
new file mode 100644
index 0000000..b6496a1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js
@@ -0,0 +1,51 @@
+import tplSpinner from 'templates/spinner.js';
+import { __ } from 'i18n';
+import { api, converse } from "@converse/headless/core";
+import { html } from "lit";
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
+
+export default (o) => {
+ const whitelist = api.settings.get('roomconfig_whitelist');
+ const config_stanza = o.model.session.get('config_stanza');
+ let fields = [];
+ let instructions = '';
+ let title;
+ if (config_stanza) {
+ const stanza = u.toStanza(config_stanza);
+ fields = sizzle('field', stanza);
+ if (whitelist.length) {
+ fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
+ }
+ const password_protected = o.model.features.get('passwordprotected');
+ const options = {
+ 'new_password': !password_protected,
+ 'fixed_username': o.model.get('jid')
+ };
+ fields = fields.map(f => u.xForm2TemplateResult(f, stanza, options));
+ instructions = stanza.querySelector('instructions')?.textContent;
+ title = stanza.querySelector('title')?.textContent;
+ } else {
+ title = __('Loading configuration form');
+ }
+ const i18n_save = __('Save');
+ const i18n_cancel = __('Cancel');
+ return html`
+ <form class="converse-form chatroom-form ${fields.length ? '' : 'converse-form--spinner'}"
+ autocomplete="off"
+ @submit=${o.submitConfigForm}>
+
+ <fieldset class="form-group">
+ <legend class="centered">${title}</legend>
+ ${ (title !== instructions) ? html`<p class="form-help">${instructions}</p>` : '' }
+ ${ fields.length ? fields : tplSpinner({'classes': 'hor_centered'}) }
+ </fieldset>
+ ${ fields.length ? html`
+ <fieldset>
+ <input type="submit" class="btn btn-primary" value="${i18n_save}">
+ <input type="button" class="btn btn-secondary button-cancel" value="${i18n_cancel}" @click=${o.closeConfigForm}>
+ </fieldset>` : '' }
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js
new file mode 100644
index 0000000..7f6fdd2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js
@@ -0,0 +1,41 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+export default (o) => {
+ const i18n_desc = __('Description:');
+ const i18n_jid = __('Groupchat XMPP Address:');
+ const i18n_occ = __('Participants:');
+ const i18n_features = __('Features:');
+ const i18n_requires_auth = __('Requires authentication');
+ const i18n_hidden = __('Hidden');
+ const i18n_requires_invite = __('Requires an invitation');
+ const i18n_moderated = __('Moderated');
+ const i18n_non_anon = __('Non-anonymous');
+ const i18n_open_room = __('Open');
+ const i18n_permanent_room = __('Permanent');
+ const i18n_public = __('Public');
+ const i18n_semi_anon = __('Semi-anonymous');
+ const i18n_temp_room = __('Temporary');
+ const i18n_unmoderated = __('Unmoderated');
+ return html`
+ <div class="room-info">
+ <p class="room-info"><strong>${i18n_jid}</strong> ${o.jid}</p>
+ <p class="room-info"><strong>${i18n_desc}</strong> ${o.desc}</p>
+ <p class="room-info"><strong>${i18n_occ}</strong> ${o.occ}</p>
+ <p class="room-info"><strong>${i18n_features}</strong>
+ <ul>
+ ${ o.passwordprotected ? html`<li class="room-info locked">${i18n_requires_auth}</li>` : '' }
+ ${ o.hidden ? html`<li class="room-info">${i18n_hidden}</li>` : '' }
+ ${ o.membersonly ? html`<li class="room-info">${i18n_requires_invite}</li>` : '' }
+ ${ o.moderated ? html`<li class="room-info">${i18n_moderated}</li>` : '' }
+ ${ o.nonanonymous ? html`<li class="room-info">${i18n_non_anon}</li>` : '' }
+ ${ o.open ? html`<li class="room-info">${i18n_open_room}</li>` : '' }
+ ${ o.persistent ? html`<li class="room-info">${i18n_permanent_room}</li>` : '' }
+ ${ o.publicroom ? html`<li class="room-info">${i18n_public}</li>` : '' }
+ ${ o.semianonymous ? html`<li class="room-info">${i18n_semi_anon}</li>` : '' }
+ ${ o.temporary ? html`<li class="room-info">${i18n_temp_room}</li>` : '' }
+ ${ o.unmoderated ? html`<li class="room-info">${i18n_unmoderated}</li>` : '' }
+ </ul>
+ </p>
+ </div>
+`};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js
new file mode 100644
index 0000000..1e090d8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js
@@ -0,0 +1,23 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+
+const tplMoved = (o) => {
+ const i18n_moved = __('The conversation has moved to a new address. Click the link below to enter.');
+ return html`
+ <p class="moved-label">${i18n_moved}</p>
+ <p class="moved-link">
+ <a class="switch-chat" @click=${ev => o.onSwitch(ev)}>${o.moved_jid}</a>
+ </p>`;
+}
+
+export default (o) => {
+ const i18n_non_existent = __('This groupchat no longer exists');
+ const i18n_reason = __('The following reason was given: "%1$s"', o.reason || '');
+ return html`
+ <div class="alert alert-danger">
+ <h3 class="alert-heading disconnect-msg">${i18n_non_existent}</h3>
+ </div>
+ ${ o.reason ? html`<p class="destroyed-reason">${i18n_reason}</p>` : '' }
+ ${ o.moved_jid ? tplMoved(o) : '' }
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js
new file mode 100644
index 0000000..aab97b4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js
@@ -0,0 +1,10 @@
+import { html } from "lit";
+
+
+export default (messages) => {
+ return html`
+ <div class="alert alert-danger">
+ <h3 class="alert-heading disconnect-msg">${messages[0]}</h3>
+ ${ messages.slice(1).map(m => html`<p class="disconnect-msg">${m}</p>`) }
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js
new file mode 100644
index 0000000..efd7789
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js
@@ -0,0 +1,50 @@
+import 'shared/components/dropdown.js';
+import 'shared/components/rich-text.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core.js";
+import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js';
+import { html } from "lit";
+import { until } from 'lit/directives/until.js';
+
+
+export default (el) => {
+ const o = el.model.toJSON();
+ const subject_hidden = el.user_settings?.get('mucs_with_hidden_subject', [])?.includes(el.model.get('jid'));
+ const heading_buttons_promise = el.getHeadingButtons(subject_hidden);
+ const i18n_hide_topic = __('Hide the groupchat topic');
+ const i18n_bookmarked = __('This groupchat is bookmarked');
+ const subject = o.subject ? o.subject.text : '';
+ const show_subject = (subject && !subject_hidden);
+ const muc_vcard = el.model.vcard?.get('image');
+ return html`
+ <div class="chatbox-title ${ show_subject ? '' : "chatbox-title--no-desc"}">
+
+ ${ muc_vcard && muc_vcard !== _converse.DEFAULT_IMAGE ? html`
+ <converse-avatar class="avatar align-self-center"
+ .data=${el.model.vcard?.attributes}
+ nonce=${el.model.vcard?.get('vcard_updated')}
+ height="40" width="40"></converse-avatar>` : ''
+ }
+
+ <div class="chatbox-title--row">
+ ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
+ <div class="chatbox-title__text" title="${ (api.settings.get('locked_muc_domain') !== 'hidden') ? o.jid : '' }">${ el.model.getDisplayName() }
+ ${ (o.bookmarked) ?
+ html`<converse-icon
+ class="fa fa-bookmark chatbox-title__text--bookmarked"
+ size="1em"
+ color="var(--chatroom-head-color)"
+ title="${i18n_bookmarked}">
+ </converse-icon>` : '' }
+ </div>
+ </div>
+ <div class="chatbox-title__buttons row no-gutters">
+ ${ until(getStandaloneButtons(heading_buttons_promise), '') }
+ ${ until(getDropdownButtons(heading_buttons_promise), '') }
+ </div>
+ </div>
+ ${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">
+ <converse-rich-text text=${subject} render_styling></converse-rich-text>
+ </p>` : '' }
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js
new file mode 100644
index 0000000..00af8ab
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js
@@ -0,0 +1,62 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { repeat } from 'lit/directives/repeat.js';
+import spinner from "templates/spinner.js";
+
+
+const form = (o) => {
+ const i18n_query = __('Show groupchats');
+ const i18n_server_address = __('Server address');
+ return html`
+ <form class="converse-form list-chatrooms"
+ @submit=${o.submitForm}>
+ <div class="form-group">
+ <label for="chatroom">${i18n_server_address}:</label>
+ <input type="text"
+ autofocus
+ @change=${o.setDomainFromEvent}
+ value="${o.muc_domain || ''}"
+ required="required"
+ name="server"
+ class="form-control"
+ placeholder="${o.server_placeholder}"/>
+ </div>
+ <input type="submit" class="btn btn-primary" name="list" value="${i18n_query}"/>
+ </form>
+ `;
+}
+
+
+const tplItem = (o, item) => {
+ const i18n_info_title = __('Show more information on this groupchat');
+ const i18n_open_title = __('Click to open this groupchat');
+ return html`
+ <li class="room-item list-group-item">
+ <div class="available-chatroom d-flex flex-row">
+ <a class="open-room available-room w-100"
+ @click=${o.openRoom}
+ data-room-jid="${item.jid}"
+ data-room-name="${item.name}"
+ title="${i18n_open_title}"
+ href="#">${item.name || item.jid}</a>
+ <a class="right room-info icon-room-info"
+ @click=${o.toggleRoomInfo}
+ data-room-jid="${item.jid}"
+ title="${i18n_info_title}"
+ href="#"></a>
+ </div>
+ </li>
+ `;
+}
+
+
+export default (o) => {
+ return html`
+ ${o.show_form ? form(o) : '' }
+ <ul class="available-chatrooms list-group">
+ ${ o.loading_items ? html`<li class="list-group-item"> ${ spinner() } </li>` : '' }
+ ${ o.feedback_text ? html`<li class="list-group-item active">${ o.feedback_text }</li>` : '' }
+ ${ repeat(o.items, (item) => item.jid, (item) => tplItem(o, item)) }
+ </ul>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js
new file mode 100644
index 0000000..54b758d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js
@@ -0,0 +1,36 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+
+export default (el) => {
+ const i18n_nickname = __('Nickname');
+ const i18n_join = el.model?.isEntered() ? __('Change nickname') : __('Enter groupchat');
+ const i18n_heading = api.settings.get('muc_show_logs_before_join') ?
+ __('Choose a nickname to enter') :
+ __('Please choose your nickname');
+
+ const validation_message = el.model?.get('nickname_validation_message');
+
+ return html`
+ <div class="chatroom-form-container muc-nickname-form">
+ <form class="converse-form chatroom-form converse-centered-form"
+ @submit=${ev => el.submitNickname(ev)}>
+ <fieldset class="form-group">
+ <label>${i18n_heading}</label>
+ <p class="validation-message">${validation_message}</p>
+ <input type="text"
+ required="required"
+ name="nick"
+ value="${el.model?.get('nick') || ''}"
+ class="form-control ${validation_message ? 'error': ''}"
+ placeholder="${i18n_nickname}"/>
+ </fieldset>
+ <fieldset class="form-group">
+ <input type="submit"
+ class="btn btn-primary"
+ name="join"
+ value="${i18n_join}"/>
+ </fieldset>
+ </form>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js
new file mode 100644
index 0000000..f259814
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js
@@ -0,0 +1,26 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (o) => {
+ const i18n_heading = __('This groupchat requires a password');
+ const i18n_password = __('Password: ');
+ const i18n_submit = __('Submit');
+ return html`
+ <form class="converse-form chatroom-form converse-centered-form" @submit=${o.submitPassword}>
+ <fieldset class="form-group">
+ <label>${i18n_heading}</label>
+ <p class="validation-message">${o.validation_message}</p>
+ <input class="hidden-username" type="text" autocomplete="username" value="${o.jid}"></input>
+ <input type="password"
+ name="password"
+ required="required"
+ class="form-control ${o.validation_message ? 'error': ''}"
+ placeholder="${i18n_password}"/>
+ </fieldset>
+ <fieldset class="form-group">
+ <input class="btn btn-primary" type="submit" value="${i18n_submit}"/>
+ </fieldset>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js
new file mode 100644
index 0000000..defdf36
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js
@@ -0,0 +1,21 @@
+import tplOccupant from "./occupant.js";
+import { __ } from 'i18n';
+import { html } from "lit";
+import { repeat } from 'lit/directives/repeat.js';
+
+
+export default (o) => {
+ const i18n_participants = o.occupants.length === 1 ? __('Participant') : __('Participants');
+ return html`
+ <div class="occupants-header">
+ <div class="occupants-header--title">
+ <span class="occupants-heading">${o.occupants.length} ${i18n_participants}</span>
+ <i class="hide-occupants" @click=${o.closeSidebar}>
+ <converse-icon class="fa fa-times" size="1em"></converse-icon>
+ </i>
+ </div>
+ </div>
+ <div class="dragresize dragresize-occupants-left"></div>
+ <ul class="occupant-list">${ repeat(o.occupants, (occ) => occ.get('jid'), (occ) => tplOccupant(occ, o)) }</ul>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js
new file mode 100644
index 0000000..4ac05f2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js
@@ -0,0 +1,22 @@
+import '../chatarea.js';
+import '../config-form.js';
+import '../destroyed.js';
+import '../disconnected.js';
+import '../heading.js';
+import '../nickname-form.js';
+import '../password-form.js';
+import { html } from "lit";
+import { getChatRoomBodyTemplate } from '../utils.js';
+
+
+export default (o) => {
+ return html`
+ <div class="flyout box-flyout">
+ <converse-dragresize></converse-dragresize>
+ ${ o.model ? html`
+ <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters">
+ </converse-muc-heading>
+ <div class="chat-body chatroom-body row no-gutters">${getChatRoomBodyTemplate(o)}</div>
+ ` : '' }
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js
new file mode 100644
index 0000000..c3facf8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js
@@ -0,0 +1,80 @@
+import { PRETTY_CHAT_STATUS } from '../constants.js';
+import { __ } from 'i18n';
+import { html } from "lit";
+import { showOccupantModal } from '../utils.js';
+
+const i18n_occupant_hint = (o) => __('Click to mention %1$s in your message.', o.get('nick'))
+
+const occupant_title = (o) => {
+ const role = o.get('role');
+ const hint_occupant = i18n_occupant_hint(o);
+ const i18n_moderator_hint = __('This user is a moderator.');
+ const i18n_participant_hint = __('This user can send messages in this groupchat.');
+ const i18n_visitor_hint = __('This user can NOT send messages in this groupchat.')
+ const spaced_jid = o.get('jid') ? `${o.get('jid')} ` : '';
+ if (role === "moderator") {
+ return `${spaced_jid}${i18n_moderator_hint} ${hint_occupant}`;
+ } else if (role === "participant") {
+ return `${spaced_jid}${i18n_participant_hint} ${hint_occupant}`;
+ } else if (role === "visitor") {
+ return `${spaced_jid}${i18n_visitor_hint} ${hint_occupant}`;
+ } else if (!["visitor", "participant", "moderator"].includes(role)) {
+ return `${spaced_jid}${hint_occupant}`;
+ }
+}
+
+
+export default (o, chat) => {
+ const affiliation = o.get('affiliation');
+ const hint_show = PRETTY_CHAT_STATUS[o.get('show')];
+ const i18n_admin = __('Admin');
+ const i18n_member = __('Member');
+ const i18n_moderator = __('Moderator');
+ const i18n_owner = __('Owner');
+ const i18n_visitor = __('Visitor');
+ const role = o.get('role');
+
+ const show = o.get('show');
+ let classes, color;
+ if (show === 'online') {
+ [classes, color] = ['fa fa-circle', 'chat-status-online'];
+ } else if (show === 'dnd') {
+ [classes, color] = ['fa fa-minus-circle', 'chat-status-busy'];
+ } else if (show === 'away') {
+ [classes, color] = ['fa fa-circle', 'chat-status-away'];
+ } else {
+ [classes, color] = ['fa fa-circle', 'subdued-color'];
+ }
+
+ return html`
+ <li class="occupant" id="${o.id}" title="${occupant_title(o)}">
+ <div class="row no-gutters">
+ <div class="col-auto">
+ <a class="show-msg-author-modal" @click=${(ev) => showOccupantModal(ev, o)}>
+ <converse-avatar
+ class="avatar chat-msg__avatar"
+ .data=${o.vcard?.attributes}
+ nonce=${o.vcard?.get('vcard_updated')}
+ height="30" width="30"></converse-avatar>
+ <converse-icon
+ title="${hint_show}"
+ color="var(--${color})"
+ style="margin-top: -0.1em"
+ size="0.82em"
+ class="${classes} chat-status chat-status--avatar"></converse-icon>
+ </a>
+ </div>
+ <div class="col occupant-nick-badge">
+ <span class="occupant-nick" @click=${chat.onOccupantClicked}>${o.getDisplayName()}</span>
+ <span class="occupant-badges">
+ ${ (affiliation === "owner") ? html`<span class="badge badge-groupchat">${i18n_owner}</span>` : '' }
+ ${ (affiliation === "admin") ? html`<span class="badge badge-info">${i18n_admin}</span>` : '' }
+ ${ (affiliation === "member") ? html`<span class="badge badge-info">${i18n_member}</span>` : '' }
+ ${ (role === "moderator") ? html`<span class="badge badge-info">${i18n_moderator}</span>` : '' }
+ ${ (role === "visitor") ? html`<span class="badge badge-secondary">${i18n_visitor}</span>` : '' }
+ </span>
+ </div>
+ </div>
+ </li>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js
new file mode 100644
index 0000000..8e89df4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js
@@ -0,0 +1,37 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { getAssignableRoles } from '@converse/headless/plugins/muc/utils.js';
+
+export default (el) => {
+ const i18n_change_role = __('Change role');
+ const i18n_new_role = __('New Role');
+ const i18n_reason = __('Reason');
+ const occupant = el.muc.getOwnOccupant();
+ const assignable_roles = getAssignableRoles(occupant);
+
+ return html`
+ <form class="role-form" @submit=${el.assignRole}>
+ <div class="form-group">
+ <input type="hidden" name="jid" value="${el.jid}"/>
+ <input type="hidden" name="nick" value="${el.nick}"/>
+ <div class="row">
+ <div class="col">
+ <label><strong>${i18n_new_role}:</strong></label>
+ <select class="custom-select select-role" name="role">
+ ${ assignable_roles.map(role => html`<option value="${role}" ?selected=${role === el.role}>${role}</option>`) }
+ </select>
+ </div>
+ <div class="col">
+ <label><strong>${i18n_reason}:</strong></label>
+ <input class="form-control" type="text" name="reason"/>
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <div class="col">
+ <input type="submit" class="btn btn-primary" value="${i18n_change_role}"/>
+ </div>
+ </div>
+ </form>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js
new file mode 100644
index 0000000..4994e52
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js
@@ -0,0 +1,367 @@
+/*global mock, converse */
+
+const $pres = converse.env.$pres;
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+
+describe("The nickname autocomplete feature", function () {
+
+ it("shows all autocompletion options when the user presses @",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+ // Nicknames from presences
+ ['dick', 'harry'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ // Nicknames from messages
+ const msg = $msg({
+ from: 'lounge@montague.lit/jane',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Hello world').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.last()?.get('received'));
+
+ // Test that pressing @ brings up all options
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const at_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 50,
+ 'key': '@'
+ };
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(at_event);
+ textarea.value = '@';
+ message_form.onKeyUp(at_event);
+
+ await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
+ expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+ }));
+
+ it("shows all autocompletion options when the user presses @ right after a new line",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+ // Nicknames from presences
+ ['dick', 'harry'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ // Nicknames from messages
+ const msg = $msg({
+ from: 'lounge@montague.lit/jane',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Hello world').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.last()?.get('received'));
+
+ // Test that pressing @ brings up all options
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const at_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 50,
+ 'key': '@'
+ };
+ const message_form = view.querySelector('converse-muc-message-form');
+ textarea.value = '\n'
+ message_form.onKeyDown(at_event);
+ textarea.value = '\n@';
+ message_form.onKeyUp(at_event);
+
+ await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
+ expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+ }));
+
+ it("shows all autocompletion options when the user presses @ right after an allowed character",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'opening_mention_characters':['(']},
+ async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+ // Nicknames from presences
+ ['dick', 'harry'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ // Nicknames from messages
+ const msg = $msg({
+ from: 'lounge@montague.lit/jane',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Hello world').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.last()?.get('received'));
+
+ // Test that pressing @ brings up all options
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const at_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 50,
+ 'key': '@'
+ };
+ textarea.value = '('
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(at_event);
+ textarea.value = '(@';
+ message_form.onKeyUp(at_event);
+
+ await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
+ expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom');
+ }));
+
+ it("should order by query index position and length", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+
+ // Nicknames from presences
+ ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', { xmlns: Strophe.NS.MUC_USER })
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const at_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault() { },
+ 'stopPropagation': function stopPropagation() { },
+ 'keyCode': 50,
+ 'key': '@'
+ };
+
+ const message_form = view.querySelector('converse-muc-message-form');
+ // Test that results are sorted by query index
+ message_form.onKeyDown(at_event);
+ textarea.value = '@ber';
+ message_form.onKeyUp(at_event);
+ await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
+ expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('bernard');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('helberlo');
+
+ // Test that when the query index is equal, results should be sorted by length
+ textarea.value = '@jo';
+ message_form.onKeyUp(at_event);
+ await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
+ expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('john');
+ expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones');
+ }));
+
+ it("autocompletes when the user presses tab",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.model.occupants.length).toBe(1);
+ let presence = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/some1'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'some1@montague.lit/resource',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.occupants.length).toBe(2);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = "hello som";
+
+ // Press tab
+ const tab_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 9,
+ 'key': 'Tab'
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(tab_event);
+ message_form.onKeyUp(tab_event);
+ await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
+ expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
+ expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+
+ const backspace_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'keyCode': 8
+ }
+ for (let i=0; i<3; i++) {
+ // Press backspace 3 times to remove "som"
+ message_form.onKeyDown(backspace_event);
+ textarea.value = textarea.value.slice(0, textarea.value.length-1)
+ message_form.onKeyUp(backspace_event);
+ }
+ await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true);
+
+ presence = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/some2'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'some2@montague.lit/resource',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ textarea.value = "hello s s";
+ message_form.onKeyDown(tab_event);
+ message_form.onKeyUp(tab_event);
+ await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
+ expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+
+ const up_arrow_event = {
+ 'target': textarea,
+ 'preventDefault': () => (up_arrow_event.defaultPrevented = true),
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 38
+ }
+ message_form.onKeyDown(up_arrow_event);
+ message_form.onKeyUp(up_arrow_event);
+ expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+ expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
+ expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
+
+ message_form.onKeyDown({
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ });
+ expect(textarea.value).toBe('hello s @some2 ');
+
+ // Test that pressing tab twice selects
+ presence = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/z3r0'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'z3r0@montague.lit/resource',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ textarea.value = "hello z";
+ message_form.onKeyDown(tab_event);
+ message_form.onKeyUp(tab_event);
+ await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
+
+ message_form.onKeyDown(tab_event);
+ message_form.onKeyUp(tab_event);
+ await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
+ }));
+
+ it("autocompletes when the user presses backspace",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.model.occupants.length).toBe(1);
+ const presence = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/some1'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'some1@montague.lit/resource',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.occupants.length).toBe(2);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = "hello @some1 ";
+
+ // Press backspace
+ const backspace_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 8,
+ 'key': 'Backspace'
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(backspace_event);
+ textarea.value = "hello @some1"; // Mimic backspace
+ message_form.onKeyUp(backspace_event);
+ await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
+ expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
+ expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js
new file mode 100644
index 0000000..f8e1790
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js
@@ -0,0 +1,96 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+
+
+describe("The <converse-muc> component", function () {
+
+ it("can be rendered as a standalone component",
+ mock.initConverse([], {'auto_insert': false}, async function (_converse) {
+
+ const { api } = _converse;
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ await muc_creation_promise;
+ const model = _converse.chatboxes.get(muc_jid);
+ await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+
+ const span_el = document.createElement('span');
+ span_el.classList.add('conversejs');
+ span_el.classList.add('converse-embedded');
+
+ const muc_el = document.createElement('converse-muc');
+ muc_el.classList.add('chatbox');
+ muc_el.classList.add('chatroom');
+ muc_el.setAttribute('jid', muc_jid);
+ span_el.appendChild(muc_el);
+
+ const body = document.querySelector('body');
+ body.appendChild(span_el);
+ await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel'));
+ body.removeChild(span_el);
+ expect(true).toBe(true);
+ }));
+
+ it("will update correctly when the jid property changes",
+ mock.initConverse([], {'auto_insert': false}, async function (_converse) {
+
+ const { api } = _converse;
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+
+
+ const muc_creation_promise = api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ await muc_creation_promise;
+ const model = _converse.chatboxes.get(muc_jid);
+ await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ const affs = api.settings.get('muc_fetch_members');
+ const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []);
+ await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations);
+ await model.messages.fetched;
+
+ model.sendMessage({'body': 'hello from the lounge!'});
+
+ const span_el = document.createElement('span');
+ span_el.classList.add('conversejs');
+ span_el.classList.add('converse-embedded');
+
+
+ const muc_el = document.createElement('converse-muc');
+ muc_el.classList.add('chatbox');
+ muc_el.classList.add('chatroom');
+ muc_el.setAttribute('jid', muc_jid);
+ span_el.appendChild(muc_el);
+
+ const body = document.querySelector('body');
+ body.appendChild(span_el);
+ await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel'));
+ muc_el.querySelector('.box-flyout').setAttribute('style', 'height: 80vh');
+
+ const message = await u.waitUntil(() => muc_el.querySelector('converse-chat-message'));
+ expect(message.model.get('body')).toBe('hello from the lounge!');
+
+ _converse.connection.sent_stanzas = [];
+
+ const muc2_jid = 'bar@montague.lit';
+ const muc2_creation_promise = api.rooms.open(muc2_jid, {nick, 'hidden': true}, false);
+ await mock.getRoomFeatures(_converse, muc2_jid, []);
+ await mock.receiveOwnMUCPresence(_converse, muc2_jid, nick);
+ await muc2_creation_promise;
+ const model2 = _converse.chatboxes.get(muc2_jid);
+ await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ await mock.returnMemberLists(_converse, muc2_jid, [], all_affiliations);
+ await model.messages.fetched;
+
+ model2.sendMessage({'body': 'hello from the bar!'});
+ muc_el.setAttribute('jid', muc2_jid);
+
+ await u.waitUntil(() => muc_el.querySelector('converse-chat-message-body').textContent.trim() === 'hello from the bar!');
+ body.removeChild(span_el);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js
new file mode 100644
index 0000000..bd2d401
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js
@@ -0,0 +1,427 @@
+/*global mock, converse */
+
+const { $msg, $pres, Strophe, u, stx } = converse.env;
+
+describe("A Groupchat Message", function () {
+
+ it("can be replaced with a correction",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const stanza = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const msg_id = u.getUniqueId();
+ await model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': msg_id,
+ }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
+ 'But soft, what light through yonder chimney breaks?', 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
+
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder window breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
+ 'But soft, what light through yonder window breaks?', 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
+ edit.click();
+ const modal = _converse.api.modal.get('converse-message-versions-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const older_msgs = modal.querySelectorAll('.older-msg');
+ expect(older_msgs.length).toBe(2);
+ expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
+ expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true);
+ }));
+
+ it("keeps the same position in history after a correction",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const stanza = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const msg_id = u.getUniqueId();
+
+ // Receiving the first message
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': msg_id,
+ }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+
+ // Receiving own message to check order against
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/romeo',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__text')[0].textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.querySelectorAll('.chat-msg__text')[1].textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ // First message correction
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
+ 'But soft, what light through yonder chimney breaks?', 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+ await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
+
+ // Second message correction
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/newguy',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder window breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+
+ // Second own message
+ await view.model.handleMessageStanza($msg({
+ 'from': 'lounge@montague.lit/romeo',
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder window breaks?').tree());
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[0].textContent ===
+ 'But soft, what light through yonder window breaks?', 500);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[2].textContent ===
+ 'But soft, what light through yonder window breaks?', 500);
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(3);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
+ edit.click();
+ const modal = _converse.api.modal.get('converse-message-versions-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const older_msgs = modal.querySelectorAll('.older-msg');
+ expect(older_msgs.length).toBe(2);
+ expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
+ expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true);
+ }));
+
+ it("can be sent as a correction by using the up arrow",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+ expect(textarea.value).toBe('');
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
+
+ spyOn(_converse.connection, 'send');
+ const new_text = 'But soft, what light through yonder window breaks?'
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length);
+
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const msg = _converse.connection.send.calls.all()[0].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>But soft, what light through yonder window breaks?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+
+ expect(view.model.messages.models.length).toBe(1);
+ const corrected_message = view.model.messages.at(0);
+ expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+ expect(corrected_message.get('correcting')).toBe(false);
+
+ const older_versions = corrected_message.get('older_versions');
+ const keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(u.hasClass('correcting', view.querySelector('.chat-msg'))).toBe(false);
+
+ // Check that messages from other users are skipped
+ await view.model.handleMessageStanza($msg({
+ 'from': muc_jid+'/someone-else',
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t('Hello world').tree());
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+
+ // Test that pressing the down arrow cancels message correction
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 40 // Down arrow
+ });
+ expect(textarea.value).toBe('');
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+ await u.waitUntil(() => !u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+ }));
+});
+
+
+describe('A Groupchat Message XEP-0308 correction ', function () {
+ it(
+ "is ignored if it's from a different occupant-id",
+ mock.initConverse([], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const msg_id = u.getUniqueId();
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/newguy"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${msg_id}">
+
+ <body>But soft, what light through yonder airlock breaks?</body>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="1"></occupant-id>
+ </message>`
+ );
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?');
+
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/newguy"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${u.getUniqueId()}">
+
+ <body>But soft, what light through yonder chimney breaks?</body>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="2"></occupant-id>
+ <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace>
+ </message>`
+ );
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(model.messages.length).toBe(2);
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?');
+ expect(model.messages.at(0).get('edited')).toBeFalsy();
+
+ expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?');
+ expect(model.messages.at(1).get('edited')).toBeTruthy();
+
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/newguy"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${u.getUniqueId()}">
+
+ <body>But soft, what light through yonder hatch breaks?</body>
+ <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace>
+ </message>`
+ );
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
+ expect(model.messages.length).toBe(3);
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?');
+ expect(model.messages.at(0).get('edited')).toBeFalsy();
+
+ expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?');
+ expect(model.messages.at(1).get('edited')).toBeTruthy();
+
+ expect(model.messages.at(2).get('body')).toBe('But soft, what light through yonder hatch breaks?');
+ expect(model.messages.at(2).get('edited')).toBeTruthy();
+
+ const message_els = Array.from(view.querySelectorAll('.chat-msg'));
+ expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false);
+ })
+ );
+
+ it(
+ "cannot be edited if it's from a different occupant id",
+ mock.initConverse([], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+
+ expect(model.get('occupant_id')).toBe(model.occupants.at(0).get('occupant_id'));
+
+ const msg_id = u.getUniqueId();
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/${nick}"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${msg_id}">
+
+ <body>But soft, what light through yonder airlock breaks?</body>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id>
+ </message>`
+ );
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?');
+
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/${nick}"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${u.getUniqueId()}">
+
+ <body>But soft, what light through yonder chimney breaks?</body>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id>
+ <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace>
+ </message>`
+ );
+
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?');
+ expect(model.messages.at(0).get('edited')).toBeTruthy();
+
+ await model.handleMessageStanza(
+ stx`
+ <message
+ xmlns="jabber:server"
+ from="lounge@montague.lit/${nick}"
+ to="_converse.connection.jid"
+ type="groupchat"
+ id="${u.getUniqueId()}">
+
+ <body>But soft, what light through yonder hatch breaks?</body>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${u.getUniqueId()}"></occupant-id>
+ <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace>
+ </message>`
+ );
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(model.messages.length).toBe(2);
+ expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?');
+ expect(model.messages.at(0).get('edited')).toBeTruthy();
+ expect(model.messages.at(0).get('editable')).toBeTruthy();
+
+ expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder hatch breaks?');
+ expect(model.messages.at(1).get('edited')).toBeTruthy();
+ expect(model.messages.at(1).get('editable')).toBeFalsy();
+
+ const message_els = Array.from(view.querySelectorAll('.chat-msg'));
+ expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false);
+
+ // We can edit our own message, but not the other
+ expect(message_els[0].querySelector('converse-dropdown .chat-msg__action-edit')).toBeDefined();
+ expect(message_els[1].querySelector('converse-dropdown .chat-msg__action-edit')).toBe(null);
+ })
+ );
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js
new file mode 100644
index 0000000..13376b0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js
@@ -0,0 +1,66 @@
+/*global mock, converse */
+
+describe("Service Discovery", function () {
+
+ it("can be used to set the muc_domain", mock.initConverse( ['discoInitialized'], {}, async function (_converse) {
+ const { u, $iq } = converse.env;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const IQ_ids = _converse.connection.IQ_ids;
+ const { api } = _converse;
+
+ expect(api.settings.get('muc_domain')).toBe(undefined);
+
+ await u.waitUntil(() => IQ_stanzas.filter(
+ (iq) => iq.querySelector(`iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).length > 0
+ );
+
+ let stanza = IQ_stanzas.find((iq) => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+ const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': info_IQ_id
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', { 'category': 'server', 'type': 'im'}).up()
+ .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms'}).up()
+ .c('feature', { 'var': 'http://jabber.org/protocol/disco#info'}).up()
+ .c('feature', { 'var': 'http://jabber.org/protocol/disco#items'}).up();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+
+ stanza = await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).pop()
+ );
+
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': IQ_ids[IQ_stanzas.indexOf(stanza)]
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+ .c('item', { 'jid': 'chat.shakespeare.lit', 'name': 'Chatroom Service'})));
+
+ stanza = await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="chat.shakespeare.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).pop()
+ );
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'type': 'result',
+ 'from': 'chat.shakespeare.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': IQ_ids[IQ_stanzas.indexOf(stanza)]
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms', 'type': 'text'}).up()
+ .c('feature', { 'var': 'http://jabber.org/protocol/muc'}).up()));
+
+ const entities = await _converse.api.disco.entities.get();
+ expect(entities.length).toBe(3); // We have an extra entity, which is the user's JID
+ expect(entities.get(_converse.domain).identities.length).toBe(2);
+ expect(entities.get('montague.lit').features.where(
+ {'var': 'http://jabber.org/protocol/disco#items'}).length).toBe(1);
+ expect(entities.get('montague.lit').features.where(
+ {'var': 'http://jabber.org/protocol/disco#info'}).length).toBe(1);
+
+ await u.waitUntil(() => _converse.api.settings.get('muc_domain') === 'chat.shakespeare.lit');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js
new file mode 100644
index 0000000..eb85da5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js
@@ -0,0 +1,226 @@
+/*global mock, converse */
+
+const { $pres, sizzle } = converse.env;
+const u = converse.env.utils;
+
+describe("Emojis", function () {
+
+ describe("The emoji picker", function () {
+
+ it("is opened to autocomplete emojis in the textarea",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('converse-emoji-picker'));
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':gri';
+
+ // Press tab
+ const tab_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 9,
+ 'key': 'Tab'
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(tab_event);
+ await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri');
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
+ let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
+ expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+ expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
+ expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
+
+ const picker = view.querySelector('converse-emoji-picker');
+ const input = picker.querySelector('.emoji-search');
+ // Test that TAB autocompletes the to first match
+ input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+
+ await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
+ visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
+ expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+ expect(input.value).toBe(':grimacing:');
+
+ // Check that ENTER now inserts the match
+ const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true});
+ input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+
+ await u.waitUntil(() => input.value === '');
+ await u.waitUntil(() => textarea.value === ':grimacing: ');
+
+ // Test that username starting with : doesn't cause issues
+ const presence = $pres({
+ 'from': `${muc_jid}/:username`,
+ 'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': _converse.jid
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'some1@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ textarea.value = ':use';
+ message_form.onKeyDown(tab_event);
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+ await u.waitUntil(() => input.value === ':use');
+ visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
+ expect(visible_emojis.length).toBe(0);
+ }));
+
+ it("is focused to autocomplete emojis in the textarea",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('converse-emoji-picker'));
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':';
+ // Press tab
+ const tab_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 9,
+ 'key': 'Tab'
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(tab_event);
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+
+ const picker = view.querySelector('converse-emoji-picker');
+ const input = picker.querySelector('.emoji-search');
+ expect(input.value).toBe(':');
+ input.value = ':gri';
+ const event = {
+ 'target': input,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {}
+ };
+ input.dispatchEvent(new KeyboardEvent('keydown', event));
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
+ let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+ emoji.click();
+ await u.waitUntil(() => textarea.value === ':grinning: ');
+ textarea.value = ':grinning: :';
+ message_form.onKeyDown(tab_event);
+
+ await u.waitUntil(() => input.value === ':');
+ input.value = ':grimacing';
+ input.dispatchEvent(new KeyboardEvent('keydown', event));
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000);
+ emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+ emoji.click();
+ await u.waitUntil(() => textarea.value === ':grinning: :grimacing: ');
+ }));
+
+
+ it("properly inserts emojis into the chat textarea",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('converse-emoji-picker'));
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':gri';
+
+ // Press tab
+ const tab_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 9,
+ 'key': 'Tab'
+ }
+ textarea.value = ':';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(tab_event);
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+ const picker = view.querySelector('converse-emoji-picker');
+ const input = picker.querySelector('.emoji-search');
+ input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+ await u.waitUntil(() => input.value === ':100:');
+ const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true});
+ input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+ expect(textarea.value).toBe(':100: ');
+
+ textarea.value = ':';
+ message_form.onKeyDown(tab_event);
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+ await u.waitUntil(() => input.value === ':');
+ input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+ await u.waitUntil(() => input.value === ':100:');
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000);
+ const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+ emoji.click();
+ expect(textarea.value).toBe(':100: ');
+ }));
+
+
+ it("allows you to search for particular emojis",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
+ const toolbar = view.querySelector('converse-chat-toolbar');
+ toolbar.querySelector('.toggle-emojis').click();
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+ await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589);
+
+ const input = view.querySelector('.emoji-search');
+ input.value = 'smiley';
+ const event = {
+ 'target': input,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {}
+ };
+ input.dispatchEvent(new KeyboardEvent('keydown', event));
+
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
+ let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view);
+ expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+ expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
+
+ // Check that pressing enter without an unambiguous match does nothing
+ const enter_event = Object.assign({}, event, {'keyCode': 13, 'bubbles': true});
+ input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+ expect(input.value).toBe('smiley');
+
+ // Check that search results update when chars are deleted
+ input.value = 'sm';
+ input.dispatchEvent(new KeyboardEvent('keydown', event));
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000);
+
+ input.value = 'smiley';
+ input.dispatchEvent(new KeyboardEvent('keydown', event));
+ await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
+
+ // Test that TAB autocompletes the to first match
+ const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
+ input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+
+ await u.waitUntil(() => input.value === ':smiley:');
+ await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view).length === 1, 1000);
+ visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view);
+ expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+
+ // Check that ENTER now inserts the match
+ input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+ await u.waitUntil(() => input.value === '');
+ expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js
new file mode 100644
index 0000000..dd8a398
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js
@@ -0,0 +1,74 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+
+describe("A XEP-0317 MUC Hat", function () {
+
+ it("can be included in a presence stanza",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const hat1_id = u.getUniqueId();
+ const hat2_id = u.getUniqueId();
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="member" role="participant"/>
+ </x>
+ <hats xmlns="xmpp:prosody.im/protocol/hats:1">
+ <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
+ <hat title="Dark Mage" id="${hat2_id}"/>
+ </hats>
+ </presence>
+ `)));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo and Terry have entered the groupchat");
+
+ let hats = view.model.getOccupant("Terry").get('hats');
+ expect(hats.length).toBe(2);
+ expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage");
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message type="groupchat" from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+ <body>Hello world</body>
+ </message>
+ `)));
+
+ const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg'));
+ let badges = Array.from(msg_el.querySelectorAll('.badge'));
+ expect(badges.length).toBe(2);
+ expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage");
+
+ const hat3_id = u.getUniqueId();
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="member" role="participant"/>
+ </x>
+ <hats xmlns="xmpp:prosody.im/protocol/hats:1">
+ <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
+ <hat title="Dark Mage" id="${hat2_id}"/>
+ <hat title="Mad hatter" id="${hat3_id}"/>
+ </hats>
+ </presence>
+ `)));
+
+ await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
+ hats = view.model.getOccupant("Terry").get('hats');
+ expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter");
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 3, 1000);
+ badges = Array.from(view.querySelectorAll('.chat-msg .badge'));
+ expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="member" role="participant"/>
+ </x>
+ </presence>
+ `)));
+ await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 0);
+ }));
+})
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js
new file mode 100644
index 0000000..ddbc7ea
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js
@@ -0,0 +1,152 @@
+/*global mock, converse */
+
+const { Strophe, sizzle, u } = converse.env;
+
+
+describe("XEP-0363: HTTP File Upload", function () {
+
+ describe("When not supported", function () {
+ describe("A file upload toolbar button", function () {
+
+ it("does not appear in MUC chats", mock.initConverse([], {}, async (_converse) => {
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload') === null);
+ expect(1).toBe(1);
+ }));
+
+ });
+ });
+
+ describe("When supported", function () {
+
+ describe("A file upload toolbar button", function () {
+
+ it("appears in MUC chats", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').querySelector('.fileupload'));
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.querySelector('.chat-toolbar .fileupload')).not.toBe(null);
+ }));
+
+ describe("when clicked and a file chosen", function () {
+
+ it("is uploaded and sent out from a groupchat", mock.initConverse(['chatBoxesFetched'], {} ,async (_converse) => {
+ const base_url = 'https://conversejs.org';
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const send_backup = XMLHttpRequest.prototype.send;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+
+ // Wait until MAM query has been sent out
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '23456' ,
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+ const iq = IQ_stanzas.pop();
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" `+
+ `id="${iq.getAttribute("id")}" `+
+ `to="upload.montague.tld" `+
+ `type="get" `+
+ `xmlns="jabber:client">`+
+ `<request `+
+ `content-type="image/jpeg" `+
+ `filename="my-juliet.jpg" `+
+ `size="23456" `+
+ `xmlns="urn:xmpp:http:upload:0"/>`+
+ `</iq>`);
+
+ const message = base_url+"/logo/conversejs-filled.svg";
+ const stanza = u.toStanza(`
+ <iq from='upload.montague.tld'
+ id="${iq.getAttribute('id')}"
+ to='romeo@montague.lit/orchard'
+ type='result'>
+ <slot xmlns='urn:xmpp:http:upload:0'>
+ <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'>
+ <header name='Authorization'>Basic Base64String==</header>
+ <header name='Cookie'>foo=bar; user=romeo</header>
+ </put>
+ <get url="${message}" />
+ </slot>
+ </iq>`);
+
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
+ const message = view.model.messages.at(0);
+ const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+ expect(el.getAttribute('value')).toBe('0');
+ message.set('progress', 0.5);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
+ message.save({
+ 'upload': _converse.SUCCESS,
+ 'oob_url': message.get('get'),
+ 'body': message.get('get'),
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ });
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => sent_stanza, 1000);
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message `+
+ `from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lounge@montague.lit" `+
+ `type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>${message}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<x xmlns="jabber:x:oob">`+
+ `<url>${message}</url>`+
+ `</x>`+
+ `<origin-id id="${sent_stanza.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ const img_link_el = await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image__link'), 1000);
+ // Check that the image renders
+ expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`);
+
+ expect(view.querySelector('.chat-msg .chat-msg__media')).toBe(null);
+ XMLHttpRequest.prototype.send = send_backup;
+ }));
+
+
+ });
+ });
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js
new file mode 100644
index 0000000..ad88e70
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js
@@ -0,0 +1,72 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+
+describe("an info message", function () {
+
+ it("is not rendered as a followup message",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ let presence = u.toStanza(`
+ <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <status code="201"/>
+ <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+ <status code="110"/>
+ </x>
+ </presence>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
+
+ presence = u.toStanza(`
+ <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <status code="210"/>
+ <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+ <status code="110"/>
+ </x>
+ </presence>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+
+ const messages = view.querySelectorAll('.chat-info');
+ expect(u.hasClass('chat-msg--followup', messages[0])).toBe(false);
+ expect(u.hasClass('chat-msg--followup', messages[1])).toBe(false);
+ }));
+
+ it("is not shown if its a duplicate",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const presence = u.toStanza(`
+ <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <status code="201"/>
+ <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
+ <status code="110"/>
+ </x>
+ </presence>
+ `);
+ // XXX: We wait for createInfoMessages to complete, if we don't
+ // we still get two info messages due to messages
+ // created from presences not being queued and run
+ // sequentially (i.e. by waiting for promises to resolve)
+ // like we do with message stanzas.
+ spyOn(view.model, 'createInfoMessages').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.model.createInfoMessages.calls.count());
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.model.createInfoMessages.calls.count() === 2);
+ expect(view.querySelectorAll('.chat-info').length).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js
new file mode 100644
index 0000000..9f46e3e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js
@@ -0,0 +1,193 @@
+/*global mock, converse */
+
+const { Strophe, $msg, $pres } = converse.env;
+const u = converse.env.utils;
+
+describe("A MAM archived message", function () {
+
+ it("will appear in the correct order",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const nick = 'romeo';
+ const muc_jid = 'room@muc.example.com';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+
+ const messages = [
+ u.toStanza(`
+ <message to="${_converse.connection.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="9fe1a9d9-c979-488c-93a4-8a3c4dcbc63e">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:20Z"/>
+ <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="bc4caee0-380a-4f08-b20b-9015177a95bb">
+ <body>first message</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="bc4caee0-380a-4f08-b20b-9015177a95bb"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>`),
+
+ u.toStanza(`
+ <message to="${_converse.connection.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="64f68d52-76e6-4fa6-93ef-9fbf96bb237b">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:25Z"/>
+ <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef">
+ <body>2nd message</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>`),
+
+ u.toStanza(`
+ <message to="${_converse.connection.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2c07703-b285-4529-a4b4-12594f749c58">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:17Z"/>
+ <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="hDs1J0QHfimjggw2">
+ <store xmlns="urn:xmpp:hints"/>
+ <event xmlns="http://jabber.org/protocol/pubsub#event">
+ <items node="urn:ietf:params:xml:ns:conference-info">
+ <item id="wGkBOwEymL2l10Fj">
+ <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+ <activity xmlns="http://jabber.org/protocol/activity">
+ <other/>
+ <text id="activity-text">An anonymous user has tipped romeo 1 karma</text>
+ <reason>Thanks for your help the other day</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ </message>
+ </forwarded>
+ </result>
+ </message>`),
+
+ u.toStanza(`
+ <message to="${_converse.connection.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2b2b039-f808-4b4c-bfbd-607173e012f9">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:22Z"/>
+ <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424">
+ <body>4th message</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>`)
+ ]
+ spyOn(model, 'updateMessage');
+ _converse.handleMAMResult(model, { messages });
+
+ await u.waitUntil(() => model.messages.length === 4);
+ expect(model.messages.at(0).get('time')).toBe('2021-10-13T17:51:20.000Z');
+ expect(model.messages.at(1).get('time')).toBe('2021-10-13T17:51:25.000Z');
+ expect(model.messages.at(2).get('time')).toBe('2021-10-13T17:52:17.000Z');
+ expect(model.messages.at(3).get('time')).toBe('2021-10-13T17:52:22.000Z');
+ }));
+
+ it("is ignored if it has the same archive-id of an already received one",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'room@muc.example.com';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ spyOn(model, 'getDuplicateMessage').and.callThrough();
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="room@muc.example.com/some1"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>Typical body text</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="room@muc.example.com"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => model.messages.length === 1);
+ await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 1);
+ let result = await model.getDuplicateMessage.calls.all()[0].returnValue;
+ expect(result).toBe(undefined);
+
+ stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.connection.jid}"
+ from="room@muc.example.com">
+ <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/>
+ <message from="room@muc.example.com/some1" type="groupchat">
+ <body>Typical body text</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+
+ spyOn(model, 'updateMessage');
+ _converse.handleMAMResult(model, { 'messages': [stanza] });
+ await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 2);
+ result = await model.getDuplicateMessage.calls.all()[1].returnValue;
+ expect(result instanceof _converse.Message).toBe(true);
+ expect(model.messages.length).toBe(1);
+ await u.waitUntil(() => model.updateMessage.calls.count());
+ }));
+
+ it("will be discarded if it's a malicious message meant to look like a carbon copy",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const muc_jid = 'xsf@muc.xmpp.org';
+ const sender_jid = `${muc_jid}/romeo`;
+ const impersonated_jid = `${muc_jid}/i_am_groot`
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const stanza = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: sender_jid
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ /*
+ * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo">
+ * <received xmlns="urn:xmpp:carbons:2">
+ * <forwarded xmlns="urn:xmpp:forward:0">
+ * <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot">
+ * <body>I am groot.</body>
+ * </message>
+ * </forwarded>
+ * </received>
+ * </message>
+ */
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'xmlns': 'jabber:client'
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': impersonated_jid,
+ 'to': muc_jid,
+ 'type': 'groupchat'
+ }).c('body').t('I am groot').tree();
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(converse.env.log, 'error');
+ await _converse.handleMAMResult(model, { 'messages': [msg] });
+ await u.waitUntil(() => converse.env.log.error.calls.count());
+ expect(converse.env.log.error).toHaveBeenCalledWith(
+ 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'
+ );
+ expect(view.querySelectorAll('.chat-msg').length).toBe(0);
+ expect(model.messages.length).toBe(0);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js
new file mode 100644
index 0000000..f432a2b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js
@@ -0,0 +1,68 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("A XEP-0333 Chat Marker", function () {
+ it("may be returned for a MUC message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim())
+ .toBe("But soft, what light through yonder airlock breaks?");
+
+ const msg_obj = view.model.messages.at(0);
+ let stanza = u.toStanza(`
+ <message xml:lang="en" to="romeo@montague.lit/orchard"
+ from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+ <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+ stanza = u.toStanza(`
+ <message xml:lang="en" to="romeo@montague.lit/orchard"
+ from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+ <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+ stanza = u.toStanza(`
+ <message xml:lang="en" to="romeo@montague.lit/orchard"
+ from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+ <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+ stanza = u.toStanza(`
+ <message xml:lang="en" to="romeo@montague.lit/orchard"
+ from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+ <body>'tis I!</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js
new file mode 100644
index 0000000..b42e00c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js
@@ -0,0 +1,56 @@
+/*global mock, converse */
+
+const { u, sizzle, $msg } = converse.env;
+
+
+describe("A Groupchat Message", function () {
+
+ it("supports the /me command", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ if (!view.querySelectorAll('.chat-area').length) {
+ view.renderChatArea();
+ }
+ let message = '/me is tired';
+ const nick = mock.chatroom_names[0];
+ let msg = $msg({
+ 'from': 'lounge@montague.lit/'+nick,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t(message).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop());
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
+ expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy();
+
+ message = '/me is as well';
+ msg = $msg({
+ from: 'lounge@montague.lit/Romeo Montague',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(message).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).pop().textContent.trim() === 'is as well');
+ expect(sizzle('.chat-msg__author:last', view).pop().textContent.includes('**Romeo Montague')).toBeTruthy();
+
+ // Check rendering of a mention inside a me message
+ const msg_text = "/me mentions romeo";
+ msg = $msg({
+ from: 'lounge@montague.lit/gibson',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(msg_text).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'13', 'end':'19', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree;
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ await u.waitUntil(() => sizzle('.chat-msg__text:last', view).pop().innerHTML.replace(/<!-.*?->/g, '') ===
+ 'mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span>');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js
new file mode 100644
index 0000000..fc0b9d1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js
@@ -0,0 +1,301 @@
+/*global mock, converse */
+const { $iq, Strophe, u } = converse.env;
+
+describe("A Groupchat", function () {
+
+ describe("upon being entered", function () {
+
+ it("will fetch the member list if muc_fetch_members is true",
+ mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) {
+
+ const { api } = _converse;
+ let sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ let view = _converse.chatboxviews.get(muc_jid);
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3);
+
+ // Check in reverse order that we requested all three lists
+ const owner_iq = sent_IQs.pop();
+ expect(Strophe.serialize(owner_iq)).toBe(
+ `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+ `</iq>`);
+
+ const admin_iq = sent_IQs.pop();
+ expect(Strophe.serialize(admin_iq)).toBe(
+ `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+ `</iq>`);
+
+ const member_iq = sent_IQs.pop();
+ expect(Strophe.serialize(member_iq)).toBe(
+ `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+ `</iq>`);
+ view.close();
+
+ _converse.connection.IQ_stanzas = [];
+ sent_IQs = _converse.connection.IQ_stanzas;
+ api.settings.set('muc_fetch_members', false);
+ await mock.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
+ view = _converse.chatboxviews.get('orchard@montague.lit');
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0);
+ await view.close();
+
+ _converse.connection.IQ_stanzas = [];
+ sent_IQs = _converse.connection.IQ_stanzas;
+ api.settings.set('muc_fetch_members', ['admin']);
+ await mock.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo');
+ view = _converse.chatboxviews.get('courtyard@montague.lit');
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="admin"]')).length).toBe(1);
+ view.close();
+
+ _converse.connection.IQ_stanzas = [];
+ sent_IQs = _converse.connection.IQ_stanzas;
+ api.settings.set('muc_fetch_members', ['owner']);
+ await mock.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo');
+ view = _converse.chatboxviews.get('garden@montague.lit');
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1);
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="owner"]')).length).toBe(1);
+ view.close();
+ }));
+
+ it("will not fetch the member list if the user is not affiliated",
+ mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
+ // Join MUC without an affiliation
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], [], true, {}, 'none', 'participant');
+ await u.waitUntil(() => model.occupants.fetchMembers.calls.count());
+ expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0);
+ }));
+
+ describe("when fetching the member lists", function () {
+
+ it("gracefully handles being forbidden from fetching the lists for certain affiliations",
+ mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'lounge@montague.lit';
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_hidden',
+ 'muc_membersonly',
+ 'muc_passwordprotected',
+ Strophe.NS.MAM,
+ Strophe.NS.SID
+ ];
+ const nick = 'romeo';
+ await _converse.api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid, features);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+ mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+
+ // Check in reverse order that we requested all three lists
+ const owner_iq = sent_IQs.pop();
+ expect(Strophe.serialize(owner_iq)).toBe(
+ `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+ `</iq>`);
+ const admin_iq = sent_IQs.pop();
+ expect(Strophe.serialize(admin_iq)).toBe(
+ `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+ `</iq>`);
+ const member_iq = sent_IQs.pop();
+ expect(Strophe.serialize(member_iq)).toBe(
+ `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+ `</iq>`);
+
+ // It might be that the user is not allowed to fetch certain lists.
+ let err_stanza = u.toStanza(
+ `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}">
+ <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(err_stanza));
+
+ err_stanza = u.toStanza(
+ `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}">
+ <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(err_stanza));
+
+ // Now the service sends the member lists to the user
+ const member_list_stanza = $iq({
+ 'from': muc_jid,
+ 'id': member_iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/orchard',
+ 'type': 'result'
+ }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+ .c('item', {
+ 'affiliation': 'member',
+ 'jid': 'hag66@shakespeare.lit',
+ 'nick': 'thirdwitch',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(member_list_stanza));
+
+ await u.waitUntil(() => view.model.occupants.length > 1);
+ expect(view.model.occupants.length).toBe(2);
+ // The existing owner occupant should not have their
+ // affiliation removed due to the owner list
+ // not being returned (forbidden err).
+ expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner');
+ expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member');
+ }));
+ });
+ });
+});
+
+describe("Someone being invited to a groupchat", function () {
+
+ it("will first be added to the member list if the groupchat is members only",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ const nick = 'romeo';
+ const room_creation_promise = _converse.api.rooms.open(muc_jid, {nick});
+
+ // Check that the groupchat queried for the features.
+ let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ // State that the chat is members-only via the features IQ
+ const view = _converse.chatboxviews.get(muc_jid);
+ const features_stanza = $iq({
+ from: 'coven@chat.shakespeare.lit',
+ 'id': stanza.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'conference',
+ 'name': 'A Dark Cave',
+ 'type': 'text'
+ }).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ .c('feature', {'var': 'muc_membersonly'}).up();
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
+ expect(view.model.features.get('membersonly')).toBeTruthy();
+
+ await room_creation_promise;
+ await mock.createContacts(_converse, 'current');
+
+ let sent_stanza, sent_id;
+ spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+ if (stanza.nodeName === 'message') {
+ sent_id = stanza.getAttribute('id');
+ sent_stanza = stanza;
+ }
+ });
+ const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const reason = "Please join this groupchat";
+ view.model.directInvite(invitee_jid, reason);
+
+ // Check in reverse order that we requested all three lists
+ const owner_iq = sent_IQs.pop();
+ expect(Strophe.serialize(owner_iq)).toBe(
+ `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+ `</iq>`);
+
+ const admin_iq = sent_IQs.pop();
+ expect(Strophe.serialize(admin_iq)).toBe(
+ `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+ `</iq>`);
+
+ const member_iq = sent_IQs.pop();
+ expect(Strophe.serialize(member_iq)).toBe(
+ `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+ `</iq>`);
+
+ // Now the service sends the member lists to the user
+ const member_list_stanza = $iq({
+ 'from': 'coven@chat.shakespeare.lit',
+ 'id': member_iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/orchard',
+ 'type': 'result'
+ }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+ .c('item', {
+ 'affiliation': 'member',
+ 'jid': 'hag66@shakespeare.lit',
+ 'nick': 'thirdwitch',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(member_list_stanza));
+
+ const admin_list_stanza = $iq({
+ 'from': 'coven@chat.shakespeare.lit',
+ 'id': admin_iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/orchard',
+ 'type': 'result'
+ }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+ .c('item', {
+ 'affiliation': 'admin',
+ 'jid': 'wiccarocks@shakespeare.lit',
+ 'nick': 'secondwitch'
+ });
+ _converse.connection._dataRecv(mock.createRequest(admin_list_stanza));
+
+ const owner_list_stanza = $iq({
+ 'from': 'coven@chat.shakespeare.lit',
+ 'id': owner_iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/orchard',
+ 'type': 'result'
+ }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'crone1@shakespeare.lit',
+ });
+ _converse.connection._dataRecv(mock.createRequest(owner_list_stanza));
+
+ // Converse puts the user on the member list
+ stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop());
+ expect(stanza.outerHTML,
+ `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="member" jid="${invitee_jid}">`+
+ `<reason>Please join this groupchat</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ const result = $iq({
+ 'from': 'coven@chat.shakespeare.lit',
+ 'id': stanza.getAttribute('id'),
+ 'to': 'romeo@montague.lit/orchard',
+ 'type': 'result'
+ });
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
+
+ // Finally check that the user gets invited.
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<message from="romeo@montague.lit/orchard" id="${sent_id}" to="${invitee_jid}" xmlns="jabber:client">`+
+ `<x jid="coven@chat.shakespeare.lit" reason="Please join this groupchat" xmlns="jabber:x:conference"/>`+
+ `</message>`
+ );
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js
new file mode 100644
index 0000000..72deb3f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js
@@ -0,0 +1,548 @@
+/*global mock, converse */
+
+const { Strophe, $msg, $pres, sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+describe("An incoming groupchat message", function () {
+
+ it("is specially marked when you are mentioned in it",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+ const message = 'romeo: Your attention is required';
+ const nick = mock.chatroom_names[0],
+ msg = $msg({
+ from: 'lounge@montague.lit/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(message).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy();
+ }));
+
+
+ it("highlights all users mentioned via XEP-0372 references",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ }))
+ );
+ });
+ let msg = $msg({
+ from: 'lounge@montague.lit/gibson',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+
+ '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+
+ '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?');
+ let message = view.querySelector('.chat-msg__text');
+ expect(message.classList.length).toEqual(1);
+
+ msg = $msg({
+ from: 'lounge@montague.lit/sw0rdf1sh',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('@gibson').up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'1', 'end':'7', 'type':'mention', 'uri':'xmpp:gibson@montague.lit'}).nodeTree;
+ await view.model.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+ message = sizzle('converse-chat-message:last .chat-msg__text', view).pop();
+ expect(message.classList.length).toEqual(1);
+ expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('@<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span>');
+ }));
+
+ it("properly renders mentions that contain the pipe character",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'romeo@montague.lit/resource',
+ 'from': `lounge@montague.lit/ThUnD3r|Gr33n`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ }))
+ );
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello @ThUnD3r|Gr33n'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>hello ThUnD3r|Gr33n</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+
+ const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>');
+ }));
+
+ it("highlights all users mentioned via XEP-0372 references in a quoted message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ }))
+ );
+ });
+ const msg = $msg({
+ from: 'lounge@montague.lit/gibson',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
+
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+
+ '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+
+ '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?</blockquote>');
+ const message = view.querySelector('.chat-msg__text');
+ expect(message.classList.length).toEqual(1);
+ }));
+});
+
+
+describe("A sent groupchat message", function () {
+
+ describe("in which someone is mentioned", function () {
+
+ it("gets parsed for mentions which get turned into references",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+
+ // Making the MUC non-anonymous so that real JIDs are included
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ Strophe.NS.SID,
+ Strophe.NS.MAM,
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_open',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve', 'robot'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ // Also check that nicks from received messages, (but for which we don't have occupant objects) can be mentioned.
+ const stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${muc_jid}/gh0st"
+ to="${_converse.connection.bare_jid}"
+ type="groupchat">
+ <body>Boo!</body>
+ </message>`);
+ await view.model.handleMessageStanza(stanza);
+
+ // Run a few unit tests for the parseTextForReferences method
+ let [text, references] = view.model.parseTextForReferences('yo @robot')
+ expect(text).toBe('yo robot');
+ expect(references)
+ .toEqual([{"begin":3,"end":8,"value":"robot","type":"mention","uri":"xmpp:robot@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences('@@gh0st')
+ expect(text).toBe('@gh0st');
+ expect(references.length).toBe(1);
+ expect(references)
+ .toEqual([{"begin":1,"end":6,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]);
+
+ [text, references] = view.model.parseTextForReferences('hello z3r0')
+ expect(references.length).toBe(0);
+ expect(text).toBe('hello z3r0');
+
+ [text, references] = view.model.parseTextForReferences('hello @z3r0')
+ expect(references.length).toBe(1);
+ expect(text).toBe('hello z3r0');
+ expect(references)
+ .toEqual([{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?')
+ expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?');
+ expect(references)
+ .toEqual([{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"},
+ {"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"},
+ {"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences('yo @gib')
+ expect(text).toBe('yo @gib');
+ expect(references.length).toBe(0);
+
+ [text, references] = view.model.parseTextForReferences('yo @gibsonian')
+ expect(text).toBe('yo @gibsonian');
+ expect(references.length).toBe(0);
+
+ [text, references] = view.model.parseTextForReferences('yo @GiBsOn')
+ expect(text).toBe('yo gibson');
+ expect(references.length).toBe(1);
+
+ [text, references] = view.model.parseTextForReferences('@gibson')
+ expect(text).toBe('gibson');
+ expect(references.length).toBe(1);
+ expect(references)
+ .toEqual([{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?')
+ expect(text).toBe('hi Link Mauve how are you?');
+ expect(references.length).toBe(1);
+ expect(references)
+ .toEqual([{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences('https://example.org/@gibson')
+ expect(text).toBe('https://example.org/@gibson');
+ expect(references.length).toBe(0);
+ expect(references).toEqual([]);
+
+ [text, references] = view.model.parseTextForReferences('mail@gibson.com')
+ expect(text).toBe('mail@gibson.com');
+ expect(references.length).toBe(0);
+ expect(references)
+ .toEqual([]);
+
+ [text, references] = view.model.parseTextForReferences(
+ "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html");
+ expect(text).toBe("Welcome gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html");
+ expect(references.length).toBe(1);
+ expect(references).toEqual([{"begin":8,"end":14,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]);
+
+ [text, references] = view.model.parseTextForReferences(
+ 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
+ expect(text).toBe(
+ 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr');
+ expect(references.length).toBe(0);
+ expect(references)
+ .toEqual([]);
+
+ [text, references] = view.model.parseTextForReferences('@gh0st where are you?')
+ expect(text).toBe('gh0st where are you?');
+ expect(references.length).toBe(1);
+ expect(references)
+ .toEqual([{"begin":0,"end":5,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]);
+ }));
+
+ it("gets parsed for mentions as indicated with an @ preceded by a space or at the start of the text",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['NotAnAdress', 'darnuria'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+
+ // Test that we don't match @nick in email adresses.
+ let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu');
+ expect(references.length).toBe(0);
+ expect(text).toBe('contact contact@NotAnAdress.eu');
+
+ // Test that we don't match @nick in url
+ [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria');
+ expect(references.length).toBe(0);
+ expect(text).toBe('nice website https://darnuria.eu/@darnuria');
+ }));
+
+ it("properly encodes the URIs in sent out references",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+ const view = _converse.chatboxviews.get(muc_jid);
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/Link Mauve`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'role': 'participant'
+ })));
+ await u.waitUntil(() => view.model.occupants.length === 2);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello @Link Mauve'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>hello Link Mauve</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ }));
+
+ it("can get corrected and given new references",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+
+ // Making the MUC non-anonymous so that real JIDs are included
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ Strophe.NS.SID,
+ Strophe.NS.MAM,
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_open',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+ await u.waitUntil(() => view.model.occupants.length === 5);
+
+ const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+ textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+ await u.waitUntil(() =>
+ view.querySelector(last_msg_sel).innerHTML.replace(/<!-.*?->/g, '') ===
+ 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+
+ '<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span> '+
+ '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?'
+ );
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+
+ const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action'));
+ action.style.opacity = 1;
+ action.click();
+
+ expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+
+ textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
+ message_form.onKeyDown(enter_event);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
+ 'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
+
+ const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop();
+ expect(Strophe.serialize(correction))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+ `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ }));
+
+ it("includes a XEP-0372 references to that person",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({
+ 'to': 'tom@montague.lit/resource',
+ 'from': `lounge@montague.lit/${nick}`
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${nick}@montague.lit/resource`,
+ 'role': 'participant'
+ })));
+ });
+ await u.waitUntil(() => view.model.occupants.length === 5);
+
+ spyOn(_converse.connection, 'send');
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ const msg = _converse.connection.send.calls.all()[1].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="lounge@montague.lit" type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+
+ `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ }));
+ });
+
+ it("highlights all users mentioned via XEP-0372 references in a quoted message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const members = [{'jid': 'gibson@gibson.net', 'nick': 'gibson', 'affiliation': 'member'}];
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html";
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual(
+ `Welcome <span class="mention" data-uri="xmpp:${muc_jid}/gibson">gibson</span> <span title=":poop:">💩</span> `+
+ `We have a guide on how to do that here: `+
+ `<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/index.html">https://conversejs.org/docs/html/index.html</a>`);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js
new file mode 100644
index 0000000..cb638f1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js
@@ -0,0 +1,247 @@
+/*global mock, converse */
+
+const { u, Strophe } = converse.env;
+
+describe("A XEP-0316 MEP notification", function () {
+
+ it("is rendered as an info message",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ let msg = 'An anonymous user has saluted romeo';
+ let reason = 'Thank you for helping me yesterday';
+ let message = u.toStanza(`
+ <message from='${muc_jid}'
+ to='${_converse.jid}'
+ type='headline'
+ id='zns61f38'>
+ <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ <items node='urn:ietf:params:xml:ns:conference-info'>
+ <item id='ehs51f40'>
+ <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+ <activity xmlns='http://jabber.org/protocol/activity'>
+ <other/>
+ <text id="activity-text" xml:lang="en">${msg}</text>
+ <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+ <reason id="activity-reason">${reason}</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ </message>`);
+
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
+ expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
+ expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
+
+ // Check that duplicates aren't created
+ _converse.connection._dataRecv(mock.createRequest(message));
+ let promise = u.getOpenPromise();
+ setTimeout(() => {
+ expect(view.querySelectorAll('.chat-info').length).toBe(1);
+ promise.resolve();
+ }, 250);
+ await promise;
+
+ // Also check a MEP message of type "groupchat"
+ msg = 'An anonymous user has poked romeo';
+ reason = 'Can you please help me with something else?';
+ message = u.toStanza(`
+ <message from='${muc_jid}'
+ to='${_converse.jid}'
+ type='groupchat'
+ id='zns61f39'>
+ <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ <items node='urn:ietf:params:xml:ns:conference-info'>
+ <item id='ehs51f40'>
+ <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+ <activity xmlns='http://jabber.org/protocol/activity'>
+ <other/>
+ <text id="activity-text" xml:lang="en">${msg}</text>
+ <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="28" end="33" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+ <reason id="activity-reason">${reason}</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ </message>`);
+
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+ expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
+ expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason);
+
+ // Check that duplicates aren't created
+ _converse.connection._dataRecv(mock.createRequest(message));
+ promise = u.getOpenPromise();
+ setTimeout(() => {
+ expect(view.querySelectorAll('.chat-info').length).toBe(2);
+ promise.resolve();
+ }, 250);
+ return promise;
+ }));
+
+ it("can trigger a notification if sent to a hidden MUC",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ // const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ // spyOn(window, 'Notification').and.returnValue(stub);
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, {'hidden': true});
+ const msg = 'An anonymous user has saluted romeo';
+ const reason = 'Thank you for helping me yesterday';
+ const message = u.toStanza(`
+ <message from='${muc_jid}'
+ to='${_converse.jid}'
+ type='headline'
+ id='zns61f38'>
+ <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ <items node='urn:ietf:params:xml:ns:conference-info'>
+ <item id='ehs51f40'>
+ <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+ <activity xmlns='http://jabber.org/protocol/activity'>
+ <other/>
+ <text id="activity-text" xml:lang="en">${msg}</text>
+ <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+ <reason id="activity-reason">${reason}</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => model.messages.length === 1);
+ // expect(window.Notification.calls.count()).toBe(1);
+
+ model.set('hidden', false);
+
+ const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000);
+ expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
+ expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
+ }));
+
+ it("renders URLs as links", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true);
+ const msg = 'An anonymous user has waved at romeo';
+ const reason = 'Check out https://conversejs.org';
+ const message = u.toStanza(`
+ <message from='${muc_jid}'
+ to='${_converse.jid}'
+ type='headline'
+ id='zns61f38'>
+ <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ <items node='urn:ietf:params:xml:ns:conference-info'>
+ <item id='ehs51f40'>
+ <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+ <activity xmlns='http://jabber.org/protocol/activity'>
+ <other/>
+ <text id="activity-text" xml:lang="en">${msg}</text>
+ <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="31" end="37" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+ <reason id="activity-reason">${reason}</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => model.messages.length === 1);
+
+ const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000);
+ expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
+ expect(view.querySelector('.reason converse-rich-text').innerHTML.replace(/<!-.*?->/g, '').trim()).toBe(
+ 'Check out <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>');
+ }));
+
+ it("can be retracted by a moderator",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const msg = 'An anonymous user has saluted romeo';
+ const reason = 'Thank you for helping me yesterday';
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message from='${muc_jid}'
+ to='${_converse.jid}'
+ type='headline'
+ id='zns61f38'>
+ <event xmlns='http://jabber.org/protocol/pubsub#event'>
+ <items node='urn:ietf:params:xml:ns:conference-info'>
+ <item id='ehs51f40'>
+ <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+ <activity xmlns='http://jabber.org/protocol/activity'>
+ <other/>
+ <text id="activity-text" xml:lang="en">${msg}</text>
+ <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+ <reason id="activity-reason">${reason}</reason>
+ </activity>
+ </conference-info>
+ </item>
+ </items>
+ </event>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>`
+ )));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
+ expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
+ expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
+ expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBe(1);
+ const action = view.querySelector('converse-message-actions converse-dropdown .chat-msg__action');
+ expect(action.textContent.trim()).toBe('Retract');
+ action.click();
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+ const message = view.model.messages.at(0);
+ const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
+ `<moderate xmlns="urn:xmpp:message-moderate:0">`+
+ `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+ `<reason></reason>`+
+ `</moderate>`+
+ `</apply-to>`+
+ `</iq>`);
+
+ // The server responds with a retraction message
+ const retraction = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/${nick}">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ <reason></reason>
+ </moderated>
+ </apply-to>
+ </message>`);
+ await view.model.handleMessageStanza(retraction);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe('');
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message div');
+ expect(msg_el.textContent).toBe(`${nick} has removed this message`);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js
new file mode 100644
index 0000000..574755f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js
@@ -0,0 +1,481 @@
+/*global mock, converse, _ */
+
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const sizzle = converse.env.sizzle;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+
+
+async function openModtools (_converse, view) {
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/modtools';
+ const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter);
+ const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ return modal;
+}
+
+describe("The groupchat moderator tool", function () {
+
+ it("allows you to set affiliations and roles",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+
+ let members = [
+ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+ {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.occupants.length === 5), 1000);
+
+ const modal = await openModtools(_converse, view);
+ const tab = modal.querySelector('#affiliations-tab');
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+ tab.click();
+ let select = modal.querySelector('.select-affiliation');
+ expect(select.value).toBe('owner');
+ select.value = 'admin';
+ let button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length);
+ let user_els = modal.querySelectorAll('.list-group--users > li');
+ expect(user_els.length).toBe(1);
+ expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin');
+
+ _converse.connection.IQ_stanzas = [];
+ select.value = 'owner';
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2);
+ user_els = modal.querySelectorAll('.list-group--users > li');
+ expect(user_els.length).toBe(2);
+ expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+ expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit');
+ expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch');
+ expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+ const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form');
+ const component = user_els[1].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form');
+ expect(u.hasClass('hidden', component)).toBeTruthy();
+ toggle.click();
+ expect(u.hasClass('hidden', component)).toBeFalsy();
+
+ const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
+ select = form.querySelector('.select-affiliation');
+ expect(select.value).toBe('owner');
+ select.value = 'admin';
+ const input = form.querySelector('input[name="reason"]');
+ input.value = "You're an admin now";
+ const submit = form.querySelector('.btn-primary');
+ submit.click();
+
+ spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
+ const sent_IQ = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="admin" jid="crone1@shakespeare.lit">`+
+ `<reason>You&apos;re an admin now</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ _converse.connection.IQ_stanzas = [];
+ const stanza = $iq({
+ 'type': 'result',
+ 'id': sent_IQ.getAttribute('id'),
+ 'from': view.model.get('jid'),
+ 'to': _converse.connection.jid
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
+
+ members = [
+ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+ {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+ ];
+ await mock.returnMemberLists(_converse, muc_jid, members);
+ await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1);
+ const alert = modal.querySelector('.alert-primary');
+ expect(alert.textContent.trim()).toBe('Affiliation changed');
+
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1);
+ user_els = modal.querySelectorAll('.list-group--users > li');
+ expect(user_els.length).toBe(1);
+ expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
+ expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
+
+ modal.querySelector('#roles-tab').click();
+ select = modal.querySelector('.select-role');
+ await u.waitUntil(() => u.isVisible(select));
+
+ expect(select.value).toBe('moderator');
+ button = modal.querySelector('.btn-primary[name="users_with_role"]');
+ button.click();
+
+ const roles_panel = modal.querySelector('#roles-tabpanel');
+ await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1);
+ select.value = 'participant';
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li')[0]?.textContent.trim() === 'No users with that role found.');
+
+ }));
+
+ it("allows you to filter affiliation search results",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const members = [
+ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'member'},
+ {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'member'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'member'},
+ {'jid': 'juliet@capulet.lit', 'nick': 'juliet', 'affiliation': 'member'},
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.occupants.length === 6), 1000);
+
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+ const modal = await openModtools(_converse, view);
+ const select = modal.querySelector('.select-affiliation');
+ expect(select.value).toBe('owner');
+ select.value = 'member';
+ const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6);
+
+ const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+ expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch');
+
+ const filter = modal.querySelector('[name="filter"]');
+ expect(filter).not.toBe(null);
+
+ filter.value = 'romeo';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
+
+ filter.value = 'r';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 3));
+
+ filter.value = 'gower';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
+
+ filter.value = 'RoMeO';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
+
+ }));
+
+ it("allows you to filter role search results",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
+ const view = _converse.chatboxviews.get(muc_jid);
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `nomorenicks@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/newb`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `newb@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/some1`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `some1@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/oldhag`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `oldhag@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/crone`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `crone@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ _converse.connection._dataRecv(mock.createRequest(
+ $pres({to: _converse.jid, from: `${muc_jid}/tux`})
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `tux@montague.lit`,
+ 'role': 'participant'
+ })
+ ));
+ await u.waitUntil(() => (view.model.occupants.length === 7), 1000);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/modtools';
+ const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter);
+
+ const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ const tab = modal.querySelector('#roles-tab');
+ tab.click();
+
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+
+ const select = modal.querySelector('.select-role');
+ expect(select.value).toBe('moderator');
+ select.value = 'participant';
+
+ const button = modal.querySelector('.btn-primary[name="users_with_role"]');
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_role);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6);
+
+ const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+ expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux');
+
+ const filter = modal.querySelector('[name="filter"]');
+ expect(filter).not.toBe(null);
+
+ filter.value = 'tux';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
+
+ filter.value = 'r';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 2));
+
+ filter.value = 'crone';
+ u.triggerEvent(filter, "keyup", "KeyboardEvent");
+ await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
+ }));
+
+ it("shows an error message if a particular affiliation list may not be retrieved",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const members = [
+ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'},
+ {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.occupants.length === 5));
+ const modal = await openModtools(_converse, view);
+ const tab = modal.querySelector('#affiliations-tab');
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ tab.click();
+ const select = modal.querySelector('.select-affiliation');
+ select.value = 'outcast';
+ const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
+ button.click();
+
+ const iq_query = await u.waitUntil(() => _.filter(
+ IQ_stanzas,
+ s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="outcast"]`, s).length
+ ).pop());
+
+ const error = u.toStanza(
+ `<iq from="${muc_jid}"
+ id="${iq_query.getAttribute('id')}"
+ type="error"
+ to="${_converse.jid}">
+
+ <error type="auth">
+ <forbidden xmlns="${Strophe.NS.STANZAS}"/>
+ </error>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(error));
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+
+ const alert = await u.waitUntil(() => modal.querySelector('.alert'));
+ expect(alert.textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
+
+ const user_els = modal.querySelectorAll('.list-group--users > li');
+ expect(user_els.length).toBe(1);
+ expect(user_els[0].textContent.trim()).toBe('No users with that affiliation found.');
+ }));
+
+ it("shows an error message if a particular affiliation may not be set",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const members = [
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'},
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.occupants.length === 2));
+ const modal = await openModtools(_converse, view);
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+
+ const tab = modal.querySelector('#affiliations-tab');
+ tab.click();
+ const select = modal.querySelector('.select-affiliation');
+ select.value = 'member';
+ const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
+ button.click();
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1);
+
+ const user_els = modal.querySelectorAll('.list-group--users > li');
+ const toggle = user_els[0].querySelector('.list-group-item:nth-child(3n) .toggle-form');
+ const component = user_els[0].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form');
+ expect(u.hasClass('hidden', component)).toBeTruthy();
+ toggle.click();
+ expect(u.hasClass('hidden', component)).toBeFalsy();
+
+ const form = user_els[0].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
+ const change_affiliation_dropdown = form.querySelector('.select-affiliation');
+ expect(change_affiliation_dropdown.value).toBe('member');
+ change_affiliation_dropdown.value = 'admin';
+ const input = form.querySelector('input[name="reason"]');
+ input.value = "You're an admin now";
+ const submit = form.querySelector('.btn-primary');
+ submit.click();
+
+ const sent_IQ = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="admin" jid="gower@shakespeare.lit">`+
+ `<reason>You&apos;re an admin now</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ const error = u.toStanza(
+ `<iq from="${muc_jid}"
+ id="${sent_IQ.getAttribute('id')}"
+ type="error"
+ to="${_converse.jid}">
+
+ <error type="cancel">
+ <not-allowed xmlns="${Strophe.NS.STANZAS}"/>
+ </error>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(error));
+
+ }));
+
+
+ it("doesn't allow admins to make more admins",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const members = [
+ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'},
+ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'},
+ {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'admin'},
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.occupants.length === 3));
+ const modal = await openModtools(_converse, view);
+ const tab = modal.querySelector('#affiliations-tab');
+ // Clear so that we don't match older stanzas
+ _converse.connection.IQ_stanzas = [];
+ tab.click();
+ const show_affiliation_dropdown = modal.querySelector('.select-affiliation');
+ show_affiliation_dropdown.value = 'member';
+ const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
+ button.click();
+
+ await u.waitUntil(() => !modal.loading_users_with_affiliation);
+ await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2);
+
+ const user_els = modal.querySelectorAll('.list-group--users > li');
+ let change_affiliation_dropdown = user_els[0].querySelector('.select-affiliation');
+ expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']);
+
+ change_affiliation_dropdown = user_els[1].querySelector('.select-affiliation');
+ expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']);
+ }));
+
+ it("lets the assignable affiliations and roles be configured via modtools_disable_assign",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/modtools';
+ const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter);
+
+ await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
+ const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+
+ expect(_converse.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);
+
+ _converse.api.settings.set('modtools_disable_assign', ['owner']);
+ expect(_converse.getAssignableAffiliations(occupant)).toEqual(['admin', 'member', 'outcast', 'none']);
+
+ _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin']);
+ expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'outcast', 'none']);
+
+ _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin', 'outcast']);
+ expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'none']);
+
+ expect(_converse.getAssignableRoles(occupant)).toEqual(['moderator', 'participant', 'visitor']);
+
+ _converse.api.settings.set('modtools_disable_assign', ['admin', 'moderator']);
+ expect(_converse.getAssignableRoles(occupant)).toEqual(['participant', 'visitor']);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js
new file mode 100644
index 0000000..b5ae67f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js
@@ -0,0 +1,124 @@
+/*global mock, converse */
+
+const { Promise, sizzle, u } = converse.env;
+
+describe('The "Groupchats" Add modal', function () {
+
+ it('can be opened from a link in the "Groupchats" section of the controlbox',
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ let label_name = modal.querySelector('label[for="chatroom"]');
+ expect(label_name.textContent.trim()).toBe('Groupchat address:');
+ const name_input = modal.querySelector('input[name="chatroom"]');
+ expect(name_input.placeholder).toBe('name@conference.example.org');
+
+ const label_nick = modal.querySelector('label[for="nickname"]');
+ expect(label_nick.textContent.trim()).toBe('Nickname:');
+ const nick_input = modal.querySelector('input[name="nickname"]');
+ expect(nick_input.value).toBe('');
+ nick_input.value = 'romeo';
+
+ expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+ modal.querySelector('input[name="chatroom"]').value = 'lounge@muc.montague.lit';
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.length);
+ await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+
+ roomspanel.model.set('muc_domain', 'muc.example.org');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ label_name = modal.querySelector('label[for="chatroom"]');
+ expect(label_name.textContent.trim()).toBe('Groupchat name:');
+ await u.waitUntil(() => modal.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org');
+ })
+ );
+
+ it("doesn't require the domain when muc_domain is set",
+ mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) {
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+ const label_name = modal.querySelector('label[for="chatroom"]');
+ expect(label_name.textContent.trim()).toBe('Groupchat name:');
+ let name_input = modal.querySelector('input[name="chatroom"]');
+ expect(name_input.placeholder).toBe('name@muc.example.org');
+ name_input.value = 'lounge';
+ let nick_input = modal.querySelector('input[name="nickname"]');
+ nick_input.value = 'max';
+
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.length);
+ await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+ expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
+
+ // However, you can still open MUCs with different domains
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ name_input = modal.querySelector('input[name="chatroom"]');
+ name_input.value = 'lounge@conference.example.org';
+ nick_input = modal.querySelector('input[name="nickname"]');
+ nick_input.value = 'max';
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
+ await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
+ expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@conference.example.org')).toBe(
+ true
+ );
+ })
+ );
+
+ it('only uses the muc_domain is locked_muc_domain is true',
+ mock.initConverse(
+ ['chatBoxesFetched'],
+ { 'muc_domain': 'muc.example.org', 'locked_muc_domain': true },
+ async function (_converse) {
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+ const label_name = modal.querySelector('label[for="chatroom"]');
+ expect(label_name.textContent.trim()).toBe('Groupchat name:');
+ let name_input = modal.querySelector('input[name="chatroom"]');
+ expect(name_input.placeholder).toBe('');
+ name_input.value = 'lounge';
+ let nick_input = modal.querySelector('input[name="nickname"]');
+ nick_input.value = 'max';
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.length);
+ await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
+ expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
+
+ // However, you can still open MUCs with different domains
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ name_input = modal.querySelector('input[name="chatroom"]');
+ name_input.value = 'lounge@conference';
+ nick_input = modal.querySelector('input[name="nickname"]');
+ nick_input.value = 'max';
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(
+ () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2
+ );
+ await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
+ expect(
+ _converse.chatboxes.models.map(m => m.get('id')).includes('lounge\\40conference@muc.example.org')
+ ).toBe(true);
+ }
+ )
+ );
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js
new file mode 100644
index 0000000..c3427d0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js
@@ -0,0 +1,262 @@
+/*global mock, converse */
+
+const Model = converse.env.Model;
+const { $pres, $iq, Strophe, sizzle, u } = converse.env;
+
+describe("Groupchats", function () {
+
+ describe("The \"rooms\" API", function () {
+
+ it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+
+ _converse.connection.IQ_stanzas = [];
+ await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
+
+ _converse.connection.IQ_stanzas = [];
+ await mock.openAndEnterChatRoom(_converse, 'news@montague.lit', 'romeo');
+
+ expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy();
+ expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy();
+ expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy();
+
+ _converse.chatboxviews.get('lounge@montague.lit').close();
+ await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit'));
+ expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy();
+ expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy();
+
+ _converse.chatboxviews.get('leisure@montague.lit').close();
+ await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit'));
+
+ _converse.chatboxviews.get('news@montague.lit').close();
+ await u.waitUntil(() => !_converse.chatboxviews.get('news@montague.lit'));
+
+ expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
+ expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
+ expect(_converse.chatboxviews.get('news@montague.lit')).toBeUndefined();
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo');
+ expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy();
+ expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy();
+
+ _converse.chatboxviews.get('leisure@montague.lit').close();
+ await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit'));
+
+ _converse.chatboxviews.get('lounge@montague.lit').close();
+ await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit'));
+
+ expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined();
+ expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined();
+ }));
+
+ it("has a method 'get' which returns a wrapped groupchat (if it exists)",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length, 300);
+ let muc_jid = 'chillout@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ let room = await _converse.api.rooms.get(muc_jid);
+ expect(room instanceof Object).toBeTruthy();
+
+ let chatroomview = _converse.chatboxviews.get(muc_jid);
+ expect(chatroomview.is_chatroom).toBeTruthy();
+
+ expect(u.isVisible(chatroomview)).toBeTruthy();
+ await chatroomview.close();
+
+ // Test with mixed case
+ muc_jid = 'Leisure@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ room = await _converse.api.rooms.get(muc_jid);
+ expect(room instanceof Object).toBeTruthy();
+ chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+ expect(u.isVisible(chatroomview)).toBeTruthy();
+
+ muc_jid = 'leisure@montague.lit';
+ room = await _converse.api.rooms.get(muc_jid);
+ expect(room instanceof Object).toBeTruthy();
+ chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+ expect(u.isVisible(chatroomview)).toBeTruthy();
+
+ muc_jid = 'leiSure@montague.lit';
+ room = await _converse.api.rooms.get(muc_jid);
+ expect(room instanceof Object).toBeTruthy();
+ chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase());
+ expect(u.isVisible(chatroomview)).toBeTruthy();
+ chatroomview.close();
+
+ // Non-existing room
+ muc_jid = 'chillout2@montague.lit';
+ room = await _converse.api.rooms.get(muc_jid);
+ expect(room).toBe(null);
+ }));
+
+ it("has a method 'open' which opens (optionally configures) and returns a wrapped chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ // Mock 'getDiscoInfo', otherwise the room won't be
+ // displayed as it waits first for the features to be returned
+ // (when it's a new room being created).
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+ let jid = 'lounge@montague.lit';
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length);
+
+ let room = await _converse.api.rooms.open(jid);
+ // Test on groupchat that's not yet open
+ expect(room instanceof Model).toBeTruthy();
+ let mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid));
+ expect(mucview.is_chatroom).toBeTruthy();
+ await u.waitUntil(() => u.isVisible(mucview));
+
+ // Test again, now that the room exists.
+ room = await _converse.api.rooms.open(jid);
+ expect(room instanceof Model).toBeTruthy();
+ mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid));
+ expect(mucview.is_chatroom).toBeTruthy();
+ expect(u.isVisible(mucview)).toBeTruthy();
+ await mucview.close();
+
+ // Test with mixed case in JID
+ jid = 'Leisure@montague.lit';
+ room = await _converse.api.rooms.open(jid);
+ expect(room instanceof Model).toBeTruthy();
+ mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
+ await u.waitUntil(() => u.isVisible(mucview));
+
+ jid = 'leisure@montague.lit';
+ room = await _converse.api.rooms.open(jid);
+ expect(room instanceof Model).toBeTruthy();
+ mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
+ await u.waitUntil(() => u.isVisible(mucview));
+
+ jid = 'leiSure@montague.lit';
+ room = await _converse.api.rooms.open(jid);
+ expect(room instanceof Model).toBeTruthy();
+ mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
+ await u.waitUntil(() => u.isVisible(mucview));
+ mucview.close();
+
+ api.settings.set('muc_instant_rooms', false);
+ // Test with configuration
+ room = await _converse.api.rooms.open('room@conference.example.org', {
+ 'nick': 'some1',
+ 'auto_configure': true,
+ 'roomconfig': {
+ 'getmemberlist': ['moderator', 'participant'],
+ 'changesubject': false,
+ 'membersonly': true,
+ 'persistentroom': true,
+ 'publicroom': true,
+ 'roomdesc': 'Welcome to this groupchat',
+ 'whois': 'anyone'
+ }
+ });
+ expect(room instanceof Model).toBeTruthy();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const selector = `iq[to="room@conference.example.org"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+ const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
+
+ // We pretend this is a new room, so no disco info is returned.
+ const features_stanza = $iq({
+ from: 'room@conference.example.org',
+ 'id': features_query.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/>
+ * <status code="110"/>
+ * <status code="201"/>
+ * </x>
+ * </presence>
+ */
+ const presence = $pres({
+ from:'room@conference.example.org/some1',
+ to:'romeo@montague.lit/pda'
+ })
+ .c('x', {xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ affiliation: 'owner',
+ jid: 'romeo@montague.lit/pda',
+ role: 'moderator'
+ }).up()
+ .c('status', {code:'110'}).up()
+ .c('status', {code:'201'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop());
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`);
+
+ const node = u.toStanza(`
+ <iq xmlns="jabber:client"
+ type="result"
+ to="romeo@montague.lit/pda"
+ from="room@conference.example.org" id="${iq.getAttribute('id')}">
+ <query xmlns="http://jabber.org/protocol/muc#owner">
+ <x xmlns="jabber:x:data" type="form">
+ <title>Configuration for room@conference.example.org</title>
+ <instructions>Complete and submit this form to configure the room.</instructions>
+ <field var="FORM_TYPE" type="hidden">
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field type="text-single" var="muc#roomconfig_roomname" label="Name">
+ <value>Room</value>
+ </field>
+ <field type="text-single" var="muc#roomconfig_roomdesc" label="Description"><value/></field>
+ <field type="boolean" var="muc#roomconfig_persistentroom" label="Make Room Persistent?"/>
+ <field type="boolean" var="muc#roomconfig_publicroom" label="Make Room Publicly Searchable?"><value>1</value></field>
+ <field type="boolean" var="muc#roomconfig_changesubject" label="Allow Occupants to Change Subject?"/>
+ <field type="list-single" var="muc#roomconfig_whois" label="Who May Discover Real JIDs?"><option label="Moderators Only">
+ <value>moderators</value></option><option label="Anyone"><value>anyone</value></option>
+ </field>
+ <field label="Roles and Affiliations that May Retrieve Member List"
+ type="list-multi"
+ var="muc#roomconfig_getmemberlist">
+ <value>moderator</value>
+ <value>participant</value>
+ <value>visitor</value>
+ </field>
+ <field type="text-private" var="muc#roomconfig_roomsecret" label="Password"><value/></field>
+ <field type="boolean" var="muc#roomconfig_moderatedroom" label="Make Room Moderated?"/>
+ <field type="boolean" var="muc#roomconfig_membersonly" label="Make Room Members-Only?"/>
+ <field type="text-single" var="muc#roomconfig_historylength" label="Maximum Number of History Messages Returned by Room">
+ <value>20</value></field>
+ </x>
+ </query>
+ </iq>`);
+
+ mucview = _converse.chatboxviews.get('room@conference.example.org');
+ spyOn(mucview.model, 'sendConfiguration').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(node));
+ await u.waitUntil(() => mucview.model.sendConfiguration.calls.count() === 1);
+
+ const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop();
+ expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent.trim()).toBe('Room');
+ expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent.trim()).toBe('Welcome to this groupchat');
+ expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent.trim()).toBe('1');
+ expect(sizzle('field[var="muc#roomconfig_getmemberlist"] value', sent_stanza).map(e => e.textContent.trim()).join(' ')).toBe('moderator participant');
+ expect(sizzle('field[var="muc#roomconfig_publicroom"] value ', sent_stanza).pop().textContent.trim()).toBe('1');
+ expect(sizzle('field[var="muc#roomconfig_changesubject"] value', sent_stanza).pop().textContent.trim()).toBe('0');
+ expect(sizzle('field[var="muc#roomconfig_whois"] value ', sent_stanza).pop().textContent.trim()).toBe('anyone');
+ expect(sizzle('field[var="muc#roomconfig_membersonly"] value', sent_stanza).pop().textContent.trim()).toBe('1');
+ expect(sizzle('field[var="muc#roomconfig_historylength"] value', sent_stanza).pop().textContent.trim()).toBe('20');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js
new file mode 100644
index 0000000..f43a88c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js
@@ -0,0 +1,141 @@
+/*global mock, converse */
+
+const { $iq, Strophe, Promise, sizzle, u } = converse.env;
+
+describe('The "Groupchats" List modal', function () {
+
+ it('can be opened from a link in the "Groupchats" section of the controlbox',
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-list-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-muc-list-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+ // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms
+ expect(modal.querySelectorAll('.available-chatrooms li').length).toBe(0);
+
+ const server_input = modal.querySelector('input[name="server"]');
+ expect(server_input.placeholder).toBe('conference.example.org');
+ server_input.value = 'chat.shakespeare.lit';
+ modal.querySelector('input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.length);
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() =>
+ IQ_stanzas.filter(s => sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"]`, s).length).pop()
+ );
+ const id = sent_stanza.getAttribute('id');
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${id}" ` +
+ `to="chat.shakespeare.lit" ` +
+ `type="get" ` +
+ `xmlns="jabber:client">` +
+ `<query xmlns="http://jabber.org/protocol/disco#items"/>` +
+ `</iq>`
+ );
+ const iq = $iq({
+ 'from': 'muc.montague.lit',
+ 'to': 'romeo@montague.lit/pda',
+ 'id': id,
+ 'type': 'result',
+ })
+ .c('query')
+ .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up()
+ .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up()
+ .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up()
+ .c('item', { jid: 'inverness@chat.shakespeare.lit', name: 'Macbeth&apos;s Castle' }).up()
+ .c('item', { jid: 'orchard@chat.shakespeare.lit', name: "Capulet's Orchard" }).up()
+ .c('item', { jid: 'friar@chat.shakespeare.lit', name: "Friar Laurence's cell" }).up()
+ .c('item', { jid: 'hall@chat.shakespeare.lit', name: "Hall in Capulet's house" }).up()
+ .c('item', { jid: 'chamber@chat.shakespeare.lit', name: "Juliet's chamber" }).up()
+ .c('item', { jid: 'public@chat.shakespeare.lit', name: 'A public place' }).up()
+ .c('item', { jid: 'street@chat.shakespeare.lit', name: 'A street' }).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(iq));
+
+ await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 11);
+ const rooms = modal.querySelectorAll('.available-chatrooms li');
+ expect(rooms[0].textContent.trim()).toBe('Groupchats found');
+ expect(rooms[1].textContent.trim()).toBe('A Lonely Heath');
+ expect(rooms[2].textContent.trim()).toBe('A Dark Cave');
+ expect(rooms[3].textContent.trim()).toBe('The Palace');
+ expect(rooms[4].textContent.trim()).toBe("Macbeth's Castle");
+ expect(rooms[5].textContent.trim()).toBe("Capulet's Orchard");
+ expect(rooms[6].textContent.trim()).toBe("Friar Laurence's cell");
+ expect(rooms[7].textContent.trim()).toBe("Hall in Capulet's house");
+ expect(rooms[8].textContent.trim()).toBe("Juliet's chamber");
+ expect(rooms[9].textContent.trim()).toBe('A public place');
+ expect(rooms[10].textContent.trim()).toBe('A street');
+
+ rooms[4].querySelector('.open-room').click();
+ await u.waitUntil(() => _converse.chatboxes.length > 1);
+ expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom
+ const view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
+ expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle");
+ })
+ );
+
+ it('is pre-filled with the muc_domain',
+ mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) {
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-list-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-muc-list-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const server_input = modal.querySelector('input[name="server"]');
+ expect(server_input.value).toBe('muc.example.org');
+ })
+ );
+
+ it("doesn't let you set the MUC domain if it's locked",
+ mock.initConverse(
+ ['chatBoxesFetched'],
+ { 'muc_domain': 'chat.shakespeare.lit', 'locked_muc_domain': true },
+ async function (_converse) {
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-list-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-muc-list-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
+
+ expect(modal.querySelector('input[name="server"]')).toBe(null);
+ expect(modal.querySelector('input[type="submit"]')).toBe(null);
+ await u.waitUntil(() => _converse.chatboxes.length);
+ const sent_stanza = await u.waitUntil(() =>
+ _converse.connection.sent_stanzas
+ .filter(s => sizzle(`query[xmlns="http://jabber.org/protocol/disco#items"]`, s).length)
+ .pop()
+ );
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" ` +
+ `to="chat.shakespeare.lit" type="get" xmlns="jabber:client">` +
+ `<query xmlns="http://jabber.org/protocol/disco#items"/>` +
+ `</iq>`
+ );
+ const iq = $iq({
+ from: 'muc.montague.lit',
+ to: 'romeo@montague.lit/pda',
+ id: sent_stanza.getAttribute('id'),
+ type: 'result',
+ })
+ .c('query')
+ .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up()
+ .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up()
+ .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up();
+ _converse.connection._dataRecv(mock.createRequest(iq));
+
+ await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 4);
+ const rooms = modal.querySelectorAll('.available-chatrooms li');
+ expect(rooms[0].textContent.trim()).toBe('Groupchats found');
+ expect(rooms[1].textContent.trim()).toBe('A Lonely Heath');
+ expect(rooms[2].textContent.trim()).toBe('A Dark Cave');
+ expect(rooms[3].textContent.trim()).toBe('The Palace');
+ }
+ )
+ );
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js
new file mode 100644
index 0000000..8fab48a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js
@@ -0,0 +1,86 @@
+/*global mock, converse */
+
+const { dayjs } = converse.env;
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("MUC Mention Notfications", function () {
+
+ it("may be received from a MUC in which the user is not currently present",
+ mock.initConverse([], {
+ 'allow_bookmarks': false, // Hack to get the rooms list to render
+ 'muc_subscribe_to_rai': true,
+ 'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ const { api } = _converse;
+
+ expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ await muc_creation_promise;
+
+ const model = _converse.chatboxes.get(muc_jid);
+ await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ expect(model.get('hidden')).toBe(true);
+ await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
+
+ const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
+
+ const base_time = new Date();
+ let message = u.toStanza(`
+ <message from="${muc_jid}">
+ <mentions xmlns='urn:xmpp:mmn:0'>
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(5, 'minutes').toISOString()}'/>
+ <message type='groupchat' id='${_converse.connection.getUniqueId()}'
+ to='${muc_jid}'
+ from='${muc_jid}/juliet'
+ xml:lang='en'>
+ <body>Romeo, wherefore art though Romeo</body>
+ <reference xmlns='urn:xmpp:reference:0'
+ type='mention'
+ begin='0'
+ uri='xmpp:${_converse.bare_jid}'
+ end='5'/>
+ </message>
+ </forwarded>
+ </mentions>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs'));
+ expect(room_el.querySelector('.msgs-indicator')?.textContent.trim()).toBe('1');
+
+ message = u.toStanza(`
+ <message from="${muc_jid}">
+ <mentions xmlns='urn:xmpp:mmn:0'>
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(4, 'minutes').toISOString()}'/>
+ <message type='groupchat' id='${_converse.connection.getUniqueId()}'
+ to='${muc_jid}'
+ from='${muc_jid}/juliet'
+ xml:lang='en'>
+ <body>Romeo, wherefore art though Romeo</body>
+ <reference xmlns='urn:xmpp:reference:0'
+ type='mention'
+ begin='0'
+ uri='xmpp:${_converse.bare_jid}'
+ end='5'/>
+ </message>
+ </forwarded>
+ </mentions>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(message));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+ await u.waitUntil(() => room_el.querySelector('.msgs-indicator')?.textContent.trim() === '2');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js
new file mode 100644
index 0000000..378cb59
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js
@@ -0,0 +1,354 @@
+/*global mock, converse */
+
+ const { Promise, Strophe, $msg, $pres, sizzle,u } = converse.env;
+ const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A Groupchat Message", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ describe("which is succeeded by an error message", function () {
+
+ it("will have the error displayed below it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello world'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter_event);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ const msg = view.model.messages.at(0);
+ const err_msg_text = "Message rejected because you're sending messages too quickly";
+ const error = u.toStanza(`
+ <message xmlns="jabber:client" id="${msg.get('msgid')}" from="${muc_jid}" to="${_converse.jid}" type="error">
+ <error type="wait">
+ <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_msg_text}</text>
+ </error>
+ <body>hello world</body>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(error));
+ expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(err_msg_text);
+ expect(view.model.messages.length).toBe(1);
+ const message = view.model.messages.at(0);
+ expect(message.get('received')).toBeUndefined();
+ expect(message.get('body')).toBe('hello world');
+ expect(message.get('error_text')).toBe(err_msg_text);
+ expect(message.get('editable')).toBe(false);
+ }));
+ });
+
+ it("can contain a chat state notification and will still be shown",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+ const message = 'romeo: Your attention is required';
+ const nick = mock.chatroom_names[0],
+ msg = $msg({
+ from: 'lounge@montague.lit/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(message)
+ .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
+ .tree();
+ await view.model.handleMessageStanza(msg);
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe(message);
+ }));
+
+ it("can not be expected to have a unique id attribute",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+ const id = u.getUniqueId();
+ let msg = $msg({
+ from: 'lounge@montague.lit/some1',
+ id: id,
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('First message').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+ msg = $msg({
+ from: 'lounge@montague.lit/some2',
+ id: id,
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Another message').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(view.model.messages.length).toBe(2);
+ }));
+
+ it("is ignored if it has the same stanza-id of an already received one",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'room@muc.example.com';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough();
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="room@muc.example.com/some1"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>Typical body text</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="room@muc.example.com"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length === 1);
+ await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1);
+ let result = await view.model.getStanzaIdQueryAttrs.calls.all()[0].returnValue;
+ expect(result instanceof Array).toBe(true);
+ expect(result[0] instanceof Object).toBe(true);
+ expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
+
+ stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="room@muc.example.com/some1"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>Typical body text</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="room@muc.example.com"/>
+ </message>`);
+ spyOn(view.model, 'updateMessage');
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ result = await view.model.getDuplicateMessage.calls.all()[0].returnValue;
+ expect(result instanceof _converse.Message).toBe(true);
+ expect(view.model.messages.length).toBe(1);
+ await u.waitUntil(() => view.model.updateMessage.calls.count());
+ }));
+
+ it("keeps track of the sender's role and affiliation",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ let msg = $msg({
+ from: 'lounge@montague.lit/romeo',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('I wrote this message!').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
+ expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar moderator owner');
+ let presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/romeo',
+ id: u.getUniqueId()
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'member',
+ jid: 'romeo@montague.lit/orchard',
+ role: 'participant'
+ }).up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'210'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.model.messages.length === 4);
+
+ msg = $msg({
+ from: 'lounge@montague.lit/romeo',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Another message!').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
+ expect(view.model.messages.last().occupant.get('role')).toBe('participant');
+ expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member');
+
+ presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/romeo',
+ id: u.getUniqueId()
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'owner',
+ jid: 'romeo@montague.lit/orchard',
+ role: 'moderator'
+ }).up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'210'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ view.model.sendMessage({'body': 'hello world'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
+
+ const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant);
+ expect(occupant.get('affiliation')).toBe('owner');
+ expect(occupant.get('role')).toBe('moderator');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(3);
+ await u.waitUntil(() => sizzle('.chat-msg', view).pop().classList.value.trim() === 'message chat-msg groupchat chat-msg--with-avatar moderator owner');
+
+ msg = $msg({
+ from: 'lounge@montague.lit/some1',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('Message from someone not in the MUC right now').tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
+
+ expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
+ expect(view.model.messages.last().occupant.get('jid')).toBe(undefined);
+
+ // Check that the occupant gets added/removed to the message as it
+ // gets removed or added.
+ presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/some1',
+ id: u.getUniqueId()
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.model.messages.last().occupant);
+ expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+ expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
+ expect(view.model.messages.last().occupant.get('jid')).toBe('some1@montague.lit');
+
+ presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ type: 'unavailable',
+ from:'lounge@montague.lit/some1',
+ id: u.getUniqueId()
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => !view.model.messages.last().occupant);
+ expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+ expect(view.model.messages.last().occupant).toBeUndefined();
+
+ presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/some1',
+ id: u.getUniqueId()
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.model.messages.last().occupant);
+ expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
+ expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
+ }));
+
+ it("will be shown as received upon MUC reflection",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
+
+ const msg_obj = view.model.messages.at(0);
+ const stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${msg_obj.get('from')}"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>${msg_obj.get('message')}</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="lounge@montague.lit"/>
+ <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+ <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+ </message>`);
+ await view.model.handleMessageStanza(stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+ expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+ expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+
+ const message = view.model.messages.at(0);
+ expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad');
+ expect(message.get('origin_id')).toBe(msg_obj.get('origin_id'));
+ expect(message.get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
+ }));
+
+ it("can cause a delivery receipt to be returned",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ const msg_obj = view.model.messages.at(0);
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${msg_obj.get('from')}"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>${msg_obj.get('message')}</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="lounge@montague.lit"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+ </message>`);
+ await view.model.handleMessageStanza(stanza);
+ await u.waitUntil(() => view.model.messages.last().get('received'));
+
+ stanza = u.toStanza(`
+ <message xml:lang="en" to="romeo@montague.lit/orchard"
+ from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+ <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js
new file mode 100644
index 0000000..3fe0b26
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js
@@ -0,0 +1,59 @@
+/*global mock, converse */
+
+const { $iq, Strophe, sizzle, u } = converse.env;
+
+describe("Chatrooms", function () {
+
+ describe("The /register commmand", function () {
+
+ it("allows you to register your nickname in a room",
+ mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
+ async function (_converse) {
+
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo')
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/register';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
+ ).pop());
+ expect(Strophe.serialize(stanza))
+ .toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
+ `type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:register"/></iq>`);
+ const result = $iq({
+ 'from': view.model.get('jid'),
+ 'id': stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('query', {'type': 'jabber:iq:register'})
+ .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
+ .c('field', {
+ 'label': 'Desired Nickname',
+ 'type': 'text-single',
+ 'var': 'muc#register_roomnick'
+ }).c('required');
+ _converse.connection._dataRecv(mock.createRequest(result));
+ stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+ ).pop());
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:register">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
+ `<field var="muc#register_roomnick"><value>romeo</value></field>`+
+ `</x>`+
+ `</query>`+
+ `</iq>`);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js
new file mode 100644
index 0000000..be3c9ec
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js
@@ -0,0 +1,3878 @@
+/*global mock, converse */
+
+const { $pres, $iq, $msg, Strophe, Promise, sizzle, u } = converse.env;
+
+describe("Groupchats", function () {
+
+ describe("An instant groupchat", function () {
+
+ it("will be created when muc_instant_rooms is set to true",
+ mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: '' } }, async function (_converse) {
+
+ let IQ_stanzas = _converse.connection.IQ_stanzas;
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'nicky';
+ await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+
+ const disco_selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+ const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(disco_selector)).pop());
+ // We pretend this is a new room, so no disco info is returned.
+ const features_stanza = $iq({
+ 'from': 'lounge@montague.lit',
+ 'id': stanza.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ spyOn(view.model, 'join').and.callThrough();
+ await mock.waitForReservedNick(_converse, muc_jid, '');
+ const input = await u.waitUntil(() => view.querySelector('input[name="nick"]'), 1000);
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED);
+ input.value = nick;
+ view.querySelector('input[type=submit]').click();
+ expect(view.model.join).toHaveBeenCalled();
+
+ _converse.connection.IQ_stanzas = [];
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+
+ // The user has just entered the room (because join was called)
+ // and receives their own presence from the server.
+ // See example 24:
+ // https://xmpp.org/extensions/xep-0045.html#enter-pres
+ //
+ /* <presence xmlns="jabber:client" to="jordie.langen@chat.example.org/converse.js-11659299" from="myroom@conference.chat.example.org/jc">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item jid="jordie.langen@chat.example.org/converse.js-11659299" affiliation="owner" role="moderator"/>
+ * <status code="110"/>
+ * <status code="201"/>
+ * </x>
+ * </presence>
+ */
+ const presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/nicky',
+ id:'5025e055-036c-4bc5-a227-706e7e352053'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'owner',
+ jid: 'romeo@montague.lit/orchard',
+ role: 'moderator'
+ }).up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'201'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED);
+ await mock.returnMemberLists(_converse, muc_jid);
+ const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length);
+ expect(num_info_msgs).toBe(1);
+
+ const info_texts = Array.from(view.querySelectorAll('.chat-content .chat-info')).map(e => e.textContent.trim());
+ expect(info_texts[0]).toBe('A new groupchat has been created');
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual("nicky has entered the groupchat");
+
+ // An instant room is created by saving the default configuratoin.
+ //
+ /* <iq to="myroom@conference.chat.example.org" type="set" xmlns="jabber:client" id="5025e055-036c-4bc5-a227-706e7e352053:sendIQ">
+ * <query xmlns="http://jabber.org/protocol/muc#owner"><x xmlns="jabber:x:data" type="submit"/></query>
+ * </iq>
+ */
+ const selector = `query[xmlns="${Strophe.NS.MUC_OWNER}"]`;
+ IQ_stanzas = _converse.connection.IQ_stanzas;
+ const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(selector)).pop());
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#owner"><x type="submit" xmlns="jabber:x:data"/>`+
+ `</query></iq>`);
+ }));
+ });
+
+ describe("A Groupchat", function () {
+
+ it("will be visible when opened as the first chat in fullscreen-view",
+ mock.initConverse(['discoInitialized'],
+ { 'view_mode': 'fullscreen', 'auto_join_rooms': ['orchard@chat.shakespeare.lit']},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await api.waitUntil('roomsAutoJoined');
+ const room = await api.rooms.get('orchard@chat.shakespeare.lit');
+ expect(room.get('hidden')).toBe(false);
+ }));
+
+ it("Can be configured to show cached messages before being joined",
+ mock.initConverse(['discoInitialized'],
+ {
+ muc_show_logs_before_join: true,
+ archived_messages_page_size: 2,
+ muc_nickname_from_jid: false,
+ muc_clear_messages_on_leave: false,
+ vcard: { nickname: '' },
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const muc_jid = 'orchard@chat.shakespeare.lit';
+ const nick = 'romeo';
+ api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await mock.waitForReservedNick(_converse, muc_jid);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await view.model.messages.fetched;
+
+ view.model.messages.create({
+ 'type': 'groupchat',
+ 'to': muc_jid,
+ 'from': `${_converse.bare_jid}/orchard`,
+ 'body': 'Hello world',
+ 'message': 'Hello world',
+ 'time': '2021-02-02T12:00:00Z'
+ });
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED);
+ await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 1);
+
+ const sel = 'converse-message-history converse-chat-message .chat-msg__text';
+ await u.waitUntil(() => view.querySelector(sel)?.textContent.trim());
+ expect(view.querySelector(sel).textContent.trim()).toBe('Hello world')
+
+ const nick_input = await u.waitUntil(() => view.querySelector('[name="nick"]'));
+ nick_input.value = nick;
+ view.querySelector('.muc-nickname-form input[type="submit"]').click();
+ _converse.connection.IQ_stanzas = [];
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ }));
+
+ it("maintains its state across reloads",
+ mock.initConverse([], {
+ 'clear_messages_on_reconnection': true,
+ 'enable_smacks': false
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const nick = 'romeo';
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const muc_jid = 'lounge@montague.lit'
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], []);
+ const view = _converse.chatboxviews.get(muc_jid);
+ let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const first_msg_id = _converse.connection.getUniqueId();
+ const last_msg_id = _converse.connection.getUniqueId();
+ let message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>1st Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>2nd Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ const result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${last_msg_id}</last>
+ <count>2</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+ while (sent_IQs.length) { sent_IQs.pop(); } // Clear so that we don't match the older query
+ await _converse.api.connection.reconnect();
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+ // The user has just entered the room (because join was called)
+ // and receives their own presence from the server.
+ // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+
+ message = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet">
+ <body>Wherefore art though?</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/>
+ <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" id="awQo6a-mi-Wa6NYh" to="${_converse.jid}" from="${muc_jid}/ews000" xml:lang="en">
+ <composing xmlns="http://jabber.org/protocol/chatstates"/>
+ <no-store xmlns="urn:xmpp:hints"/>
+ <no-permanent-store xmlns="urn:xmpp:hints"/>
+ <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:54Z" from="juliet@shakespeare.lit"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ const affs = api.settings.get('muc_fetch_members');
+ const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []);
+ await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations);
+
+ iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ expect(Strophe.serialize(iq_get)).toBe(
+ `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+
+ `</x>`+
+ `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("shows a new messages indicator when you're scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const message = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet">
+ <body>Wherefore art though?</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/>
+ <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/>
+ </message>`);
+
+ view.model.ui.set('scrolled', true); // hack
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ await u.waitUntil(() => view.model.messages.length);
+ const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
+ chat_new_msgs_indicator.click();
+ expect(view.model.ui.get('scrolled')).toBeFalsy();
+ await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator));
+ }));
+
+
+ describe("topic", function () {
+
+ it("is shown the header", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+ const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>${text}</subject>
+ <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+ <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+ await new Promise(resolve => view.model.once('change:subject', resolve));
+ const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'), 1000);
+ expect(head_desc?.textContent.trim()).toBe(text);
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>This is a message subject</subject>
+ <body>This is a message</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(sizzle('.chat-msg__subject', view).length).toBe(1);
+ expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
+ expect(sizzle('.chat-msg__text').length).toBe(1);
+ expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
+ expect(view.querySelector('.chat-head__desc').textContent.trim()).toBe(text);
+ }));
+
+ it("can be toggled by the user", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+ const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>${text}</subject>
+ <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+ <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+ await new Promise(resolve => view.model.once('change:subject', resolve));
+
+ const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'));
+ expect(head_desc?.textContent.trim()).toBe(text);
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>This is a message subject</subject>
+ <body>This is a message</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(sizzle('.chat-msg__subject', view).length).toBe(1);
+ expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
+ expect(sizzle('.chat-msg__text').length).toBe(1);
+ expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message');
+ const topic_el = view.querySelector('.chat-head__desc');
+ expect(topic_el.textContent.trim()).toBe(text);
+ expect(u.isVisible(topic_el)).toBe(true);
+
+ await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Hide topic');
+ const toggle = view.querySelector('.hide-topic');
+ expect(toggle.textContent.trim()).toBe('Hide topic');
+ toggle.click();
+ await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Show topic');
+ }));
+
+ it("will always be shown when it's new", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+ const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org';
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>${text}</subject>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+ await new Promise(resolve => view.model.once('change:subject', resolve));
+
+ const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'));
+ expect(head_desc?.textContent.trim()).toBe(text);
+
+ let topic_el = view.querySelector('.chat-head__desc');
+ expect(topic_el.textContent.trim()).toBe(text);
+ expect(u.isVisible(topic_el)).toBe(true);
+
+ const toggle = view.querySelector('.hide-topic');
+ expect(toggle.textContent.trim()).toBe('Hide topic');
+ toggle.click();
+ await u.waitUntil(() => !u.isVisible(topic_el));
+
+ stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>Another topic</subject>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => u.isVisible(view.querySelector('.chat-head__desc')));
+ topic_el = view.querySelector('.chat-head__desc');
+ expect(topic_el.textContent.trim()).toBe('Another topic');
+ }));
+
+
+ it("causes an info message to be shown when received in real-time", mock.initConverse([], {}, async function (_converse) {
+ spyOn(_converse.ChatRoom.prototype, 'handleSubjectChange').and.callThrough();
+ await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'romeo');
+ const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>This is an older topic</subject>
+ <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/>
+ <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/>
+ </message>`)));
+ await u.waitUntil(() => view.model.handleSubjectChange.calls.count());
+ expect(sizzle('.chat-info__message', view).length).toBe(0);
+
+ const desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'));
+ expect(desc.textContent.trim()).toBe('This is an older topic');
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>This is a new topic</subject>
+ </message>`)));
+ await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2);
+
+ await u.waitUntil(() => sizzle('.chat-info__message', view).pop()?.textContent.trim() === 'Topic set by ralphm');
+ await u.waitUntil(() => desc.textContent.trim() === 'This is a new topic');
+
+ // Doesn't show multiple subsequent topic change notifications
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm">
+ <subject>Yet another topic</subject>
+ </message>`)));
+ await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 3);
+ await u.waitUntil(() => desc.textContent.trim() === 'Yet another topic');
+ expect(sizzle('.chat-info__message', view).length).toBe(1);
+
+ // Sow multiple subsequent topic change notification from someone else
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
+ <subject>Some1's topic</subject>
+ </message>`)));
+ await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4);
+ await u.waitUntil(() => desc.textContent.trim() === "Some1's topic");
+ expect(sizzle('.chat-info__message', view).length).toBe(2);
+ const el = sizzle('.chat-info__message', view).pop();
+ expect(el.textContent.trim()).toBe('Topic set by some1');
+
+ // Removes current topic
+ const stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1">
+ <subject/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5);
+ await u.waitUntil(() => view.querySelector('.chat-head__desc') === null);
+ await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-info').textContent.trim() === "Topic cleared by some1");
+ }));
+ });
+
+ it("restores cached messages when it reconnects and clear_messages_on_reconnection and muc_clear_messages_on_leave are false",
+ mock.initConverse([], {
+ 'clear_messages_on_reconnection': false,
+ 'muc_clear_messages_on_leave': false
+ },
+ async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
+ const model = _converse.chatboxes.get(muc_jid);
+ const message = 'Hello world',
+ nick = mock.chatroom_names[0],
+ msg = $msg({
+ 'from': 'lounge@montague.lit/'+nick,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t(message).tree();
+
+ await model.handleMessageStanza(msg);
+ await u.waitUntil(() => document.querySelector('converse-chat-message'));
+ await model.close();
+ await u.waitUntil(() => !document.querySelector('converse-chat-message'));
+
+ _converse.connection.IQ_stanzas = [];
+ await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
+ await u.waitUntil(() => document.querySelector('converse-chat-message'));
+ expect(model.messages.length).toBe(1);
+ expect(document.querySelectorAll('converse-chat-message').length).toBe(1);
+ }));
+
+ it("clears cached messages when it reconnects and clear_messages_on_reconnection is true",
+ mock.initConverse([], {'clear_messages_on_reconnection': true}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const message = 'Hello world',
+ nick = mock.chatroom_names[0],
+ msg = $msg({
+ 'from': 'lounge@montague.lit/'+nick,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t(message).tree();
+
+ await view.model.handleMessageStanza(msg);
+ await view.model.close();
+
+ _converse.connection.IQ_stanzas = [];
+ await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
+ expect(view.model.messages.length).toBe(0);
+ expect(view.querySelector('converse-chat-history')).toBe(null);
+ }));
+
+ it("is opened when an xmpp: URI is clicked inside another groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ if (!view.querySelectorAll('.chat-area').length) {
+ view.renderChatArea();
+ }
+ expect(_converse.chatboxes.length).toEqual(2);
+ const message = 'Please go to xmpp:coven@chat.shakespeare.lit?join',
+ nick = mock.chatroom_names[0],
+ msg = $msg({
+ 'from': 'lounge@montague.lit/'+nick,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t(message).tree();
+
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text a'));
+ view.querySelector('.chat-msg__text a').click();
+ await u.waitUntil(() => _converse.chatboxes.length === 3)
+ expect(_converse.chatboxes.pluck('id').includes('coven@chat.shakespeare.lit')).toBe(true);
+ }));
+
+ it("shows a notification if it's not anonymous",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ const nick = 'romeo';
+ await _converse.api.rooms.open(muc_jid);
+ await mock.getRoomFeatures(_converse, muc_jid);
+ await mock.waitForReservedNick(_converse, muc_jid, nick);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ /* <presence to="romeo@montague.lit/_converse.js-29092160"
+ * from="coven@chat.shakespeare.lit/some1">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+ * <status code="110"/>
+ * <status code="100"/>
+ * </x>
+ * </presence></body>
+ */
+ const presence = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'coven@chat.shakespeare.lit/some1'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'romeo@montague.lit/_converse.js-29092160',
+ 'role': 'moderator'
+ }).up()
+ .c('status', {code: '110'}).up()
+ .c('status', {code: '100'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']);
+
+ const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length);
+ expect(num_info_msgs).toBe(1);
+ expect(sizzle('div.chat-info', view).pop().textContent.trim()).toBe("This groupchat is not anonymous");
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+ }));
+
+
+ it("shows join/leave messages when users enter or exit a groupchat",
+ mock.initConverse(['chatBoxesFetched'], {'muc_fetch_members': false}, async function (_converse) {
+
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ const nick = 'some1';
+ const room_creation_promise = await _converse.api.rooms.open(muc_jid, {nick});
+ await mock.getRoomFeatures(_converse, muc_jid);
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop());
+
+ const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+ await _converse.api.waitUntil('chatRoomViewInitialized');
+
+ /* We don't show join/leave messages for existing occupants. We
+ * know about them because we receive their presences before we
+ * receive our own.
+ */
+ let presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/oldguy'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'oldguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ /* <presence to="romeo@montague.lit/_converse.js-29092160"
+ * from="coven@chat.shakespeare.lit/some1">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+ * <status code="110"/>
+ * </x>
+ * </presence></body>
+ */
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/some1'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'romeo@montague.lit/_converse.js-29092160',
+ 'role': 'moderator'
+ }).up()
+ .c('status', {code: '110'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications')?.textContent);
+ expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+ await room_creation_promise;
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ await view.model.messages.fetched;
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newguy have entered the groupchat");
+
+ const msg = $msg({
+ 'from': 'coven@chat.shakespeare.lit/some1',
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t('hello world').tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ // Add another entrant, otherwise the above message will be
+ // collapsed if "newguy" leaves immediately again
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newgirl'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newgirl@montague.lit/_converse.js-213098781',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newguy and newgirl have entered the groupchat");
+
+ // Don't show duplicate join messages
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-290918392',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'
+ * type='unavailable'>
+ * <status>Disconnected: Replaced by new connection</status>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='none'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ type: 'unavailable',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('status', 'Disconnected: Replaced by new connection').up()
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat");
+
+ // When the user immediately joins again, we collapse the
+ // multiple join/leave messages.
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newgirl and newguy have entered the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ type: 'unavailable',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/nomorenicks'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-290918392',
+ type: 'unavailable',
+ from: 'coven@chat.shakespeare.lit/nomorenicks'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newgirl have entered the groupchat\nnewguy and nomorenicks have left the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/nomorenicks'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat");
+
+ // Test a member joining and leaving
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-290918392',
+ from: 'coven@chat.shakespeare.lit/insider'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'member',
+ 'jid': 'insider@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'
+ * type='unavailable'>
+ * <status>Disconnected: Replaced by new connection</status>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='none'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ type: 'unavailable',
+ from: 'coven@chat.shakespeare.lit/insider'
+ })
+ .c('status', 'Disconnected: Replaced by new connection').up()
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'member',
+ 'jid': 'insider@montague.lit/_converse.js-290929789',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newgirl and nomorenicks have entered the groupchat\nnewguy and insider have left the groupchat");
+
+ expect(view.model.occupants.length).toBe(5);
+ expect(view.model.occupants.findWhere({'jid': 'insider@montague.lit'}).get('show')).toBe('offline');
+
+ // New girl leaves
+ presence = $pres({
+ 'to': 'romeo@montague.lit/_converse.js-29092160',
+ 'type': 'unavailable',
+ 'from': 'coven@chat.shakespeare.lit/newgirl'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newgirl@montague.lit/_converse.js-213098781',
+ 'role': 'none'
+ });
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and nomorenicks have entered the groupchat\nnewguy, insider and newgirl have left the groupchat");
+ expect(view.model.occupants.length).toBe(4);
+ }));
+
+ it("combines subsequent join/leave messages when users enter or exit a groupchat",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo')
+ const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo has entered the groupchat");
+
+ let presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+ <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo and fabio have entered the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat");
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="owner" jid="jc@opkode.com/converse.js-30645022" role="moderator"/>
+ <status code="110"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and others have entered the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, fabio and jcbrand have entered the groupchat\nDele Olajide has left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, fabio and others have entered the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fuvuv" xml:lang="en">
+ <c xmlns="http://jabber.org/protocol/caps" node="http://jabber.pix-art.de" ver="5tOurnuFnp2h50hKafeUyeN4Yl8=" hash="sha-1"/>
+ <x xmlns="vcard-temp:x:update"/>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, fabio and others have entered the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fuvuv">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, fabio and others have entered the groupchat\nfuvuv has left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
+ <status>Disconnected: Replaced by new connection</status>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+ <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+ <status>Ready for a new day</status>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, jcbrand and others have entered the groupchat\nfuvuv has left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
+ <status>Disconnected: closed</status>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo and jcbrand have entered the groupchat\nfuvuv, fabio and Dele Olajide have left the groupchat");
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio">
+ <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo, jcbrand and fabio have entered the groupchat\nfuvuv and Dele Olajide have left the groupchat");
+
+ expect(1).toBe(1);
+ }));
+
+ it("doesn't show the disconnection messages when join_leave_events is not in muc_show_info_messages setting",
+ mock.initConverse(['chatBoxesFetched'], {'muc_show_info_messages': []}, async function (_converse) {
+
+ spyOn(_converse.ChatRoom.prototype, 'onOccupantAdded').and.callThrough();
+ spyOn(_converse.ChatRoom.prototype, 'onOccupantRemoved').and.callThrough();
+ await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
+ const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+ let presence = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.model.onOccupantAdded.calls.count() === 2);
+ expect(view.model.notifications.get('entered')).toBeFalsy();
+ expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
+ await mock.sendMessage(view, 'hello world');
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/newguy">
+ <status>Gotta go!</status>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.model.onOccupantRemoved.calls.count());
+ expect(view.model.onOccupantRemoved.calls.count()).toBe(1);
+ expect(view.model.notifications.get('entered')).toBeFalsy();
+ await mock.sendMessage(view, 'hello world');
+ expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('');
+ }));
+
+ it("role-change messages that follow a MUC leave are left out",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ // See https://github.com/conversejs/converse.js/issues/1259
+
+ await mock.openAndEnterChatRoom(_converse, 'conversations@conference.siacs.eu', 'romeo');
+
+ const presence = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'conversations@conference.siacs.eu/Guus'
+ }).c('x', {
+ 'xmlns': Strophe.NS.MUC_USER
+ }).c('item', {
+ 'affiliation': 'none',
+ 'jid': 'Guus@montague.lit/xxx',
+ 'role': 'visitor'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const view = _converse.chatboxviews.get('conversations@conference.siacs.eu');
+ const msg = $msg({
+ 'from': 'conversations@conference.siacs.eu/romeo',
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t('Some message').tree();
+
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop());
+
+ let stanza = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="conversations@conference.siacs.eu/Guus">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" role="none"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ stanza = u.toStanza(
+ `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="conversations@conference.siacs.eu/Guus">
+ <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="ISg6+9AoK1/cwhbNEDviSvjdPzI=" hash="sha-1"/>
+ <x xmlns="vcard-temp:x:update">
+ <photo>bf987c486c51fbc05a6a4a9f20dd19b5efba3758</photo>
+ </x>
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="none" role="visitor"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim()
+ === "romeo and Guus have entered the groupchat");
+ expect(1).toBe(1);
+ }));
+
+ it("can be configured if you're its owner", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ let sent_IQ, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+
+ await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'});
+ const view = await u.waitUntil(() => _converse.chatboxviews.get('coven@chat.shakespeare.lit'));
+ await u.waitUntil(() => u.isVisible(view));
+ // We pretend this is a new room, so no disco info is returned.
+ const features_stanza = $iq({
+ from: 'coven@chat.shakespeare.lit',
+ 'id': IQ_id,
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ /* <presence to="romeo@montague.lit/_converse.js-29092160"
+ * from="coven@chat.shakespeare.lit/some1">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+ * <status code="110"/>
+ * </x>
+ * </presence></body>
+ */
+ const presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/some1'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'romeo@montague.lit/_converse.js-29092160',
+ 'role': 'moderator'
+ }).up()
+ .c('status', {code: '110'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.configure-chatroom-button') !== null);
+
+ const own_occupant = view.model.getOwnOccupant();
+ await u.waitUntil(() => own_occupant.get('affiliation') === 'owner');
+
+ view.querySelector('.configure-chatroom-button').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sel = 'iq query[xmlns="http://jabber.org/protocol/muc#owner"]';
+ const iq = await u.waitUntil(() => sent_IQs.filter(iq => sizzle(sel, iq).length).pop());
+
+ /* Check that an IQ is sent out, asking for the
+ * configuration form.
+ * See: // https://xmpp.org/extensions/xep-0045.html#example-163
+ *
+ * <iq from='crone1@shakespeare.lit/desktop'
+ * id='config1'
+ * to='coven@chat.shakespeare.lit'
+ * type='get'>
+ * <query xmlns='http://jabber.org/protocol/muc#owner'/>
+ * </iq>
+ */
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+
+ `</iq>`);
+
+ /* Server responds with the configuration form.
+ * See: // https://xmpp.org/extensions/xep-0045.html#example-165
+ */
+ const config_stanza = $iq({from: 'coven@chat.shakespeare.lit',
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'})
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'})
+ .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'})
+ .c('title').t('Configuration for "coven" Room').up()
+ .c('instructions').t('Complete this form to modify the configuration of your room.').up()
+ .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'})
+ .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up()
+ .c('field', {
+ 'label': 'Natural-Language Room Name',
+ 'type': 'text-single',
+ 'var': 'muc#roomconfig_roomname'})
+ .c('value').t('A Dark Cave').up().up()
+ .c('field', {
+ 'label': 'Short Description of Room',
+ 'type': 'text-single',
+ 'var': 'muc#roomconfig_roomdesc'})
+ .c('value').t('The place for all good witches!').up().up()
+ .c('field', {
+ 'label': 'Enable Public Logging?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_enablelogging'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Allow Occupants to Change Subject?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_changesubject'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Allow Occupants to Invite Others?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_allowinvites'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Who Can Send Private Messages?',
+ 'type': 'list-single',
+ 'var': 'muc#roomconfig_allowpm'})
+ .c('value').t('anyone').up()
+ .c('option', {'label': 'Anyone'})
+ .c('value').t('anyone').up().up()
+ .c('option', {'label': 'Anyone with Voice'})
+ .c('value').t('participants').up().up()
+ .c('option', {'label': 'Moderators Only'})
+ .c('value').t('moderators').up().up()
+ .c('option', {'label': 'Nobody'})
+ .c('value').t('none').up().up().up()
+ .c('field', {
+ 'label': 'Roles for which Presence is Broadcasted',
+ 'type': 'list-multi',
+ 'var': 'muc#roomconfig_presencebroadcast'})
+ .c('value').t('moderator').up()
+ .c('value').t('participant').up()
+ .c('value').t('visitor').up()
+ .c('option', {'label': 'Moderator'})
+ .c('value').t('moderator').up().up()
+ .c('option', {'label': 'Participant'})
+ .c('value').t('participant').up().up()
+ .c('option', {'label': 'Visitor'})
+ .c('value').t('visitor').up().up().up()
+ .c('field', {
+ 'label': 'Roles and Affiliations that May Retrieve Member List',
+ 'type': 'list-multi',
+ 'var': 'muc#roomconfig_getmemberlist'})
+ .c('value').t('moderator').up()
+ .c('value').t('participant').up()
+ .c('value').t('visitor').up()
+ .c('option', {'label': 'Moderator'})
+ .c('value').t('moderator').up().up()
+ .c('option', {'label': 'Participant'})
+ .c('value').t('participant').up().up()
+ .c('option', {'label': 'Visitor'})
+ .c('value').t('visitor').up().up().up()
+ .c('field', {
+ 'label': 'Make Room Publicly Searchable?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_publicroom'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Make Room Publicly Searchable?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_publicroom'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Make Room Persistent?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_persistentroom'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Make Room Moderated?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_moderatedroom'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Make Room Members Only?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_membersonly'})
+ .c('value').t(0).up().up()
+ .c('field', {
+ 'label': 'Password Required for Entry?',
+ 'type': 'boolean',
+ 'var': 'muc#roomconfig_passwordprotectedroom'})
+ .c('value').t(1).up().up()
+ .c('field', {'type': 'fixed'})
+ .c('value').t(
+ 'If a password is required to enter this groupchat, you must specify the password below.'
+ ).up().up()
+ .c('field', {
+ 'label': 'Password',
+ 'type': 'text-private',
+ 'var': 'muc#roomconfig_roomsecret'})
+ .c('value').t('cauldronburn');
+ _converse.connection._dataRecv(mock.createRequest(config_stanza));
+
+ const membersonly = await u.waitUntil(() => view.querySelector('input[name="muc#roomconfig_membersonly"]'));
+ expect(membersonly.getAttribute('type')).toBe('checkbox');
+ membersonly.checked = true;
+
+ const moderated = view.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]');
+ expect(moderated.length).toBe(1);
+ expect(moderated[0].getAttribute('type')).toBe('checkbox');
+ moderated[0].checked = true;
+
+ const password = view.querySelectorAll('input[name="muc#roomconfig_roomsecret"]');
+ expect(password.length).toBe(1);
+ expect(password[0].getAttribute('type')).toBe('password');
+
+ const allowpm = view.querySelectorAll('select[name="muc#roomconfig_allowpm"]');
+ expect(allowpm.length).toBe(1);
+ allowpm[0].value = 'moderators';
+
+ const presencebroadcast = view.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]');
+ expect(presencebroadcast.length).toBe(1);
+ presencebroadcast[0].value = ['moderator'];
+
+ view.querySelector('.chatroom-form input[type="submit"]').click();
+
+ expect(sent_IQ.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1');
+ expect(sent_IQ.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1');
+ expect(sent_IQ.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators');
+ expect(sent_IQ.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator');
+ }));
+
+
+ it("properly handles notification that a room has been destroyed",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openChatRoomViaModal(_converse, 'problematic@muc.montague.lit', 'romeo')
+ const presence = $pres().attrs({
+ from:'problematic@muc.montague.lit',
+ id:'n13mt3l',
+ to:'romeo@montague.lit/pda',
+ type:'error'})
+ .c('error').attrs({'type':'cancel'})
+ .c('gone').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
+ .t('xmpp:other-room@chat.jabberfr.org?join').up()
+ .c('text').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'})
+ .t("We didn't like the name").nodeTree;
+
+ const view = _converse.chatboxviews.get('problematic@muc.montague.lit');
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const msg = await u.waitUntil(() => view.querySelector('.chatroom-body .disconnect-msg'));
+ expect(msg.textContent.trim()).toBe('This groupchat no longer exists');
+ expect(view.querySelector('.chatroom-body .destroyed-reason').textContent.trim())
+ .toBe(`The following reason was given: "We didn't like the name"`);
+ expect(view.querySelector('.chatroom-body .moved-label').textContent.trim())
+ .toBe('The conversation has moved to a new address. Click the link below to enter.');
+ expect(view.querySelector('.chatroom-body .moved-link').textContent.trim())
+ .toBe(`other-room@chat.jabberfr.org`);
+ }));
+
+ it("allows the user to invite their roster contacts to enter the groupchat",
+ mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'}, async function (_converse) {
+
+ // We need roster contacts, so that we have someone to invite
+ await mock.waitForRoster(_converse, 'current');
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_anonymous'
+ ]
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.model.getOwnAffiliation()).toBe('owner');
+ expect(view.model.features.get('open')).toBe(false);
+ await u.waitUntil(() => view.querySelector('.open-invite-modal'));
+
+ // Members can't invite if the room isn't open
+ view.model.getOwnOccupant().set('affiliation', 'member');
+
+ await u.waitUntil(() => view.querySelector('.open-invite-modal') === null);
+
+ view.model.features.set('open', 'true');
+ await u.waitUntil(() => view.querySelector('.open-invite-modal'));
+
+ view.querySelector('.open-invite-modal').click();
+ const modal = _converse.api.modal.get('converse-muc-invite-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000)
+
+ expect(modal.querySelectorAll('#invitee_jids').length).toBe(1);
+ expect(modal.querySelectorAll('textarea').length).toBe(1);
+
+ spyOn(view.model, 'directInvite').and.callThrough();
+
+ const input = modal.querySelector('#invitee_jids input');
+ input.value = "Balt";
+ modal.querySelector('input[type="submit"]').click();
+
+ await u.waitUntil(() => modal.querySelector('.error'));
+
+ const error = modal.querySelector('.error');
+ expect(error.textContent).toBe('Please enter a valid XMPP address');
+
+ let evt = new Event('input');
+ input.dispatchEvent(evt);
+
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+ const hint = await u.waitUntil(() => modal.querySelector('.suggestion-box__results li'));
+ expect(input.value).toBe('Balt');
+ expect(hint.textContent.trim()).toBe('Balthasar');
+
+ evt = new Event('mousedown', {'bubbles': true});
+ evt.button = 0;
+ hint.dispatchEvent(evt);
+
+ const textarea = modal.querySelector('textarea');
+ textarea.value = "Please join!";
+ modal.querySelector('input[type="submit"]').click();
+
+ expect(view.model.directInvite).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="balthasar@montague.lit" `+
+ `xmlns="jabber:client">`+
+ `<x jid="lounge@montague.lit" reason="Please join!" xmlns="jabber:x:conference"/>`+
+ `</message>`
+ );
+ }));
+
+ it("can be joined automatically, based upon a received invite",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current'); // We need roster contacts, who can invite us
+ const name = mock.cur_names[0];
+ const from_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await u.waitUntil(() => _converse.roster.get(from_jid).vcard.get('fullname'));
+
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await view.close(); // Hack, otherwise we have to mock stanzas.
+
+ const muc_jid = 'lounge@montague.lit';
+ const reason = "Please join this groupchat";
+
+ expect(_converse.chatboxes.models.length).toBe(1);
+ expect(_converse.chatboxes.models[0].id).toBe("controlbox");
+
+ const stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.bare_jid}" from="${from_jid}" id="9bceb415-f34b-4fa4-80d5-c0d076a24231">
+ <x xmlns="jabber:x:conference" jid="${muc_jid}" reason="${reason}"/>
+ </message>`);
+ await _converse.onDirectMUCInvitation(stanza);
+
+ expect(_converse.api.confirm).toHaveBeenCalledWith(
+ name + ' has invited you to join a groupchat: '+ muc_jid +
+ ', and left the following reason: "'+reason+'"');
+ expect(_converse.chatboxes.models.length).toBe(2);
+ expect(_converse.chatboxes.models[0].id).toBe('controlbox');
+ expect(_converse.chatboxes.models[1].id).toBe(muc_jid);
+ }));
+
+ it("shows received groupchat messages",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const text = 'This is a received message';
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const nick = mock.chatroom_names[0];
+ view.model.occupants.create({
+ 'nick': nick,
+ 'muc_jid': `${view.model.get('jid')}/${nick}`
+ });
+
+ const message = $msg({
+ from: 'lounge@montague.lit/'+nick,
+ id: '1',
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(text);
+ await view.model.handleMessageStanza(message.nodeTree);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent.trim()).toBe(text);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ }));
+
+ it("shows sent groupchat messages", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const text = 'This is a sent message';
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = text;
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('sendMessage', jasmine.any(Object));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ // Let's check that if we receive the same message again, it's
+ // not shown.
+ const stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="lounge@montague.lit/romeo"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <body>${text}</body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="lounge@montague.lit"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="${view.model.messages.at(0).get('origin_id')}"/>
+ </message>`);
+ await view.model.handleMessageStanza(stanza);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text);
+ expect(view.model.messages.length).toBe(1);
+ // We don't emit an event if it's our own message
+ expect(_converse.api.trigger.calls.count(), 1);
+ }));
+
+ it("will cause the chat area to be scrolled down only if it was at the bottom already",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const message = 'This message is received while the chat area is scrolled up';
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ // Create enough messages so that there's a scrollbar.
+ const promises = [];
+ for (let i=0; i<20; i++) {
+ promises.push(
+ view.model.handleMessageStanza(
+ $msg({
+ from: 'lounge@montague.lit/someone',
+ to: 'romeo@montague.lit.com',
+ type: 'groupchat',
+ id: u.getUniqueId(),
+ }).c('body').t('Message: '+i).tree())
+ );
+ }
+ await Promise.all(promises);
+ const promise = u.getOpenPromise();
+
+ // Give enough time for `markScrolled` to have been called
+ setTimeout(async () => {
+ const content = view.querySelector('.chat-content');
+ content.scrollTop = 0;
+ await view.model.handleMessageStanza(
+ $msg({
+ from: 'lounge@montague.lit/someone',
+ to: 'romeo@montague.lit.com',
+ type: 'groupchat',
+ id: u.getUniqueId(),
+ }).c('body').t(message).tree());
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 21);
+ // Now check that the message appears inside the chatbox in the DOM
+ const msg_txt = sizzle('.chat-msg:last .chat-msg__text', content).pop().textContent;
+ expect(msg_txt).toEqual(message);
+ expect(content.scrollTop).toBe(0);
+ promise.resolve();
+ }, 500);
+
+ return promise;
+ }));
+
+
+ it("informs users if the room configuration has changed",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+
+ const stanza = u.toStanza(`
+ <message from='${muc_jid}'
+ id='80349046-F26A-44F3-A7A6-54825064DD9E'
+ to='${_converse.jid}'
+ type='groupchat'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <status code='170'/>
+ </x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length);
+ const info_messages = view.querySelectorAll('.chat-content .chat-info');
+ expect(info_messages[0].textContent.trim()).toBe('Groupchat logging is now enabled');
+ }));
+
+ it("queries for the groupchat information before attempting to join the user",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const nick = "some1";
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const muc_jid = 'coven@chat.shakespeare.lit';
+
+ await _converse.api.rooms.open(muc_jid, { nick });
+ const stanza = await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+
+ // Check that the groupchat queried for the feautures.
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ /* <iq from='coven@chat.shakespeare.lit'
+ * id='ik3vs715'
+ * to='hag66@shakespeare.lit/pda'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'>
+ * <identity
+ * category='conference'
+ * name='A Dark Cave'
+ * type='text'/>
+ * <feature var='http://jabber.org/protocol/muc'/>
+ * <feature var='muc_passwordprotected'/>
+ * <feature var='muc_hidden'/>
+ * <feature var='muc_temporary'/>
+ * <feature var='muc_open'/>
+ * <feature var='muc_unmoderated'/>
+ * <feature var='muc_nonanonymous'/>
+ * </query>
+ * </iq>
+ */
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': stanza.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'conference',
+ 'name': 'A Dark Cave',
+ 'type': 'text'
+ }).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_passwordprotected'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ .c('feature', {'var': 'muc_open'}).up()
+ .c('feature', {'var': 'muc_unmoderated'}).up()
+ .c('feature', {'var': 'muc_nonanonymous'});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+ let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
+ view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+ expect(view.model.features.get('fetched')).toBeTruthy();
+ expect(view.model.features.get('passwordprotected')).toBe(true);
+ expect(view.model.features.get('hidden')).toBe(true);
+ expect(view.model.features.get('temporary')).toBe(true);
+ expect(view.model.features.get('open')).toBe(true);
+ expect(view.model.features.get('unmoderated')).toBe(true);
+ expect(view.model.features.get('nonanonymous')).toBe(true);
+ }));
+
+ it("updates the shown features when the groupchat configuration has changed",
+ mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) {
+
+ let features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_publicroom',
+ 'muc_temporary',
+ 'muc_open',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ const muc_jid = 'room@conference.example.org';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const info_el = view.querySelector(".show-muc-details-modal");
+ info_el.click();
+ let modal = _converse.api.modal.get('converse-muc-details-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ let features_list = modal.querySelector('.features-list');
+ let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
+
+ expect(features_shown.join(' ')).toBe(
+ 'Password protected - This groupchat requires a password before entry '+
+ 'Open - Anyone can join this groupchat '+
+ 'Temporary - This groupchat will disappear once the last person leaves '+
+ 'Not anonymous - All other groupchat participants can see your XMPP address '+
+ 'Not moderated - Participants entering this groupchat can write right away');
+ expect(view.model.features.get('hidden')).toBe(false);
+ expect(view.model.features.get('mam_enabled')).toBe(false);
+ expect(view.model.features.get('membersonly')).toBe(false);
+ expect(view.model.features.get('moderated')).toBe(false);
+ expect(view.model.features.get('nonanonymous')).toBe(true);
+ expect(view.model.features.get('open')).toBe(true);
+ expect(view.model.features.get('passwordprotected')).toBe(true);
+ expect(view.model.features.get('persistent')).toBe(false);
+ expect(view.model.features.get('publicroom')).toBe(true);
+ expect(view.model.features.get('semianonymous')).toBe(false);
+ expect(view.model.features.get('temporary')).toBe(true);
+ expect(view.model.features.get('unmoderated')).toBe(true);
+ expect(view.model.features.get('unsecured')).toBe(false);
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room');
+
+ modal.querySelector('.close').click();
+ view.querySelector('.configure-chatroom-button').click();
+
+ const IQs = _converse.connection.IQ_stanzas;
+ const s = `iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`;
+ let iq = await u.waitUntil(() => IQs.filter(iq => iq.querySelector(s)).pop());
+
+ const response_el = u.toStanza(
+ `<iq xmlns="jabber:client"
+ type="result"
+ to="romeo@montague.lit/pda"
+ from="room@conference.example.org" id="${iq.getAttribute('id')}">
+ <query xmlns="http://jabber.org/protocol/muc#owner">
+ <x xmlns="jabber:x:data" type="form">
+ <title>Configuration for room@conference.example.org</title>
+ <instructions>Complete and submit this form to configure the room.</instructions>
+ <field var="FORM_TYPE" type="hidden">
+ <value>http://jabber.org/protocol/muc#roomconfig</value>
+ </field>
+ <field type="fixed">
+ <value>Room information</value>
+ </field>
+ <field var="muc#roomconfig_roomname" type="text-single" label="Title">
+ <value>Room</value>
+ </field>
+ <field var="muc#roomconfig_roomdesc" type="text-single" label="Description">
+ <desc>A brief description of the room</desc>
+ <value>This room is used in tests</value>
+ </field>
+ <field var="muc#roomconfig_lang" type="text-single" label="Language tag for room (e.g. 'en', 'de', 'fr' etc.)">
+ <desc>Indicate the primary language spoken in this room</desc>
+ <value>en</value>
+ </field>
+ <field var="muc#roomconfig_persistentroom" type="boolean" label="Persistent (room should remain even when it is empty)">
+ <desc>Rooms are automatically deleted when they are empty, unless this option is enabled</desc>
+ <value>1</value>
+ </field>
+ <field var="muc#roomconfig_publicroom" type="boolean" label="Include room information in public lists">
+ <desc>Enable this to allow people to find the room</desc>
+ <value>1</value>
+ </field>
+ <field type="fixed"><value>Access to the room</value></field>
+ <field var="muc#roomconfig_roomsecret" type="text-private" label="Password"><value/></field>
+ <field var="muc#roomconfig_membersonly" type="boolean" label="Only allow members to join">
+ <desc>Enable this to only allow access for room owners, admins and members</desc>
+ </field>
+ <field var="{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites" type="boolean" label="Allow members to invite new members"/>
+ <field type="fixed"><value>Permissions in the room</value>
+ </field>
+ <field var="muc#roomconfig_changesubject" type="boolean" label="Allow anyone to set the room's subject">
+ <desc>Choose whether anyone, or only moderators, may set the room's subject</desc>
+ </field>
+ <field var="muc#roomconfig_moderatedroom" type="boolean" label="Moderated (require permission to speak)">
+ <desc>In moderated rooms occupants must be given permission to speak by a room moderator</desc>
+ </field>
+ <field var="muc#roomconfig_whois" type="list-single" label="Addresses (JIDs) of room occupants may be viewed by:">
+ <option label="Moderators only"><value>moderators</value></option>
+ <option label="Anyone"><value>anyone</value></option>
+ <value>anyone</value>
+ </field>
+ <field type="fixed"><value>Other options</value></field>
+ <field var="muc#roomconfig_historylength" type="text-single" label="Maximum number of history messages returned by room">
+ <desc>Specify the maximum number of previous messages that should be sent to users when they join the room</desc>
+ <value>50</value>
+ </field>
+ <field var="muc#roomconfig_defaulthistorymessages" type="text-single" label="Default number of history messages returned by room">
+ <desc>Specify the number of previous messages sent to new users when they join the room</desc>
+ <value>20</value>
+ </field>
+ </x>
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(response_el));
+ await u.waitUntil(() => document.querySelector('.chatroom-form input'));
+ expect(view.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org");
+ sizzle('[name="muc#roomconfig_membersonly"]', view).pop().click();
+ sizzle('[name="muc#roomconfig_roomname"]', view).pop().value = "New room name"
+ view.querySelector('.chatroom-form input[type="submit"]').click();
+
+ iq = await u.waitUntil(() => IQs.filter(iq => u.matchesSelector(iq, `iq[to="${muc_jid}"][type="set"]`)).pop());
+ const result = $iq({
+ "xmlns": "jabber:client",
+ "type": "result",
+ "to": "romeo@montague.lit/orchard",
+ "from": "lounge@muc.montague.lit",
+ "id": iq.getAttribute('id')
+ });
+
+ IQs.length = 0; // Empty the array
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ iq = await u.waitUntil(() => IQs.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'conference',
+ 'name': 'New room name',
+ 'type': 'text'
+ }).up();
+ features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ features.forEach(f => features_stanza.c('feature', {'var': f}).up());
+ features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
+ .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
+ .c('value').t('This is the description').up().up()
+ .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
+ .c('value').t(0);
+
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ await u.waitUntil(() => new Promise(success => view.model.features.on('change', success)));
+
+ info_el.click();
+ modal = _converse.api.modal.get('converse-muc-details-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ features_list = modal.querySelector('.features-list');
+ features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
+ expect(features_shown.join(' ')).toBe(
+ 'Password protected - This groupchat requires a password before entry '+
+ 'Hidden - This groupchat is not publicly searchable '+
+ 'Members only - This groupchat is restricted to members only '+
+ 'Temporary - This groupchat will disappear once the last person leaves '+
+ 'Not anonymous - All other groupchat participants can see your XMPP address '+
+ 'Not moderated - Participants entering this groupchat can write right away');
+ expect(view.model.features.get('hidden')).toBe(true);
+ expect(view.model.features.get('mam_enabled')).toBe(false);
+ expect(view.model.features.get('membersonly')).toBe(true);
+ expect(view.model.features.get('moderated')).toBe(false);
+ expect(view.model.features.get('nonanonymous')).toBe(true);
+ expect(view.model.features.get('open')).toBe(false);
+ expect(view.model.features.get('passwordprotected')).toBe(true);
+ expect(view.model.features.get('persistent')).toBe(false);
+ expect(view.model.features.get('publicroom')).toBe(false);
+ expect(view.model.features.get('semianonymous')).toBe(false);
+ expect(view.model.features.get('temporary')).toBe(true);
+ expect(view.model.features.get('unmoderated')).toBe(true);
+ expect(view.model.features.get('unsecured')).toBe(false);
+ await u.waitUntil(() => view.querySelector('.chatbox-title__text')?.textContent.trim() === 'New room name');
+ }));
+
+ it("indicates when a room is no longer anonymous",
+ mock.initConverse([], {}, async function (_converse) {
+
+ let IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+
+ await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1');
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+
+ // We pretend this is a new room, so no disco info is returned.
+ const features_stanza = $iq({
+ from: 'coven@chat.shakespeare.lit',
+ 'id': IQ_id,
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+ /* <message xmlns="jabber:client"
+ * type="groupchat"
+ * to="romeo@montague.lit/_converse.js-27854181"
+ * from="coven@chat.shakespeare.lit">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <status code="104"/>
+ * <status code="172"/>
+ * </x>
+ * </message>
+ */
+ const message = $msg({
+ type:'groupchat',
+ to: 'romeo@montague.lit/_converse.js-27854181',
+ from: 'coven@chat.shakespeare.lit'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('status', {code: '104'}).up()
+ .c('status', {code: '172'});
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length);
+ const chat_body = view.querySelector('.chatroom-body');
+ expect(sizzle('.message:last', chat_body).pop().textContent.trim())
+ .toBe('This groupchat is now no longer anonymous');
+ }));
+
+ it("informs users if they have been kicked out of the groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ /* <presence
+ * from='harfleur@chat.shakespeare.lit/pistol'
+ * to='pistol@shakespeare.lit/harfleur'
+ * type='unavailable'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='none' role='none'>
+ * <actor nick='Fluellen'/>
+ * <reason>Avaunt, you cullion!</reason>
+ * </item>
+ * <status code='110'/>
+ * <status code='307'/>
+ * </x>
+ * </presence>
+ */
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+
+ const presence = $pres().attrs({
+ from:'lounge@montague.lit/romeo',
+ to:'romeo@montague.lit/pda',
+ type:'unavailable'
+ })
+ .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'none',
+ jid: 'romeo@montague.lit/pda',
+ role: 'none'
+ })
+ .c('actor').attrs({nick: 'Fluellen'}).up()
+ .c('reason').t('Avaunt, you cullion!').up()
+ .up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'307'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area')));
+ expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy();
+ const chat_body = view.querySelector('.chatroom-body');
+ expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(3);
+ expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe(
+ 'You have been kicked from this groupchat');
+ expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe(
+ 'This action was done by Fluellen.');
+ expect(chat_body.querySelector('.disconnect-msg:nth-child(3)').textContent.trim()).toBe(
+ 'The reason given is: "Avaunt, you cullion!".');
+
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.DISCONNECTED);
+ }));
+
+ it("informs users if they have exited the groupchat due to a technical reason",
+ mock.initConverse([], {}, async function (_converse) {
+
+ /* <presence
+ * from='harfleur@chat.shakespeare.lit/pistol'
+ * to='pistol@shakespeare.lit/harfleur'
+ * type='unavailable'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='none' role='none'>
+ * <actor nick='Fluellen'/>
+ * <reason>Avaunt, you cullion!</reason>
+ * </item>
+ * <status code='110'/>
+ * <status code='307'/>
+ * </x>
+ * </presence>
+ */
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const presence = $pres().attrs({
+ from:'lounge@montague.lit/romeo',
+ to:'romeo@montague.lit/pda',
+ type:'unavailable'
+ })
+ .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'none',
+ jid: 'romeo@montague.lit/pda',
+ role: 'none'
+ })
+ .c('reason').t('Flux capacitor overload!').up()
+ .up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'333'}).up()
+ .c('status').attrs({code:'307'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area')));
+ expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy();
+ const chat_body = view.querySelector('.chatroom-body');
+ expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(2);
+ expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe(
+ 'You have exited this groupchat due to a technical problem');
+ expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe(
+ 'The reason given is: "Flux capacitor overload!".');
+ }));
+
+
+ it("can be saved to, and retrieved from, browserStorage",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+ // We instantiate a new ChatBoxes collection, which by default
+ // will be empty.
+ await mock.openControlBox(_converse);
+ const newchatboxes = new _converse.ChatBoxes();
+ expect(newchatboxes.length).toEqual(0);
+ // The chatboxes will then be fetched from browserStorage inside the
+ // onConnected method
+ newchatboxes.onConnected();
+ await new Promise(resolve => _converse.api.listen.once('chatBoxesFetched', resolve));
+
+ expect(newchatboxes.length).toEqual(2);
+ // Check that the chatrooms retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ const attrs = ['id', 'box_id', 'visible'];
+ let new_attrs, old_attrs;
+ for (let i=0; i<attrs.length; i++) {
+ new_attrs = newchatboxes.models.map(m => m.attributes[attrs[i]]);
+ old_attrs = _converse.chatboxes.models.map(m => m.attributes[attrs[i]]);
+ expect(new_attrs.sort()).toEqual(old_attrs.sort());
+ }
+ }));
+
+ it("can be closed again by clicking a DOM element with class 'close-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const model = await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+ spyOn(model, 'close').and.callThrough();
+ spyOn(_converse.api, "trigger").and.callThrough();
+ spyOn(model, 'leave');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ const view = await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit'));
+ const button = await u.waitUntil(() => view.querySelector('.close-chatbox-button'));
+ button.click();
+ await u.waitUntil(() => model.close.calls.count());
+ expect(model.leave).toHaveBeenCalled();
+ await u.waitUntil(() => _converse.api.trigger.calls.count());
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+ }));
+
+ it("informs users of role and affiliation changes",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo and annoyingGuy have entered the groupchat");
+
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'visitor'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo has entered the groupchat\nannoyingGuy has been muted");
+
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo has entered the groupchat\nannoyingGuy has been given a voice");
+
+ // Check that we don't see an info message concerning the role,
+ // if the affiliation has changed.
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'none',
+ 'role': 'visitor'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() =>
+ Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+ "annoyingGuy is no longer a member of this groupchat"
+ );
+ expect(1).toBe(1);
+ }));
+
+ it("notifies users of role and affiliation changes for members not currently in the groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ let message = $msg({
+ from: 'lounge@montague.lit',
+ id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F40',
+ to: 'romeo@montague.lit'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'absentguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(message));
+ await u.waitUntil(() => view.model.occupants.length > 1);
+ expect(view.model.occupants.length).toBe(2);
+ expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('member');
+
+ message = $msg({
+ from: 'lounge@montague.lit',
+ id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F41',
+ to: 'romeo@montague.lit'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'absentguy@montague.lit',
+ 'affiliation': 'none',
+ 'role': 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(message));
+ expect(view.model.occupants.length).toBe(2);
+ expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('none');
+
+ }));
+ });
+
+
+ describe("Each chat groupchat can take special commands", function () {
+
+ it("takes /help to show the available commands",
+ mock.initConverse([], {}, async function (_converse) {
+
+ spyOn(window, 'confirm').and.callFake(() => true);
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+ textarea.value = '/help';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter);
+
+ await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length);
+ let chat_help_el = view.querySelector('converse-chat-help');
+ let info_messages = sizzle('.chat-info', chat_help_el);
+ expect(info_messages.length).toBe(19);
+ expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
+ expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+ expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+ expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+ expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+ expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+ expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+ expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages');
+ expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+ expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+ expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+ expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+ expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+ expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+ expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+ expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+
+ const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+ occupant.set('affiliation', 'admin');
+
+ view.querySelector('.close-chat-help').click();
+ expect(view.model.get('show_help_messages')).toBe(false);
+ await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+ textarea.value = '/help';
+ message_form.onKeyDown(enter);
+ chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+ info_messages = sizzle('.chat-info', chat_help_el);
+ expect(info_messages.length).toBe(18);
+ let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+ expect(commands).toEqual([
+ "/admin", "/ban", "/clear", "/deop", "/destroy",
+ "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
+ "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
+ ]);
+ occupant.set('affiliation', 'member');
+ view.querySelector('.close-chat-help').click();
+ await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+ textarea.value = '/help';
+ message_form.onKeyDown(enter);
+ chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+ info_messages = sizzle('.chat-info', chat_help_el);
+ expect(info_messages.length).toBe(9);
+ commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+ expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
+
+ view.querySelector('.close-chat-help').click();
+ await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+ expect(view.model.get('show_help_messages')).toBe(false);
+
+ occupant.set('role', 'participant');
+ // Role changes causes rerender, so we need to get the new textarea
+
+ textarea.value = '/help';
+ message_form.onKeyDown(enter);
+ await u.waitUntil(() => view.model.get('show_help_messages'));
+ chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+ info_messages = sizzle('.chat-info', chat_help_el);
+ expect(info_messages.length).toBe(5);
+ commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+ expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
+
+ // Test that /topic is available if all users may change the subject
+ // Note: we're making a shortcut here, this value should never be set manually
+ view.model.config.set('changesubject', true);
+ view.querySelector('.close-chat-help').click();
+ await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+ textarea.value = '/help';
+ message_form.onKeyDown(enter);
+ chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+ info_messages = sizzle('.chat-info', chat_help_el);
+ expect(info_messages.length).toBe(7);
+ commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+ expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
+ }));
+
+ it("takes /help to show the available commands and commands can be disabled by config",
+ mock.initConverse([], {muc_disable_slash_commands: ['mute', 'voice']}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
+ spyOn(window, 'confirm').and.callFake(() => true);
+ textarea.value = '/clear';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown(enter);
+ textarea.value = '/help';
+ message_form.onKeyDown(enter);
+
+ await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length);
+ const info_messages = sizzle('.chat-info:not(.chat-event)', view);
+ expect(info_messages.length).toBe(17);
+ expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+ expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+ expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+ expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+ expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+ expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+ expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+ expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+ expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+ expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+ expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+ expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+ expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+ expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+ expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+ }));
+
+ it("takes /member to make an occupant a member",
+ mock.initConverse([], {}, async function (_converse) {
+
+ let iq_stanza;
+ await mock.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@muc.montague.lit');
+ /* We don't show join/leave messages for existing occupants. We
+ * know about them because we receive their presences before we
+ * receive our own.
+ */
+ const presence = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'lounge@muc.montague.lit/marc'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'marc@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.occupants.length).toBe(2);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake((stanza) => {
+ sent_stanza = stanza;
+ });
+
+ // First check that an error message appears when a
+ // non-existent nick is used.
+ textarea.value = '/member chris Welcome to the club!';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ expect(_converse.connection.send).not.toHaveBeenCalled();
+ await u.waitUntil(() => view.querySelectorAll('.chat-error').length);
+ expect(view.querySelector('.chat-error').textContent.trim())
+ .toBe('Error: couldn\'t find a groupchat participant based on your arguments');
+
+ // Now test with an existing nick
+ textarea.value = '/member marc Welcome to the club!';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => Strophe.serialize(sent_stanza) ===
+ `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="member" jid="marc@montague.lit">`+
+ `<reason>Welcome to the club!</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ let result = $iq({
+ "xmlns": "jabber:client",
+ "type": "result",
+ "to": "romeo@montague.lit/orchard",
+ "from": "lounge@muc.montague.lit",
+ "id": sent_stanza.getAttribute('id')
+ });
+ _converse.connection.IQ_stanzas = [];
+ _converse.connection._dataRecv(mock.createRequest(result));
+ iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop()
+ );
+
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="member"/>`+
+ `</query>`+
+ `</iq>`)
+ expect(view.model.occupants.length).toBe(2);
+
+ result = $iq({
+ "xmlns": "jabber:client",
+ "type": "result",
+ "to": "romeo@montague.lit/orchard",
+ "from": "lounge@muc.montague.lit",
+ "id": iq_stanza.getAttribute("id")
+ }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+ .c("item", {"jid": "marc", "affiliation": "member"});
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ expect(view.model.occupants.length).toBe(2);
+ iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop()
+ );
+
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="owner"/>`+
+ `</query>`+
+ `</iq>`)
+ expect(view.model.occupants.length).toBe(2);
+
+ result = $iq({
+ "xmlns": "jabber:client",
+ "type": "result",
+ "to": "romeo@montague.lit/orchard",
+ "from": "lounge@muc.montague.lit",
+ "id": iq_stanza.getAttribute("id")
+ }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+ .c("item", {"jid": "romeo@montague.lit", "affiliation": "owner"});
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ expect(view.model.occupants.length).toBe(2);
+ iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop()
+ );
+
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="admin"/>`+
+ `</query>`+
+ `</iq>`)
+ expect(view.model.occupants.length).toBe(2);
+
+ result = $iq({
+ "xmlns": "jabber:client",
+ "type": "result",
+ "to": "romeo@montague.lit/orchard",
+ "from": "lounge@muc.montague.lit",
+ "id": iq_stanza.getAttribute("id")
+ }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.querySelectorAll('.occupant').length, 500);
+ await u.waitUntil(() => view.querySelectorAll('.badge').length > 1);
+ expect(view.model.occupants.length).toBe(2);
+ expect(view.querySelectorAll('.occupant').length).toBe(2);
+ }));
+
+ it("takes /topic to set the groupchat topic", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ // Check the alias /topic
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/topic This is the groupchat subject';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ const { sent_stanzas } = _converse.connection;
+ await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is the groupchat subject'));
+
+ // Check /subject
+ textarea.value = '/subject This is a new subject';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+
+ let sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is a new subject').pop());
+ expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+ '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+ '<subject xmlns="jabber:client">This is a new subject</subject>'+
+ '</message>');
+
+ // Check case insensitivity
+ textarea.value = '/Subject This is yet another subject';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is yet another subject').pop());
+ expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+ '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+ '<subject xmlns="jabber:client">This is yet another subject</subject>'+
+ '</message>');
+
+ while (sent_stanzas.length) {
+ sent_stanzas.pop();
+ }
+ // Check unsetting the topic
+ textarea.value = '/topic';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ sent_stanza = await u.waitUntil(() => sent_stanzas.pop());
+ expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+ '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+ '<subject xmlns="jabber:client"></subject>'+
+ '</message>');
+ }));
+
+ it("takes /clear to clear messages", mock.initConverse([], {}, async function (_converse) {
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/clear';
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(false));
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+ expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?');
+ }));
+
+ it("takes /owner to make a user an owner", mock.initConverse([], {}, async function (_converse) {
+ let sent_IQ, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/owner';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+ const err_msg = await u.waitUntil(() => view.querySelector('.chat-error'));
+ expect(err_msg.textContent.trim()).toBe(
+ "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.");
+
+ const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]';
+ const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length);
+ expect(stanzas.length).toBe(0);
+
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/owner nobody You\'re responsible';
+ message_form.onFormSubmitted(new Event('submit'));
+ await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2);
+ expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
+ "Error: couldn't find a groupchat participant based on your arguments");
+
+ expect(_converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length).length).toBe(0);
+
+ // Call now with the correct of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/owner annoyingGuy You\'re responsible';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+ // Check that the member list now gets updated
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="owner" jid="annoyingguy@montague.lit">`+
+ `<reason>You&apos;re responsible</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'owner',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() =>
+ Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+ "annoyingGuy is now an owner of this groupchat"
+ );
+ }));
+
+ it("takes /ban to ban a user", mock.initConverse([], {}, async function (_converse) {
+ let sent_IQ, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/ban';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+ await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+ "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.");
+
+ const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]';
+ const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length);
+ expect(stanzas.length).toBe(0);
+
+ // Call now with the correct amount of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/ban annoyingGuy You\'re annoying';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+ // Check that the member list now gets updated
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+
+ `<reason>You&apos;re annoying</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D628',
+ 'to': 'romeo@montague.lit/desktop'
+ }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'outcast',
+ 'role': 'participant'
+ }).c('actor', {'nick': 'romeo'}).up()
+ .c('reason').t("You're annoying").up().up()
+ .c('status', {'code': '301'});
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+ expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo");
+ expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+ presence = $pres({
+ 'from': 'lounge@montague.lit/joe2',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'joe2@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ textarea.value = '/ban joe22';
+ message_form.onFormSubmitted(new Event('submit'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
+ "Error: couldn't find a groupchat participant based on your arguments");
+ }));
+
+
+ it("takes a /kick command to kick a user", mock.initConverse([], {}, async function (_converse) {
+ let sent_IQ, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(view.model, 'setRole').and.callThrough();
+ spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/annoying guy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'none',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/kick';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+ await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+ "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.");
+ expect(view.model.setRole).not.toHaveBeenCalled();
+ // Call now with the correct amount of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/kick @annoying guy You\'re annoying';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+ expect(view.model.setRole).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item nick="annoying guy" role="none">`+
+ `<reason>You&apos;re annoying</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ /* <presence
+ * from='harfleur@chat.shakespeare.lit/pistol'
+ * to='gower@shakespeare.lit/cell'
+ * type='unavailable'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='none' role='none'/>
+ * <status code='307'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoying guy',
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'unavailable'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'affiliation': 'none',
+ 'role': 'none'
+ }).c('actor', {'nick': 'romeo'}).up()
+ .c('reason').t("You're annoying").up().up()
+ .c('status', {'code': '307'});
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+ expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo");
+ expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+ }));
+
+
+ it("takes /op and /deop to make a user a moderator or not",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ let sent_IQ, IQ_id;
+ const sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ spyOn(view.model, 'setRole').and.callThrough();
+ spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+ // New user enters the groupchat
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * id='27C55F89-1C6A-459A-9EB5-77690145D624'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member' role='moderator'/>
+ * </x>
+ * </presence>
+ */
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/trustworthyguy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'trustworthyguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo and trustworthyguy have entered the groupchat");
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/op';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+ await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+ "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.");
+
+ expect(view.model.setRole).not.toHaveBeenCalled();
+ // Call now with the correct amount of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/op trustworthyguy You\'re trustworthy';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+ expect(view.model.setRole).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item nick="trustworthyguy" role="moderator">`+
+ `<reason>You&apos;re trustworthy</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='moderator'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ 'from': 'lounge@montague.lit/trustworthyguy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'trustworthyguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'moderator'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ // Check now that things get restored when the user is given a voice
+ await u.waitUntil(
+ () => view.querySelector('.chat-content__notifications').textContent.split('\n', 2).pop()?.trim() ===
+ "trustworthyguy is now a moderator");
+
+ // Call now with the correct amount of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/deop trustworthyguy Perhaps not';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+ expect(view.model.setRole).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item nick="trustworthyguy" role="participant">`+
+ `<reason>Perhaps not</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='participant'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ 'from': 'lounge@montague.lit/trustworthyguy',
+ 'to': 'romeo@montague.lit/desktop'
+ }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'trustworthyguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("trustworthyguy is no longer a moderator"));
+ }));
+
+ it("takes /mute and /voice to mute and unmute a user",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ var sent_IQ, IQ_id;
+ var sendIQ = _converse.connection.sendIQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
+ sent_IQ = iq;
+ IQ_id = sendIQ.bind(this)(iq, callback, errback);
+ });
+ spyOn(view.model, 'setRole').and.callThrough();
+ spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+ // New user enters the groupchat
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * id='27C55F89-1C6A-459A-9EB5-77690145D624'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member' role='participant'/>
+ * </x>
+ * </presence>
+ */
+ let presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'id':'27C55F89-1C6A-459A-9EB5-77690145D624',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "romeo and annoyingGuy have entered the groupchat");
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/mute';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+ await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+ "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.");
+ expect(view.model.setRole).not.toHaveBeenCalled();
+ // Call now with the correct amount of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/mute annoyingGuy You\'re annoying';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2)
+ expect(view.model.setRole).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item nick="annoyingGuy" role="visitor">`+
+ `<reason>You&apos;re annoying</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='visitor'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'visitor'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been muted"));
+
+ // Call now with the correct of arguments.
+ // XXX: Calling onFormSubmitted directly, trying
+ // again via triggering Event doesn't work for some weird
+ // reason.
+ textarea.value = '/voice annoyingGuy Now you can talk again';
+ message_form.onFormSubmitted(new Event('submit'));
+
+ await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+ expect(view.model.setRole).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+ `<item nick="annoyingGuy" role="participant">`+
+ `<reason>Now you can talk again</reason>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`);
+
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * to='crone1@shakespeare.lit/desktop'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='visitor'/>
+ * </x>
+ * </presence>
+ */
+ presence = $pres({
+ 'from': 'lounge@montague.lit/annoyingGuy',
+ 'to': 'romeo@montague.lit/desktop'
+ })
+ .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+ .c('item', {
+ 'jid': 'annoyingguy@montague.lit',
+ 'affiliation': 'member',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been given a voice"));
+ }));
+
+ it("takes /destroy to destroy a muc",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const new_muc_jid = 'foyer@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ let view = _converse.chatboxviews.get(muc_jid);
+ spyOn(_converse.api, 'confirm').and.callThrough();
+ let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/destroy';
+ let message_form = view.querySelector('converse-muc-message-form');
+ message_form.onFormSubmitted(new Event('submit'));
+ let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+ await u.waitUntil(() => u.isVisible(modal));
+
+ let challenge_el = modal.querySelector('[name="challenge"]');
+ challenge_el.value = muc_jid+'e';
+ const reason_el = modal.querySelector('[name="reason"]');
+ reason_el.value = 'Moved to a new location';
+ const newjid_el = modal.querySelector('[name="newjid"]');
+ newjid_el.value = new_muc_jid;
+ let submit = modal.querySelector('[type="submit"]');
+ submit.click();
+ expect(u.isVisible(modal)).toBeTruthy();
+ expect(u.hasClass('error', challenge_el)).toBeTruthy();
+ challenge_el.value = muc_jid;
+ submit.click();
+
+ let sent_IQs = _converse.connection.IQ_stanzas;
+ let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+ `<destroy jid="${new_muc_jid}">`+
+ `<reason>`+
+ `Moved to a new location`+
+ `</reason>`+
+ `</destroy>`+
+ `</query>`+
+ `</iq>`);
+
+ let result_stanza = $iq({
+ 'type': 'result',
+ 'id': sent_IQ.getAttribute('id'),
+ 'from': view.model.get('jid'),
+ 'to': _converse.connection.jid
+ });
+ expect(_converse.chatboxes.length).toBe(2);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(result_stanza));
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+ await u.waitUntil(() => _converse.chatboxes.length === 1);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+
+ // Try again without reason or new JID
+ _converse.connection.IQ_stanzas = [];
+ sent_IQs = _converse.connection.IQ_stanzas;
+ await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo');
+ view = _converse.chatboxviews.get(new_muc_jid);
+ textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = '/destroy';
+ message_form = view.querySelector('converse-muc-message-form');
+ message_form.onFormSubmitted(new Event('submit'));
+ modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+ await u.waitUntil(() => u.isVisible(modal));
+
+ challenge_el = modal.querySelector('[name="challenge"]');
+ challenge_el.value = new_muc_jid;
+ submit = modal.querySelector('[type="submit"]');
+ submit.click();
+
+ sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+ `<destroy/>`+
+ `</query>`+
+ `</iq>`);
+
+ result_stanza = $iq({
+ 'type': 'result',
+ 'id': sent_IQ.getAttribute('id'),
+ 'from': view.model.get('jid'),
+ 'to': _converse.connection.jid
+ });
+ expect(_converse.chatboxes.length).toBe(2);
+ _converse.connection._dataRecv(mock.createRequest(result_stanza));
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+ await u.waitUntil(() => _converse.chatboxes.length === 1);
+ }));
+ });
+
+ describe("When attempting to enter a groupchat", function () {
+
+ it("will show an error message if the groupchat requires a password",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'protected';
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const presence = $pres().attrs({
+ 'from': `${muc_jid}/romeo`,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit/pda',
+ 'type': 'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+ .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const chat_body = view.querySelector('.chatroom-body');
+ await u.waitUntil(() => chat_body.querySelectorAll('form.chatroom-form').length === 1);
+ expect(chat_body.querySelector('.chatroom-form label').textContent.trim())
+ .toBe('This groupchat requires a password');
+
+ // Let's submit the form
+ spyOn(view.model, 'join');
+ const input_el = view.querySelector('[name="password"]');
+ input_el.value = 'secret';
+ view.querySelector('input[type=submit]').click();
+ expect(view.model.join).toHaveBeenCalledWith('romeo', 'secret');
+ }));
+
+ it("will show an error message if the groupchat is members-only and the user not included",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'members-only@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+
+ // State that the chat is members-only via the features IQ
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'conference',
+ 'name': 'A Dark Cave',
+ 'type': 'text'
+ }).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ .c('feature', {'var': 'muc_membersonly'}).up();
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit/pda',
+ type: 'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+ .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')?.textContent?.trim() ===
+ 'You are not on the member list of this groupchat.');
+ }));
+
+ it("will show an error message if the user has been banned",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'off-limits@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit/pda',
+ type: 'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'auth'})
+ .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+ expect(el.textContent.trim()).toBe('You have been banned from this groupchat');
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.BANNED);
+ }));
+
+ it("will show an error message if the user is not allowed to have created the groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'impermissable@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
+
+ // We pretend this is a new room, so no disco info is returned.
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+ const features_stanza = $iq({
+ 'from': 'room@conference.example.org',
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to:'romeo@montague.lit/pda',
+ type:'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+ .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+ expect(el.textContent.trim()).toBe('You are not allowed to create new groupchats.');
+ }));
+
+ it("will show an error message if the groupchat doesn't yet exist",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'nonexistent@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit/pda',
+ type:'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+ .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+ expect(el.textContent.trim()).toBe("This groupchat does not (yet) exist.");
+ }));
+
+ it("will show an error message if the groupchat has reached its maximum number of participants",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'maxed-out@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
+
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to:'romeo@montague.lit/pda',
+ type:'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+ .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+ expect(el.textContent.trim()).toBe("This groupchat has reached its maximum number of participants.");
+ }));
+ });
+
+
+ describe("The affiliations delta", function () {
+
+ it("can be computed in various ways", mock.initConverse([], {}, async function (_converse) {
+ await mock.openChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo');
+ var exclude_existing = false;
+ var remove_absentees = false;
+ var new_list = [];
+ var old_list = [];
+ const muc_utils = converse.env.muc_utils;
+ let delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(0);
+
+ new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+ old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(0);
+
+ // When remove_absentees is false, then affiliations in the old
+ // list which are not in the new one won't be removed.
+ old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(0);
+
+ // With exclude_existing set to false, any changed affiliations
+ // will be included in the delta (i.e. existing affiliations are included in the comparison).
+ old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(1);
+ expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit');
+ expect(delta[0].affiliation).toBe('member');
+
+ // To also remove affiliations from the old list which are not
+ // in the new list, we set remove_absentees to true
+ remove_absentees = true;
+ old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'},
+ {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(1);
+ expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
+ expect(delta[0].affiliation).toBe('none');
+
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list);
+ expect(delta.length).toBe(2);
+ expect(delta[0].jid).toBe('oldhag666@shakespeare.lit');
+ expect(delta[0].affiliation).toBe('none');
+ expect(delta[1].jid).toBe('wiccarocks@shakespeare.lit');
+ expect(delta[1].affiliation).toBe('none');
+
+ // To only add a user if they don't already have an
+ // affiliation, we set 'exclude_existing' to true
+ exclude_existing = true;
+ old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(0);
+
+ old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}];
+ delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+ expect(delta.length).toBe(0);
+ }));
+ });
+
+
+ describe("A XEP-0085 Chat Status Notification", function () {
+
+ it("is is not sent out to a MUC if the user is a visitor in a moderated room",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough();
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_moderated',
+ 'muc_anonymous'
+ ]
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ view.model.setChatState(_converse.ACTIVE);
+
+ expect(view.model.sendChatState).toHaveBeenCalled();
+ const last_stanza = _converse.connection.sent_stanzas.pop();
+ expect(Strophe.serialize(last_stanza)).toBe(
+ `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ // Romeo loses his voice
+ const presence = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: `${muc_jid}/romeo`
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {'affiliation': 'none', 'role': 'visitor'}).up()
+ .c('status', {code: '110'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+ await u.waitUntil(() => occupant.get('role') === 'visitor');
+
+ spyOn(_converse.connection, 'send');
+ view.model.setChatState(_converse.INACTIVE);
+ expect(view.model.sendChatState.calls.count()).toBe(2);
+ expect(_converse.connection.send).not.toHaveBeenCalled();
+ }));
+
+
+ describe("A composing notification", function () {
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ const members = [
+ {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
+ {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
+ ];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ let csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+ let presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newguy have entered the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/nomorenicks'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newguy and nomorenicks have entered the groupchat", 1000);
+
+ // Manually clear so that we can more easily test
+ view.model.notifications.set('entered', []);
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent, 1000);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+
+ const remove_notifications_timeouts = [];
+ const setTimeout = window.setTimeout;
+ spyOn(window, 'setTimeout').and.callFake((f, w) => {
+ if (f.toString() === "() => this.removeNotification(actor, state)") {
+ remove_notifications_timeouts.push(f)
+ }
+ setTimeout(f, w);
+ });
+
+ // <composing> state
+ let msg = $msg({
+ from: muc_jid+'/newguy',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent, 1000);
+ expect(csntext.trim()).toEqual('newguy is typing');
+ expect(remove_notifications_timeouts.length).toBe(1);
+ expect(view.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
+
+ // <composing> state for a different occupant
+ msg = $msg({
+ from: muc_jid+'/nomorenicks',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing', 1000);
+
+ // <composing> state for a different occupant
+ msg = $msg({
+ from: muc_jid+'/majortom',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing', 1000);
+
+ // <composing> state for a different occupant
+ msg = $msg({
+ from: muc_jid+'/groundcontrol',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing', 1000);
+
+ msg = $msg({
+ from: `${muc_jid}/some1`,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('hello world').tree();
+ await view.model.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
+
+ // Test that the composing notifications get removed via timeout.
+ if (remove_notifications_timeouts.length) {
+ remove_notifications_timeouts[0]();
+ }
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing', 1000);
+ }));
+ });
+
+ describe("A paused notification", function () {
+
+ it("will be shown if received", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1');
+ const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+
+ /* <presence to="romeo@montague.lit/_converse.js-29092160"
+ * from="coven@chat.shakespeare.lit/some1">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+ * <status code="110"/>
+ * </x>
+ * </presence></body>
+ */
+ let presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/some1'
+ }).c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'owner',
+ 'jid': 'romeo@montague.lit/_converse.js-29092160',
+ 'role': 'moderator'
+ }).up()
+ .c('status', {code: '110'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1 and newguy have entered the groupchat");
+
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/nomorenicks'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'nomorenicks@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+ "some1, newguy and nomorenicks have entered the groupchat");
+
+ // Manually clear so that we can more easily test
+ view.model.notifications.set('entered', []);
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+
+ // <composing> state
+ let msg = $msg({
+ from: muc_jid+'/newguy',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing');
+
+ // <composing> state for a different occupant
+ msg = $msg({
+ from: muc_jid+'/nomorenicks',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'newguy and nomorenicks are typing');
+
+ // <paused> state from occupant who typed first
+ msg = $msg({
+ from: muc_jid+'/newguy',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await view.model.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'nomorenicks is typing\nnewguy has stopped typing');
+ }));
+ });
+ });
+
+ describe("A muted user", function () {
+
+ it("will receive a user-friendly error message when trying to send a message",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'trollbox@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+ textarea.value = 'Hello world';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onFormSubmitted(new Event('submit'));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ let stanza = u.toStanza(`
+ <message id="${view.model.messages.at(0).get('msgid')}"
+ xmlns="jabber:client"
+ type="error"
+ to="troll@montague.lit/resource"
+ from="trollbox@montague.lit">
+ <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim(), 1000);
+ expect(view.querySelector('.chat-msg__error').textContent.trim()).toBe(
+ "Your message was not delivered because you weren't allowed to send it.");
+
+ textarea.value = 'Hello again';
+ message_form.onFormSubmitted(new Event('submit'));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+ stanza = u.toStanza(`
+ <message id="${view.model.messages.at(1).get('msgid')}"
+ xmlns="jabber:client"
+ type="error"
+ to="troll@montague.lit/resource"
+ from="trollbox@montague.lit">
+ <error type="auth">
+ <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
+ </error>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 2);
+ const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error';
+ await u.waitUntil(() => view.querySelector(sel)?.textContent.trim());
+ expect(view.querySelector(sel).textContent.trim()).toBe('Thou shalt not!')
+ }));
+
+ it("will see an explanatory message instead of a textarea",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ Strophe.NS.SID,
+ 'muc_moderated',
+ ]
+ const muc_jid = 'trollbox@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('.chat-textarea'));
+
+ let stanza = u.toStanza(`
+ <presence
+ from='trollbox@montague.lit/troll'
+ to='romeo@montague.lit/orchard'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='none'
+ nick='troll'
+ role='visitor'/>
+ <status code='110'/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => view.querySelector('.chat-textarea') === null);
+ let bottom_panel = view.querySelector('.muc-bottom-panel');
+ expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+ // This only applies to moderated rooms, so let's check that
+ // the textarea becomes visible when the room's
+ // configuration changes to be non-moderated
+ view.model.features.set('moderated', false);
+ await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null);
+ const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+ expect(textarea === null).toBe(false);
+
+ view.model.features.set('moderated', true);
+ await u.waitUntil(() => view.querySelector('.chat-textarea') === null);
+ bottom_panel = view.querySelector('.muc-bottom-panel');
+ expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+ // Check now that things get restored when the user is given a voice
+ await u.waitUntil(() =>
+ Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+ "troll is no longer an owner of this groupchat"
+ );
+
+ stanza = u.toStanza(`
+ <presence
+ from='trollbox@montague.lit/troll'
+ to='romeo@montague.lit/orchard'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='none'
+ nick='troll'
+ role='participant'/>
+ <status code='110'/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null);
+ expect(textarea === null).toBe(false);
+ // Check now that things get restored when the user is given a voice
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "troll has been given a voice");
+ }));
+ });
+
+ describe("when muc_send_probes is true", function () {
+
+ it("sends presence probes when muc_send_probes is true",
+ mock.initConverse([], {'muc_send_probes': true}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/ralphm">
+ <body>This message will trigger a presence probe</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ await u.waitUntil(() => view.model.messages.length);
+ let occupant = view.model.messages.at(0)?.occupant;
+ expect(occupant).toBeDefined();
+ expect(occupant.get('nick')).toBe('ralphm');
+ expect(occupant.get('affiliation')).toBeUndefined();
+ expect(occupant.get('role')).toBeUndefined();
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence[type="probe"]')).pop());
+ expect(Strophe.serialize(probe)).toBe(
+ `<presence to="${muc_jid}/ralphm" type="probe" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`);
+
+ let presence = u.toStanza(
+ `<presence xmlns="jabber:client" to="${converse.jid}" from="${muc_jid}/ralphm">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="member" jid="ralph@example.org/Conversations.ZvLu" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ expect(occupant.get('affiliation')).toBe('member');
+ expect(occupant.get('role')).toBe('participant');
+
+ // Check that unavailable but affiliated occupants don't get destroyed
+ stanza = u.toStanza(`
+ <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/gonePhising">
+ <body>This message from an unavailable user will trigger a presence probe</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => view.model.messages.length === 2);
+ occupant = view.model.messages.at(1)?.occupant;
+ expect(occupant).toBeDefined();
+ expect(occupant.get('nick')).toBe('gonePhising');
+ expect(occupant.get('affiliation')).toBeUndefined();
+ expect(occupant.get('role')).toBeUndefined();
+
+ probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/gonePhising"]`)).pop());
+ expect(Strophe.serialize(probe)).toBe(
+ `<presence to="${muc_jid}/gonePhising" type="probe" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`);
+
+ presence = u.toStanza(
+ `<presence xmlns="jabber:client" type="unavailable" to="${converse.jid}" from="${muc_jid}/gonePhising">
+ <x xmlns="http://jabber.org/protocol/muc#user">
+ <item affiliation="member" jid="gonePhishing@example.org/d34dBEEF" role="participant"/>
+ </x>
+ </presence>`);
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ expect(view.model.occupants.length).toBe(3);
+ expect(occupant.get('affiliation')).toBe('member');
+ expect(occupant.get('role')).toBe('participant');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js
new file mode 100644
index 0000000..2c9f23d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js
@@ -0,0 +1,475 @@
+/*global mock, converse */
+
+const { $pres, $iq, Strophe, sizzle, u, stx } = converse.env;
+
+describe("A MUC", function () {
+
+ it("allows you to change your nickname via a modal",
+ mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+
+ expect(model.get('nick')).toBe(nick);
+ expect(model.occupants.length).toBe(1);
+ expect(model.occupants.at(0).get('nick')).toBe(nick);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ const dropdown_item = view.querySelector(".open-nickname-modal");
+ dropdown_item.click();
+
+ const modal = _converse.api.modal.get('converse-muc-nickname-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+
+ const input = modal.querySelector('input[name="nick"]');
+ expect(input.value).toBe(nick);
+
+ const newnick = 'loverboy';
+ input.value = newnick;
+ modal.querySelector('input[type="submit"]')?.click();
+
+ await u.waitUntil(() => !u.isVisible(modal));
+
+ const { sent_stanzas } = _converse.connection;
+ const sent_stanza = sent_stanzas.pop()
+ expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+ `<presence from="${_converse.jid}" id="${sent_stanza.getAttribute('id')}" to="${muc_jid}/${newnick}" xmlns="jabber:client"/>`);
+
+ // Two presence stanzas are received from the MUC service
+ _converse.connection._dataRecv(mock.createRequest(
+ stx`
+ <presence
+ xmlns="jabber:server"
+ from='${muc_jid}/${nick}'
+ id='DC352437-C019-40EC-B590-AF29E879AF98'
+ to='${_converse.jid}'
+ type='unavailable'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member'
+ jid='${_converse.jid}'
+ nick='${newnick}'
+ role='participant'/>
+ <status code='303'/>
+ <status code='110'/>
+ </x>
+ </presence>`
+ ));
+
+ expect(model.get('nick')).toBe(newnick);
+
+ _converse.connection._dataRecv(mock.createRequest(
+ stx`
+ <presence
+ xmlns="jabber:server"
+ from='${muc_jid}/${newnick}'
+ id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
+ to='${_converse.jid}'>
+ <x xmlns='http://jabber.org/protocol/muc#user'>
+ <item affiliation='member'
+ jid='${_converse.jid}'
+ role='participant'/>
+ <status code='110'/>
+ </x>
+ </presence>`
+ ));
+
+ await u.waitUntil(() => model.occupants.at(0).get('nick') === newnick);
+ expect(model.occupants.length).toBe(1);
+ }));
+
+ it("informs users if their nicknames have been changed.",
+ mock.initConverse([], {}, async function (_converse) {
+
+ /* The service then sends two presence stanzas to the full JID
+ * of each occupant (including the occupant who is changing his
+ * or her room nickname), one of type "unavailable" for the old
+ * nickname and one indicating availability for the new
+ * nickname.
+ *
+ * See: https://xmpp.org/extensions/xep-0045.html#changenick
+ *
+ * <presence
+ * from='coven@montague.lit/thirdwitch'
+ * id='DC352437-C019-40EC-B590-AF29E879AF98'
+ * to='hag66@shakespeare.lit/pda'
+ * type='unavailable'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * nick='oldhag'
+ * role='participant'/>
+ * <status code='303'/>
+ * <status code='110'/>
+ * </x>
+ * </presence>
+ *
+ * <presence
+ * from='coven@montague.lit/oldhag'
+ * id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
+ * to='hag66@shakespeare.lit/pda'>
+ * <x xmlns='http://jabber.org/protocol/muc#user'>
+ * <item affiliation='member'
+ * jid='hag66@shakespeare.lit/pda'
+ * role='participant'/>
+ * <status code='110'/>
+ * </x>
+ * </presence>
+ */
+ const { __ } = _converse;
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'oldnick');
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => view.querySelectorAll('li .occupant-nick').length, 500);
+ let occupants = view.querySelector('.occupant-list');
+ expect(occupants.childElementCount).toBe(1);
+ expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick");
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual("oldnick has entered the groupchat");
+
+ let presence = $pres().attrs({
+ from:'lounge@montague.lit/oldnick',
+ id:'DC352437-C019-40EC-B590-AF29E879AF98',
+ to:'romeo@montague.lit/pda',
+ type:'unavailable'
+ })
+ .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'owner',
+ jid: 'romeo@montague.lit/pda',
+ nick: 'newnick',
+ role: 'moderator'
+ }).up()
+ .c('status').attrs({code:'303'}).up()
+ .c('status').attrs({code:'110'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelectorAll('.chat-info').length);
+
+ expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe(
+ __(_converse.muc.new_nickname_messages["303"], "newnick")
+ );
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+
+ occupants = view.querySelector('.occupant-list');
+ expect(occupants.childElementCount).toBe(1);
+
+ presence = $pres().attrs({
+ from:'lounge@montague.lit/newnick',
+ id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67',
+ to:'romeo@montague.lit/pda'
+ })
+ .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'owner',
+ jid: 'romeo@montague.lit/pda',
+ role: 'moderator'
+ }).up()
+ .c('status').attrs({code:'110'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+ expect(view.querySelectorAll('div.chat-info').length).toBe(1);
+ expect(sizzle('div.chat-info', view)[0].textContent.trim()).toBe(
+ __(_converse.muc.new_nickname_messages["303"], "newnick")
+ );
+ occupants = view.querySelector('.occupant-list');
+ await u.waitUntil(() => sizzle('.occupant-nick:first', occupants).pop().textContent.trim() === "newnick");
+ expect(view.model.occupants.length).toBe(1);
+ expect(view.model.get('nick')).toBe("newnick");
+ }));
+
+ describe("when being entered", function () {
+
+ it("will use the user's reserved nickname, if it exists",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+
+ let stanza = await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop()
+ );
+ // We pretend this is a new room, so no disco info is returned.
+ const features_stanza = $iq({
+ from: 'lounge@montague.lit',
+ 'id': stanza.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+
+ /* <iq from='hag66@shakespeare.lit/pda'
+ * id='getnick1'
+ * to='coven@chat.shakespeare.lit'
+ * type='get'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'
+ * node='x-roomuser-item'/>
+ * </iq>
+ */
+ const iq = await u.waitUntil(() => IQ_stanzas.filter(
+ s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length
+ ).pop());
+
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+
+ `type="get" xmlns="jabber:client">`+
+ `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
+
+ /* <iq from='coven@chat.shakespeare.lit'
+ * id='getnick1'
+ * to='hag66@shakespeare.lit/pda'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'
+ * node='x-roomuser-item'>
+ * <identity
+ * category='conference'
+ * name='thirdwitch'
+ * type='text'/>
+ * </query>
+ * </iq>
+ */
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ stanza = $iq({
+ 'type': 'result',
+ 'id': iq.getAttribute('id'),
+ 'from': view.model.get('jid'),
+ 'to': _converse.connection.jid
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'})
+ .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // The user has just entered the groupchat (because join was called)
+ // and receives their own presence from the server.
+ // See example 24:
+ // https://xmpp.org/extensions/xep-0045.html#enter-pres
+ const presence = $pres({
+ to:'romeo@montague.lit/orchard',
+ from:'lounge@montague.lit/thirdwitch',
+ id:'DC352437-C019-40EC-B590-AF29E879AF97'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'member',
+ jid: 'romeo@montague.lit/orchard',
+ role: 'participant'
+ }).up()
+ .c('status').attrs({code:'110'}).up()
+ .c('status').attrs({code:'210'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length);
+ const info_text = sizzle('.chat-content .chat-info:first', view).pop().textContent.trim();
+ expect(info_text).toBe('Your nickname has been automatically set to thirdwitch');
+ }));
+
+ it("will use the nickname set in the global settings if the user doesn't have a VCard nickname",
+ mock.initConverse(['chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'},
+ async function (_converse) {
+
+ await mock.openChatRoomViaModal(_converse, 'roomy@muc.montague.lit');
+ const view = _converse.chatboxviews.get('roomy@muc.montague.lit');
+ expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch');
+ }));
+
+ it("will render a nickname form if a nickname conflict happens and muc_nickname_from_jid=false",
+ mock.initConverse([], { vcard: { nickname: '' }}, async function (_converse) {
+
+ const muc_jid = 'conflicted@muc.montague.lit';
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit/pda',
+ type: 'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by: muc_jid, type:'cancel'})
+ .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message'));
+ expect(el.textContent.trim()).toBe('The nickname you chose is reserved or currently in use, please choose a different one.');
+ }));
+
+
+ it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true",
+ mock.initConverse(['chatBoxesFetched'], {vcard: { nickname: '' }}, async function (_converse) {
+
+ const { api } = _converse;
+ const muc_jid = 'conflicting@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+ /* <presence
+ * from='coven@chat.shakespeare.lit/thirdwitch'
+ * id='n13mt3l'
+ * to='hag66@shakespeare.lit/pda'
+ * type='error'>
+ * <x xmlns='http://jabber.org/protocol/muc'/>
+ * <error by='coven@chat.shakespeare.lit' type='cancel'>
+ * <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ * </error>
+ * </presence>
+ */
+ api.settings.set('muc_nickname_from_jid', true);
+
+ const attrs = {
+ 'from': `${muc_jid}/romeo`,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit/pda',
+ 'type': 'error'
+ };
+ let presence = $pres().attrs(attrs)
+ .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({'by': muc_jid, 'type':'cancel'})
+ .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(view.model, 'join').and.callThrough();
+
+ // Simulate repeatedly that there's already someone in the groupchat
+ // with that nickname
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.join).toHaveBeenCalledWith('romeo-2');
+
+ attrs.from = `${muc_jid}/romeo-2`;
+ attrs.id = u.getUniqueId();
+ presence = $pres().attrs(attrs)
+ .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({'by': muc_jid, type:'cancel'})
+ .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ expect(view.model.join).toHaveBeenCalledWith('romeo-3');
+
+ attrs.from = `${muc_jid}/romeo-3`;
+ attrs.id = new Date().getTime();
+ presence = $pres().attrs(attrs)
+ .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({'by': muc_jid, 'type': 'cancel'})
+ .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(view.model.join).toHaveBeenCalledWith('romeo-4');
+ }));
+
+ it("will show an error message if the user's nickname doesn't conform to groupchat policy",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'conformist@muc.montague.lit'
+ await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
+
+ const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+ iq => iq.querySelector(
+ `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+ )).pop());
+ const features_stanza = $iq({
+ 'from': muc_jid,
+ 'id': iq.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+ const presence = $pres().attrs({
+ from: `${muc_jid}/romeo`,
+ id: u.getUniqueId(),
+ to:'romeo@montague.lit/pda',
+ type:'error'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
+ .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
+ .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+ expect(el.textContent.trim()).toBe("Your nickname doesn't conform to this groupchat's policies.");
+ }));
+
+ it("doesn't show the nickname field if locked_muc_nickname is true",
+ mock.initConverse(['chatBoxesFetched'], {
+ locked_muc_nickname: true,
+ muc_nickname_from_jid: true,
+ vcard: { nickname: '' },
+ }, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000)
+ const name_input = modal.querySelector('input[name="chatroom"]');
+ name_input.value = 'lounge@montague.lit';
+ expect(modal.querySelector('label[for="nickname"]')).toBe(null);
+ expect(modal.querySelector('input[name="nickname"]')).toBe(null);
+ modal.querySelector('form input[type="submit"]').click();
+ await u.waitUntil(() => _converse.chatboxes.length > 1);
+ const chatroom = _converse.chatboxes.get('lounge@montague.lit');
+ expect(chatroom.get('nick')).toBe('romeo');
+ }));
+
+ it("uses the JID node if muc_nickname_from_jid is set to true",
+ mock.initConverse(['chatBoxesFetched'], {'muc_nickname_from_jid': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000)
+ const label_nick = modal.querySelector('label[for="nickname"]');
+ expect(label_nick.textContent.trim()).toBe('Nickname:');
+ const nick_input = modal.querySelector('input[name="nickname"]');
+ expect(nick_input.value).toBe('romeo');
+ }));
+
+ it("uses the nickname passed in to converse.initialize",
+ mock.initConverse(['chatBoxesFetched'], {'nickname': 'st.nick'}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ roomspanel.querySelector('.show-add-muc-modal').click();
+ mock.closeControlBox(_converse);
+ const modal = _converse.api.modal.get('converse-add-muc-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000)
+ const label_nick = modal.querySelector('label[for="nickname"]');
+ expect(label_nick.textContent.trim()).toBe('Nickname:');
+ const nick_input = modal.querySelector('input[name="nickname"]');
+ expect(nick_input.value).toBe('st.nick');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js
new file mode 100644
index 0000000..1ae2c94
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js
@@ -0,0 +1,228 @@
+/*global mock, converse */
+
+const { $pres, sizzle, u } = converse.env;
+
+describe("The occupants sidebar", function () {
+
+ it("shows all members even if they're not currently present in the groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit'
+ const members = [{
+ 'nick': 'juliet',
+ 'jid': 'juliet@capulet.lit',
+ 'affiliation': 'member'
+ }];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.model.occupants.length === 2);
+
+ const occupants = view.querySelector('.occupant-list');
+ for (let i=0; i<mock.chatroom_names.length; i++) {
+ const name = mock.chatroom_names[i];
+ const role = mock.chatroom_roles[name].role;
+ // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/'+name
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: mock.chatroom_roles[name].affiliation,
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ role: role
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ }
+
+ await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500);
+ expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length);
+ expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length);
+
+ mock.chatroom_names.forEach(name => {
+ const model = view.model.occupants.findWhere({'nick': name});
+ const index = view.model.occupants.indexOf(model);
+ expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
+ });
+
+ // Test users leaving the groupchat
+ // https://xmpp.org/extensions/xep-0045.html#exit
+ for (let i=mock.chatroom_names.length-1; i>-1; i--) {
+ const name = mock.chatroom_names[i];
+ // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/'+name,
+ type: 'unavailable'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: mock.chatroom_roles[name].affiliation,
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ role: 'none'
+ }).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ expect(occupants.querySelectorAll('li').length).toBe(8);
+ }
+ const presence = $pres({
+ to: 'romeo@montague.lit/pda',
+ from: 'lounge@montague.lit/nonmember'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: null,
+ jid: 'servant@montague.lit',
+ role: 'visitor'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => occupants.querySelectorAll('li').length > 8, 500);
+ expect(occupants.querySelectorAll('li').length).toBe(9);
+ expect(view.model.occupants.length).toBe(9);
+ expect(view.model.occupants.filter(o => o.isMember()).length).toBe(8);
+
+ view.model.rejoin();
+ // Test that members aren't removed when we reconnect
+ expect(view.model.occupants.length).toBe(8);
+ view.model.session.set('connection_status', converse.ROOMSTATUS.ENTERED); // Hack
+ await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length === 8);
+ }));
+
+ it("shows users currently present in the groupchat",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ var view = _converse.chatboxviews.get('lounge@montague.lit');
+ const occupants = view.querySelector('.occupant-list');
+ for (var i=0; i<mock.chatroom_names.length; i++) {
+ const name = mock.chatroom_names[i];
+ // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/'+name
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'none',
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ role: 'participant'
+ }).up()
+ .c('status');
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ }
+
+ await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500);
+ expect(occupants.querySelectorAll('li').length).toBe(1+mock.chatroom_names.length);
+
+ mock.chatroom_names.forEach(name => {
+ const model = view.model.occupants.findWhere({'nick': name});
+ const index = view.model.occupants.indexOf(model);
+ expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
+ });
+
+ // Test users leaving the groupchat
+ // https://xmpp.org/extensions/xep-0045.html#exit
+ for (i=mock.chatroom_names.length-1; i>-1; i--) {
+ const name = mock.chatroom_names[i];
+ // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/'+name,
+ type: 'unavailable'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: "none",
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ role: 'none'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ }
+ await u.waitUntil(() => occupants.querySelectorAll('li').length === 1);
+ }));
+
+ it("lets you click on an occupant to insert it into the chat textarea",
+ mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ var view = _converse.chatboxviews.get(muc_jid);
+ const occupants = view.querySelector('.occupant-list');
+ const name = mock.chatroom_names[0];
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/'+name
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'none',
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ role: 'participant'
+ }).up()
+ .c('status');
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500);
+ expect(occupants.querySelectorAll('li').length).toBe(2);
+ view.querySelectorAll('.occupant-nick')[1].click()
+
+ const textarea = view.querySelector('.chat-textarea');
+ expect(textarea.value).toBe('@Dyon van de Wege ');
+ }));
+
+ it("indicates moderators and visitors by means of a special css class and tooltip",
+ mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ let contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length, 500);
+ let occupants = view.querySelectorAll('.occupant-list li');
+ expect(occupants.length).toBe(1);
+ expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
+ expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
+ expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Owner');
+ expect(sizzle('.badge:last', occupants[0]).pop().textContent.trim()).toBe('Moderator');
+
+ var presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/moderatorman'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'admin',
+ jid: contact_jid,
+ role: 'moderator',
+ }).up()
+ .c('status').attrs({code:'110'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 1, 500);
+ occupants = view.querySelectorAll('.occupant-list li');
+ expect(occupants.length).toBe(2);
+ expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("moderatorman");
+ expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe("romeo");
+ expect(occupants[0].querySelectorAll('.badge').length).toBe(2);
+ expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Admin');
+ expect(occupants[0].querySelectorAll('.badge')[1].textContent.trim()).toBe('Moderator');
+
+ expect(occupants[0].getAttribute('title')).toBe(
+ contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
+ );
+
+ contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:'lounge@montague.lit/visitorwoman'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ jid: contact_jid,
+ role: 'visitor',
+ }).up()
+ .c('status').attrs({code:'110'}).nodeTree;
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 2, 500);
+ occupants = view.querySelector('.occupant-list').querySelectorAll('li');
+ expect(occupants.length).toBe(3);
+ expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe("visitorwoman");
+ expect(occupants[2].querySelectorAll('.badge').length).toBe(1);
+ expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('Visitor');
+ expect(occupants[2].getAttribute('title')).toBe(
+ contact_jid + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.'
+ );
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js
new file mode 100644
index 0000000..147c94e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js
@@ -0,0 +1,221 @@
+/*global mock, converse */
+
+const { Strophe } = converse.env;
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("XEP-0437 Room Activity Indicators", function () {
+
+ it("will be activated for a MUC that becomes hidden",
+ mock.initConverse(
+ [], {
+ 'allow_bookmarks': false, // Hack to get the rooms list to render
+ 'muc_subscribe_to_rai': true,
+ 'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ expect(view.model.get('hidden')).toBe(false);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const first_msg_id = _converse.connection.getUniqueId();
+ const last_msg_id = _converse.connection.getUniqueId();
+ let message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>1st MAM Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ message = u.toStanza(
+ `<message xmlns="jabber:client"
+ to="romeo@montague.lit/orchard"
+ from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/>
+ <message from="${muc_jid}/some1" type="groupchat">
+ <body>2nd MAM Message</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message));
+
+ const result = u.toStanza(
+ `<iq type='result' id='${iq_get.getAttribute('id')}'>
+ <fin xmlns='urn:xmpp:mam:2'>
+ <set xmlns='http://jabber.org/protocol/rsm'>
+ <first index='0'>${first_msg_id}</first>
+ <last>${last_msg_id}</last>
+ <count>2</count>
+ </set>
+ </fin>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => view.model.messages.length === 2);
+
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ view.model.save({'hidden': true});
+ await u.waitUntil(() => sent_stanzas.length === 3);
+
+ expect(Strophe.serialize(sent_stanzas[0])).toBe(
+ `<message from="${_converse.jid}" id="${sent_stanzas[0].getAttribute('id')}" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+ `<received id="${last_msg_id}" xmlns="urn:xmpp:chat-markers:0"/>`+
+ `</message>`
+ );
+ expect(Strophe.serialize(sent_stanzas[1])).toBe(
+ `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`
+ );
+ expect(Strophe.serialize(sent_stanzas[2])).toBe(
+ `<presence to="montague.lit" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `<rai xmlns="urn:xmpp:rai:0"/>`+
+ `</presence>`
+ );
+
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
+ expect(view.model.get('has_activity')).toBe(false);
+
+ const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
+
+ const activity_stanza = u.toStanza(`
+ <message from="${Strophe.getDomainFromJid(muc_jid)}">
+ <rai xmlns="urn:xmpp:rai:0">
+ <activity>${muc_jid}</activity>
+ </rai>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(activity_stanza));
+
+ await u.waitUntil(() => view.model.get('has_activity'));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+ }));
+
+ it("will be activated for a MUC that starts out hidden",
+ mock.initConverse(
+ [], {
+ 'allow_bookmarks': false, // Hack to get the rooms list to render
+ 'muc_subscribe_to_rai': true,
+ 'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ const { api } = _converse;
+ expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
+
+ const muc_jid = 'lounge@montague.lit';
+ const nick = 'romeo';
+ const sent_stanzas = _converse.connection.sent_stanzas;
+
+ const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false);
+ await mock.getRoomFeatures(_converse, muc_jid, []);
+ await mock.receiveOwnMUCPresence(_converse, muc_jid, nick);
+ await muc_creation_promise;
+
+ const model = _converse.chatboxes.get(muc_jid);
+ await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+ expect(model.get('hidden')).toBe(true);
+
+
+ const getSentPresences = () => sent_stanzas.filter(s => s.nodeName === 'presence');
+ await u.waitUntil(() => getSentPresences().length === 3, 500);
+ const sent_presences = getSentPresences();
+
+ expect(Strophe.serialize(sent_presences[1])).toBe(
+ `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`
+ );
+ expect(Strophe.serialize(sent_presences[2])).toBe(
+ `<presence to="montague.lit" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `<rai xmlns="urn:xmpp:rai:0"/>`+
+ `</presence>`
+ );
+
+ await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
+ expect(model.get('has_activity')).toBe(false);
+
+ const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
+
+ const activity_stanza = u.toStanza(`
+ <message from="${Strophe.getDomainFromJid(muc_jid)}">
+ <rai xmlns="urn:xmpp:rai:0">
+ <activity>${muc_jid}</activity>
+ </rai>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(activity_stanza));
+
+ await u.waitUntil(() => model.get('has_activity'));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+ }));
+
+
+ it("may not be activated due to server resource constraints",
+ mock.initConverse(
+ [], {
+ 'allow_bookmarks': false, // Hack to get the rooms list to render
+ 'muc_subscribe_to_rai': true,
+ 'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
+
+ const muc_jid = 'lounge@montague.lit';
+ const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ expect(model.get('hidden')).toBe(false);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ model.save({'hidden': true});
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'presence').length === 2);
+
+ const sent_presences = sent_stanzas.filter(s => s.nodeName === 'presence');
+ expect(Strophe.serialize(sent_presences[0])).toBe(
+ `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`
+ );
+ expect(Strophe.serialize(sent_presences[1])).toBe(
+ `<presence to="montague.lit" xmlns="jabber:client">`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `<rai xmlns="urn:xmpp:rai:0"/>`+
+ `</presence>`
+ );
+ // If an error presence with "resource-constraint" is returned, we rejoin
+ const activity_stanza = u.toStanza(`
+ <presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}">
+ <error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+ </presence>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(activity_stanza));
+
+ await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+ }));
+
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js
new file mode 100644
index 0000000..fda19a7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js
@@ -0,0 +1,1084 @@
+/*global mock, converse */
+
+const { Strophe, $iq } = converse.env;
+const u = converse.env.utils;
+
+
+async function sendAndThenRetractMessage (_converse, view) {
+ view.model.sendMessage({'body': 'hello world'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+ const msg_obj = view.model.messages.last();
+ const reflection_stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${msg_obj.get('from')}"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <msg_body>${msg_obj.get('message')}</msg_body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+ by="lounge@montague.lit"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+ </message>`);
+ await view.model.handleMessageStanza(reflection_stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+
+ const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
+ retract_button.click();
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+}
+
+
+describe("Message Retractions", function () {
+
+ describe("A groupchat message retraction", function () {
+
+ it("is not applied if it's not from the right author",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Hello world</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>
+ `);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await view.model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+
+ const retraction_stanza = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo">
+ <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns="urn:xmpp:message-retract:0" />
+ </apply-to>
+ </message>
+ `);
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+ expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(2);
+ expect(view.model.messages.at(1).get('retracted')).toBeTruthy();
+ expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy();
+ expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true);
+
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+ }));
+
+ it("can be received before the message it pertains to",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const date = (new Date()).toISOString();
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const retraction_stanza = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+ <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+ <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
+ </apply-to>
+ </message>
+ `);
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(converse.env.log, 'warn');
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
+ await u.waitUntil(() => view.model.messages.length === 1);
+ expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
+ expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true);
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Hello world</body>
+ <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(received_stanza));
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1, 1000);
+ expect(view.model.messages.length).toBe(1);
+
+ const message = view.model.messages.at(0)
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('dangling_retraction')).toBe(false);
+ expect(message.get('origin_id')).toBe('origin-id-1');
+ expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
+ expect(message.get('time')).toBe(date);
+ expect(message.get('type')).toBe('groupchat');
+ expect(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true);
+ }));
+ });
+
+ describe("A groupchat message moderator retraction", function () {
+
+ it("can be received before the message it pertains to",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const date = (new Date()).toISOString();
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const retraction_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1">
+ <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison">
+ <retract xmlns="urn:xmpp:message-retract:0"/>
+ <reason>Insults</reason>
+ </moderated>
+ </apply-to>
+ </message>
+ `);
+ const view = _converse.chatboxviews.get(muc_jid);
+ spyOn(converse.env.log, 'warn');
+ spyOn(view.model, 'handleModeration').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+ await u.waitUntil(() => view.model.handleModeration.calls.count() === 1);
+ await u.waitUntil(() => view.model.messages.length === 1);
+ expect(await view.model.handleModeration.calls.first().returnValue).toBe(true);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true);
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Hello world</body>
+ <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>
+
+ `);
+
+ _converse.connection._dataRecv(mock.createRequest(received_stanza));
+ await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+
+ const message = view.model.messages.at(0)
+ expect(message.get('moderated')).toBe('retracted');
+ expect(message.get('dangling_moderation')).toBe(false);
+ expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1');
+ expect(message.get('time')).toBe(date);
+ expect(message.get('type')).toBe('groupchat');
+ expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true);
+ }));
+ });
+
+
+ describe("A message retraction", function () {
+
+ it("can be received before the message it pertains to",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const date = (new Date()).toISOString();
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+
+ const retraction_stanza = u.toStanza(`
+ <message id="${u.getUniqueId()}"
+ to="${_converse.bare_jid}"
+ from="${contact_jid}"
+ type="chat"
+ xmlns="jabber:client">
+ <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns="urn:xmpp:message-retract:0"/>
+ </apply-to>
+ </message>
+ `);
+
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+ await u.waitUntil(() => view.model.messages.length === 1);
+ const message = view.model.messages.at(0);
+ expect(message.get('dangling_retraction')).toBe(true);
+ expect(message.get('is_ephemeral')).toBe(false);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(view.querySelectorAll('.chat-msg').length).toBe(0);
+
+ const stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.bare_jid}"
+ type="chat"
+ id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+ from="${contact_jid}">
+ <body>Hello world</body>
+ <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+ expect(view.model.messages.length).toBe(1);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('dangling_retraction')).toBe(false);
+ expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3');
+ expect(message.get('time')).toBe(date);
+ expect(message.get('type')).toBe('chat');
+ }));
+ });
+
+ describe("A Received Chat Message", function () {
+
+ it("can be followed up by a retraction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.bare_jid}"
+ type="chat"
+ id="29132ea0-0121-2897-b121-36638c259554"
+ from="${contact_jid}">
+ <body>😊</body>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
+ </message>`);
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length === 1);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+ stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.bare_jid}"
+ type="chat"
+ id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+ from="${contact_jid}">
+ <body>This message will be retracted</body>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+ </message>`);
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length === 2);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+ const retraction_stanza = u.toStanza(`
+ <message id="${u.getUniqueId()}"
+ to="${_converse.bare_jid}"
+ from="${contact_jid}"
+ type="chat"
+ xmlns="jabber:client">
+ <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns="urn:xmpp:message-retract:0"/>
+ </apply-to>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+
+ expect(view.model.messages.length).toBe(2);
+
+ const message = view.model.messages.at(1);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+ expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message');
+ expect(u.hasClass('chat-msg--followup', view.querySelector('.chat-msg--retracted'))).toBe(true);
+ }));
+ });
+
+ describe("A Sent Chat Message", function () {
+
+ it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: ''} }, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+
+ view.model.sendMessage({'body': 'hello world'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+ const message = view.model.messages.at(0);
+ expect(view.model.messages.length).toBe(1);
+ expect(message.get('retracted')).toBeFalsy();
+ expect(message.get('editable')).toBeTruthy();
+
+
+ const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
+ retract_button.click();
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+
+ const msg_obj = view.model.messages.at(0);
+ const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+ expect(Strophe.serialize(retraction_stanza)).toBe(
+ `<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
+ `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+ `</apply-to>`+
+ `</message>`);
+
+ expect(view.model.messages.length).toBe(1);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('editable')).toBeFalsy();
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+ expect(el.textContent.trim()).toBe('Romeo Montague has removed this message');
+ }));
+ });
+
+
+ describe("A Received Groupchat Message", function () {
+
+ it("can be followed up by a retraction by the author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Hello world</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/>
+ </message>
+ `);
+ const view = _converse.chatboxviews.get(muc_jid);
+ await view.model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+
+ const retraction_stanza = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+ <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+ <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
+ </apply-to>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
+
+ // We opportunistically save the message as retracted, even before receiving the retraction message
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+ expect(msg_el.textContent.trim()).toBe('eve has removed this message');
+ expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null);
+ }));
+
+
+ it("can be retracted by a moderator, with the IQ response received before the retraction message",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Visit this site to get free Bitcoin!</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>
+ `);
+ await view.model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => view.model.messages.length === 1);
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+
+ const reason = "This content is inappropriate for this forum!"
+ const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
+ retract_button.click();
+
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+
+ const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
+ reason_input.value = 'This content is inappropriate for this forum!';
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+ const message = view.model.messages.at(0);
+ const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
+ `<moderate xmlns="urn:xmpp:message-moderate:0">`+
+ `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+ `<reason>This content is inappropriate for this forum!</reason>`+
+ `</moderate>`+
+ `</apply-to>`+
+ `</iq>`);
+
+ const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(result_iq));
+
+ // We opportunistically save the message as retracted, even before receiving the retraction message
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+ expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
+
+ const qel = msg_el.querySelector('q');
+ expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!');
+
+ // The server responds with a retraction message
+ const retraction = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ <reason>${reason}</reason>
+ </moderated>
+ </apply-to>
+ </message>`);
+ await view.model.handleMessageStanza(retraction);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ }));
+
+ it("can not be retracted if the MUC doesn't support message moderation",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Visit this site to get free Bitcoin!</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>
+ `);
+ await view.model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => view.querySelector('.chat-msg__content'));
+ expect(view.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null);
+ const result = await view.model.canModerateMessages();
+ expect(result).toBe(false);
+ }));
+
+
+ it("can be retracted by a moderator, with the retraction message received before the IQ response",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+
+ const received_stanza = u.toStanza(`
+ <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'>
+ <body>Visit this site to get free Bitcoin!</body>
+ <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+ </message>
+ `);
+ await view.model.handleMessageStanza(received_stanza);
+ await u.waitUntil(() => view.model.messages.length === 1);
+ expect(view.model.messages.length).toBe(1);
+
+ const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
+ retract_button.click();
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+
+ const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]');
+ const reason = "This content is inappropriate for this forum!"
+ reason_input.value = reason;
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+ const message = view.model.messages.at(0);
+ const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+ // The server responds with a retraction message
+ const retraction = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ <reason>${reason}</reason>
+ </moderated>
+ </apply-to>
+ </message>`);
+ await view.model.handleMessageStanza(retraction);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(msg_el.textContent).toBe('romeo has removed this message');
+ const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
+ expect(qel.textContent).toBe('This content is inappropriate for this forum!');
+
+ const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(result_iq));
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid);
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ }));
+ });
+
+
+ describe("A Sent Groupchat Message", function () {
+
+ it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+ occupant.save('role', 'member');
+ const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000);
+
+ const msg_obj = view.model.messages.last();
+ expect(msg_obj.get('retracted')).toBeTruthy();
+
+ expect(Strophe.serialize(retraction_stanza)).toBe(
+ `<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+
+ `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+ `</apply-to>`+
+ `</message>`);
+
+ const message = view.model.messages.last();
+ expect(message.get('is_ephemeral')).toBe(false);
+ expect(message.get('editable')).toBeFalsy();
+
+ const stanza_id = message.get(`stanza_id ${muc_jid}`);
+ // The server responds with a retraction message
+ const reflection = u.toStanza(`
+ <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ </apply-to>
+ </message>`);
+
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(reflection));
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1, 1000);
+
+ await u.waitUntil(() => view.model.messages.length === 2, 1000);
+ expect(view.model.messages.last().get('retracted')).toBeTruthy();
+ expect(view.model.messages.last().get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.last().get('editable')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent).toBe('romeo has removed this message');
+ }));
+
+ it("can be retracted by its author, causing an error message in response",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+
+ expect(occupant.get('role')).toBe('moderator');
+ occupant.save('role', 'member');
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator"));
+ const retraction_stanza = await sendAndThenRetractMessage(_converse, view);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000);
+
+ expect(view.model.messages.length).toBe(1);
+ await u.waitUntil(() => view.model.messages.last().get('retracted'), 1000);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent.trim()).toBe('romeo has removed this message');
+
+ const message = view.model.messages.last();
+ const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
+ // The server responds with an error message
+ const error = u.toStanza(`
+ <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo">
+ <error by='${muc_jid}' type='auth'>
+ <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ </apply-to>
+ </message>`);
+
+ _converse.connection._dataRecv(mock.createRequest(error));
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 1, 1000);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0, 1000);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+
+ const errmsg = view.querySelector('.chat-msg__error');
+ expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message.");
+ }));
+
+ it("can be retracted by its author, causing a timeout error in response",
+ mock.initConverse(['chatBoxesFetched'], { stanza_timeout: 1 }, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+ occupant.save('role', 'member');
+ await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator"))
+ await sendAndThenRetractMessage(_converse, view);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.last().get('retracted')).toBeTruthy();
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent.trim()).toBe('romeo has removed this message');
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
+ expect(view.model.messages.at(0).get('editable')).toBeTruthy();
+
+ const error_messages = view.querySelectorAll('.chat-msg__error');
+ expect(error_messages.length).toBe(1);
+ expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.');
+ }));
+
+
+ it("can be retracted by a moderator", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+
+ view.model.sendMessage({'body': 'Visit this site to get free bitcoin'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ const stanza_id = 'retraction-id-1';
+ const msg_obj = view.model.messages.at(0);
+ const reflection_stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${msg_obj.get('from')}"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <msg_body>${msg_obj.get('message')}</msg_body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="${stanza_id}"
+ by="lounge@montague.lit"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+ </message>`);
+ await view.model.handleMessageStanza(reflection_stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('editable')).toBe(true);
+
+ // The server responds with a retraction message
+ const reason = "This content is inappropriate for this forum!"
+ const retraction = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ <reason>${reason}</reason>
+ </moderated>
+ </apply-to>
+ </message>`);
+ await view.model.handleMessageStanza(retraction);
+ expect(view.model.messages.length).toBe(1);
+ await u.waitUntil(() => view.model.messages.at(0).get('moderated') === 'retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ }));
+
+ it("can be retracted by the sender if they're a moderator",
+ mock.initConverse(['chatBoxesFetched'], {'allow_message_retraction': 'moderator'}, async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const occupant = view.model.getOwnOccupant();
+ expect(occupant.get('role')).toBe('moderator');
+
+ view.model.sendMessage({'body': 'Visit this site to get free bitcoin'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+ // Check that you can only edit a message before it's been
+ // reflected. You can't retract because it hasn't
+ await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-edit'));
+ expect(view.querySelectorAll('.chat-msg__action').length).toBe(1);
+
+ const stanza_id = 'retraction-id-1';
+ const msg_obj = view.model.messages.at(0);
+ const reflection_stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ from="${msg_obj.get('from')}"
+ to="${_converse.connection.jid}"
+ type="groupchat">
+ <msg_body>${msg_obj.get('message')}</msg_body>
+ <stanza-id xmlns="urn:xmpp:sid:0"
+ id="${stanza_id}"
+ by="lounge@montague.lit"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+ </message>`);
+
+ await view.model.handleMessageStanza(reflection_stanza);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('editable')).toBe(true);
+
+ const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
+ retract_button.click();
+ await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
+ const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
+ submit_button.click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop());
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+ `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
+ `<moderate xmlns="urn:xmpp:message-moderate:0">`+
+ `<retract xmlns="urn:xmpp:message-retract:0"/>`+
+ `<reason></reason>`+
+ `</moderate>`+
+ `</apply-to>`+
+ `</iq>`);
+
+ const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(result_iq));
+
+ // We opportunistically save the message as retracted, even before receiving the retraction message
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+
+ const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message');
+ expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message');
+ expect(msg_el.querySelector('q')).toBe(null);
+
+ // The server responds with a retraction message
+ const retraction = u.toStanza(`
+ <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+ <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
+ <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns='urn:xmpp:message-retract:0' />
+ </moderated>
+ </apply-to>
+ </message>`);
+ await view.model.handleMessageStanza(retraction);
+ expect(view.model.messages.length).toBe(1);
+ expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
+ expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined);
+ expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
+ expect(view.model.messages.at(0).get('editable')).toBe(false);
+ }));
+ });
+
+
+ describe("when archived", function () {
+
+ it("may be returned as a tombstone message",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]);
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+ const view = _converse.chatboxviews.get(contact_jid);
+ const first_id = u.getUniqueId();
+
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+ const first_message = u.toStanza(`
+ <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+ <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}">
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/>
+ <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0">
+ <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/>
+ <body>😊</body>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(first_message));
+
+ const tombstone = u.toStanza(`
+ <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+ <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}">
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
+ <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1">
+ <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
+ <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+ const last_id = u.getUniqueId();
+ const retraction = u.toStanza(`
+ <message id='${u.getUniqueId()}' to='${_converse.jid}'>
+ <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}">
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
+ <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'>
+ <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns='urn:xmpp:message-retract:0'/>
+ </apply-to>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(retraction));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t(first_id).up()
+ .c('last').t(last_id).up()
+ .c('count').t('2');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3);
+
+ expect(view.model.messages.length).toBe(2);
+ const message = view.model.messages.at(1);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('is_tombstone')).toBe(true);
+ expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+ expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false);
+ expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent.trim()).toBe('Mercutio has removed this message');
+ expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false);
+ }));
+
+ it("may be returned as a tombstone groupchat message",
+ mock.initConverse(
+ ['discoInitialized'], {},
+ async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+
+ const first_id = u.getUniqueId();
+ const tombstone = u.toStanza(`
+ <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+ <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
+ <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
+ <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ spyOn(view.model, 'handleRetraction').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+ const last_id = u.getUniqueId();
+ const retraction = u.toStanza(`
+ <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+ <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1">
+ <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
+ <retract xmlns="urn:xmpp:message-retract:0"/>
+ </apply-to>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(retraction));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t(first_id).up()
+ .c('last').t(last_id).up()
+ .c('count').t('2');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => view.model.messages.length === 1);
+ let message = view.model.messages.at(0);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('is_tombstone')).toBe(true);
+
+ await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
+ expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false);
+ expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(true);
+ expect(view.model.messages.length).toBe(1);
+ message = view.model.messages.at(0);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('is_tombstone')).toBe(true);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent.trim()).toBe('eve has removed this message');
+ }));
+
+ it("may be returned as a tombstone moderated groupchat message",
+ mock.initConverse(
+ ['discoInitialized', 'chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop());
+ const queryid = stanza.querySelector('query').getAttribute('queryid');
+
+ const first_id = u.getUniqueId();
+ const tombstone = u.toStanza(`
+ <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+ <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1">
+ <moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'>
+ <retracted xmlns="urn:xmpp:message-retract:0"/>
+ <reason>This message contains inappropriate content</reason>
+ </moderated>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ spyOn(view.model, 'handleModeration').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(tombstone));
+
+ const last_id = u.getUniqueId();
+ const retraction = u.toStanza(`
+ <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+ <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+ <message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1">
+ <apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0">
+ <moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'>
+ <retract xmlns="urn:xmpp:message-retract:0"/>
+ <reason>This message contains inappropriate content</reason>
+ </moderated>
+ </apply-to>
+ </message>
+ </forwarded>
+ </result>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(retraction));
+
+ const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
+ .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
+ .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'})
+ .c('first', {'index': '0'}).t(first_id).up()
+ .c('last').t(last_id).up()
+ .c('count').t('2');
+ _converse.connection._dataRecv(mock.createRequest(iq_result));
+
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.length).toBe(1);
+ let message = view.model.messages.at(0);
+ await u.waitUntil(() => message.get('retracted'));
+ expect(message.get('is_tombstone')).toBe(true);
+
+ await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
+ expect(await view.model.handleModeration.calls.first().returnValue).toBe(false);
+ expect(await view.model.handleModeration.calls.all()[1].returnValue).toBe(true);
+
+ expect(view.model.messages.length).toBe(1);
+ message = view.model.messages.at(0);
+ expect(message.get('retracted')).toBeTruthy();
+ expect(message.get('is_tombstone')).toBe(true);
+ expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length, 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1);
+ const el = view.querySelector('.chat-msg--retracted .chat-msg__message div');
+ expect(el.textContent.trim()).toBe('A moderator has removed this message');
+ const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
+ expect(qel.textContent.trim()).toBe('This message contains inappropriate content');
+ }));
+ });
+})
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js
new file mode 100644
index 0000000..e285892
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js
@@ -0,0 +1,58 @@
+/*global mock, converse */
+
+const { u, $msg } = converse.env;
+
+describe("An incoming groupchat Message", function () {
+
+ it("can be styled with span XEP-0393 message styling hints that contain mentions",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const msg_text = "This *message mentions romeo*";
+ const msg = $msg({
+ from: 'lounge@montague.lit/gibson',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(msg_text).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'23', 'end':'29', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree;
+ await view.model.handleMessageStanza(msg);
+ const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(message.classList.length).toEqual(1);
+
+ const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This <span class="styling-directive">*</span>'+
+ '<b>message mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span></b>'+
+ '<span class="styling-directive">*</span>');
+ }));
+
+ it("will not have styling applied to mentioned nicknames themselves",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ const msg_text = "x_y_z_ hello";
+ const msg = $msg({
+ from: 'lounge@montague.lit/gibson',
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(msg_text).up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'6', 'type':'mention', 'uri':'xmpp:xyz@montague.lit'}).nodeTree;
+ await view.model.handleMessageStanza(msg);
+ const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(message.classList.length).toEqual(1);
+
+ const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="mention" data-uri="xmpp:xyz@montague.lit">x_y_z_</span> hello');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js
new file mode 100644
index 0000000..d0d2db2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js
@@ -0,0 +1,18 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+describe('The visible_toolbar_buttons configuration setting', function () {
+
+ it("can be used to show a participants toggle in a MUC's toolbar",
+ mock.initConverse([], { 'visible_toolbar_buttons': { 'toggle_occupants': true } },
+ async (_converse) => {
+
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => view.querySelector('converse-chat-toolbar .toggle_occupants'));
+ expect(1).toBe(1);
+ })
+ );
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js
new file mode 100644
index 0000000..a7494af
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js
@@ -0,0 +1,489 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+describe("A Groupchat Message", function () {
+
+ it("will render an unfurl based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg";
+ const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src);
+ expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url);
+ }));
+
+ it("will render an unfurl with limited OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ /* Some sites don't include ogp data such as title, description and
+ * url. This test is to check that we fall back gracefully */
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://mempool.space</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://mempool.space');
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://conversejs.org/dist/images/custom_emojis/converse.png" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/png" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1000" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="500" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe('https://conversejs.org/dist/images/custom_emojis/converse.png');
+ expect(unfurl.querySelector('.card-body')).toBe(null);
+ }));
+
+ it("will render an unfurl containing a GIF", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+ const unfurl_url = "https://giphy.com/gifs/giphyqa-4YY4DnqeUDBXNTcYMu";
+ const gif_url = "https://media4.giphy.com/media/4YY4DnqeUDBXNTcYMu/giphy.gif?foo=bar";
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>${unfurl_url}</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe(unfurl_url);
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Animated GIF" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Alright then, keep your secrets" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${gif_url}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/gif" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="360" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="302" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(gif_url);
+ }));
+
+ it("will render multiple unfurls based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com');
+
+ let metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+ await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 1);
+
+ metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://duckduckgo.com" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="DuckDuckGo" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://duckduckgo.com/assets/logo_social-media.png" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="DuckDuckGo - Privacy, simplified." />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs." />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2);
+ }));
+
+ it("will not render an unfurl received from a MUC participant", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ spyOn(view.model, 'handleMetadataFastening').and.callThrough();
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}/arzu" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ await u.waitUntil(() => view.model.handleMetadataFastening.calls.count());
+ expect(view.model.handleMetadataFastening.calls.first().returnValue).toBe(false);
+ expect(view.querySelector('converse-message-unfurl')).toBe(null);
+ }));
+
+ it("will not render an unfurl based on OGP data if render_media is false",
+ mock.initConverse(['chatBoxesFetched'],
+ { 'render_media': false },
+ async function (_converse) {
+
+ const { api } = _converse;
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ spyOn(view.model, 'handleMetadataFastening').and.callThrough();
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ expect(view.querySelector('converse-message-unfurl')).toBe(null);
+
+ api.settings.set('render_media', true);
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+
+ let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'));
+ expect(button.textContent.trim()).toBe('Hide media');
+ button.click();
+
+ await u.waitUntil(() => !view.querySelector('converse-message-unfurl'), 1000);
+ button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'));
+ expect(button.textContent.trim()).toBe('Show media');
+ }));
+
+ it("will only render a single unfurl when receiving the same OGP data multiple times",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ spyOn(view.model, 'handleMetadataFastening').and.callThrough();
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ await u.waitUntil(() => view.model.handleMetadataFastening.calls.count());
+ const unfurls = await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl'));
+ expect(unfurls.length).toBe(1);
+ }));
+
+ it("will not render an unfurl image if the domain is not in allowed_image_domains",
+ mock.initConverse(['chatBoxesFetched'],
+ {'allowed_image_domains': []},
+ async function (_converse) {
+
+ const { api } = _converse;
+
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ await u.waitUntil(() => !view.querySelector('converse-message-unfurl'));
+
+ api.settings.set('allowed_image_domains', null);
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ }));
+
+ it("lets the user hide an unfurl",
+ mock.initConverse(['chatBoxesFetched'],
+ {'render_media': true},
+ async function (_converse) {
+
+ const { api } = _converse;
+
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const message_stanza = u.toStanza(`
+ <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(message_stanza));
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'));
+ expect(button.textContent.trim()).toBe('Hide media');
+ button.click();
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750);
+ button = view.querySelector('.chat-msg__content .chat-msg__action-hide-previews');
+ expect(button.textContent.trim()).toBe('Show media');
+ button.click();
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750);
+
+ // Check that the image doesn't render if the domain is not allowed
+ expect(view.querySelector('converse-message-unfurl .chat-image')).not.toBe(null);
+ api.settings.set('allowed_image_domains', []);
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') === null);
+ api.settings.set('allowed_image_domains', undefined);
+ await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') !== null);
+ }));
+
+ it("will not render an unfurl that has been removed in a subsequent correction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const nick = 'romeo';
+ const muc_jid = 'lounge@muc.montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+ const view = _converse.chatboxviews.get(muc_jid);
+
+ const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg";
+ const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
+
+ spyOn(_converse.connection, 'send').and.callThrough();
+
+ const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+ const message_form = view.querySelector('converse-muc-message-form');
+ textarea.value = unfurl_url;
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ message_form.onKeyDown(enter_event);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe(unfurl_url);
+
+ let msg = _converse.connection.send.calls.all()[1].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(
+ `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+ `<body>${unfurl_url}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+
+ const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(el.textContent).toBe(unfurl_url);
+
+ const metadata_stanza = u.toStanza(`
+ <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
+ <apply-to xmlns="urn:xmpp:fasten:0" id="${msg.getAttribute('id')}">
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
+ <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
+ </apply-to>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(metadata_stanza));
+
+ const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
+ expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src);
+ expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url);
+
+ // Modify the message to use a different URL
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe(unfurl_url);
+ textarea.value = "never mind";
+ message_form.onKeyDown(enter_event);
+
+ const getSentMessages = () => _converse.connection.send.calls.all().map(c => c.args[0]).filter(s => s.nodeName === 'message');
+ await u.waitUntil(() => getSentMessages().length == 2);
+ msg = getSentMessages().pop();
+ expect(Strophe.serialize(msg))
+ .toBe(
+ `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+ `<body>never mind</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<replace id="${msg.querySelector('replace')?.getAttribute('id')}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js
new file mode 100644
index 0000000..35d062e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js
@@ -0,0 +1,54 @@
+/*global mock, converse */
+
+const $pres = converse.env.$pres;
+const u = converse.env.utils;
+
+describe("XSS", function () {
+ describe("A Groupchat", function () {
+
+ it("escapes occupant nicknames when rendering them, to avoid JS-injection attacks",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538"
+ * from="oo@conference.chat.example.org/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;">
+ * <x xmlns="http://jabber.org/protocol/muc#user">
+ * <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/>
+ * <status code="110"/>
+ * </x>
+ * </presence>"
+ */
+ const presence = $pres({
+ to:'romeo@montague.lit/pda',
+ from:"lounge@montague.lit/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;"
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ jid: 'someone@montague.lit',
+ role: 'moderator',
+ }).up()
+ .c('status').attrs({code:'110'}).nodeTree;
+
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => view.querySelectorAll('.occupant-list .occupant-nick').length === 2);
+ const occupants = view.querySelectorAll('.occupant-list li .occupant-nick');
+ expect(occupants.length).toBe(2);
+ expect(occupants[0].textContent.trim()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;");
+ }));
+
+ it("escapes the subject before rendering it, to avoid JS-injection attacks",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc');
+ spyOn(window, 'alert');
+ const subject = '<img src="x" onerror="alert(\'XSS\');"/>';
+ const view = _converse.chatboxviews.get('jdev@conference.jabber.org');
+ view.model.set({'subject': {
+ 'text': subject,
+ 'author': 'ralphm'
+ }});
+ const text = await u.waitUntil(() => view.querySelector('.chat-head__desc')?.textContent.trim());
+ expect(text).toBe(subject);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js
new file mode 100644
index 0000000..b03b5d3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js
@@ -0,0 +1,341 @@
+import './modals/occupant.js';
+import './modals/moderator-tools.js';
+import log from "@converse/headless/log";
+import tplSpinner from 'templates/spinner.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { html } from "lit";
+import { setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js';
+
+const { Strophe, u } = converse.env;
+
+const COMMAND_TO_AFFILIATION = {
+ 'admin': 'admin',
+ 'ban': 'outcast',
+ 'member': 'member',
+ 'owner': 'owner',
+ 'revoke': 'none'
+};
+const COMMAND_TO_ROLE = {
+ 'deop': 'participant',
+ 'kick': 'none',
+ 'mute': 'visitor',
+ 'op': 'moderator',
+ 'voice': 'participant'
+};
+
+/**
+ * Presents a confirmation modal to the user asking them to accept or decline a
+ * MUC invitation.
+ * @async
+ */
+export function confirmDirectMUCInvitation ({ contact, jid, reason }) {
+ if (!reason) {
+ return api.confirm(__('%1$s has invited you to join a groupchat: %2$s', contact, jid));
+ } else {
+ return api.confirm(
+ __(
+ '%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"',
+ contact,
+ jid,
+ reason
+ )
+ );
+ }
+}
+
+export function clearHistory (jid) {
+ if (_converse.router.history.getFragment() === `converse/room?jid=${jid}`) {
+ _converse.router.navigate('');
+ }
+}
+
+export async function destroyMUC (model) {
+ const messages = [__('Are you sure you want to destroy this groupchat?')];
+ let fields = [
+ {
+ 'name': 'challenge',
+ 'label': __('Please enter the XMPP address of this groupchat to confirm'),
+ 'challenge': model.get('jid'),
+ 'placeholder': __('name@example.org'),
+ 'required': true
+ },
+ {
+ 'name': 'reason',
+ 'label': __('Optional reason for destroying this groupchat'),
+ 'placeholder': __('Reason')
+ },
+ {
+ 'name': 'newjid',
+ 'label': __('Optional XMPP address for a new groupchat that replaces this one'),
+ 'placeholder': __('replacement@example.org')
+ }
+ ];
+ try {
+ fields = await api.confirm(__('Confirm'), messages, fields);
+ const reason = fields.filter(f => f.name === 'reason').pop()?.value;
+ const newjid = fields.filter(f => f.name === 'newjid').pop()?.value;
+ return model.sendDestroyIQ(reason, newjid).then(() => model.close());
+ } catch (e) {
+ log.error(e);
+ }
+}
+
+export function getNicknameRequiredTemplate (model) {
+ const jid = model.get('jid');
+ if (api.settings.get('muc_show_logs_before_join')) {
+ return html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>`;
+ } else {
+ return html`<converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>`;
+ }
+}
+
+export function getChatRoomBodyTemplate (o) {
+ const view = o.model.session.get('view');
+ const jid = o.model.get('jid');
+ const RS = converse.ROOMSTATUS;
+ const conn_status = o.model.session.get('connection_status');
+
+ if (view === converse.MUC.VIEWS.CONFIG) {
+ return html`<converse-muc-config-form class="muc-form-container" jid="${jid}"></converse-muc-config-form>`;
+ } else {
+ return html`
+ ${ conn_status == RS.PASSWORD_REQUIRED ? html`<converse-muc-password-form class="muc-form-container" jid="${jid}"></converse-muc-password-form>` : '' }
+ ${ conn_status == RS.ENTERED ? html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>` : '' }
+ ${ conn_status == RS.CONNECTING ? tplSpinner() : '' }
+ ${ conn_status == RS.NICKNAME_REQUIRED ? getNicknameRequiredTemplate(o.model) : '' }
+ ${ conn_status == RS.DISCONNECTED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' }
+ ${ conn_status == RS.BANNED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' }
+ ${ conn_status == RS.DESTROYED ? html`<converse-muc-destroyed jid="${jid}"></converse-muc-destroyed>` : '' }
+ `;
+ }
+}
+
+export function getAutoCompleteListItem (text, input) {
+ input = input.trim();
+ const element = document.createElement('li');
+ element.setAttribute('aria-selected', 'false');
+
+ if (api.settings.get('muc_mention_autocomplete_show_avatar')) {
+ const img = document.createElement('img');
+ let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE;
+
+ if (_converse.vcards) {
+ const vcard = _converse.vcards.findWhere({ 'nickname': text });
+ if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image');
+ }
+
+ img.setAttribute('src', dataUri);
+ img.setAttribute('width', '22');
+ img.setAttribute('class', 'avatar avatar-autocomplete');
+ element.appendChild(img);
+ }
+
+ const regex = new RegExp('(' + input + ')', 'ig');
+ const parts = input ? text.split(regex) : [text];
+
+ parts.forEach(txt => {
+ if (input && txt.match(regex)) {
+ const match = document.createElement('mark');
+ match.textContent = txt;
+ element.appendChild(match);
+ } else {
+ element.appendChild(document.createTextNode(txt));
+ }
+ });
+
+ return element;
+}
+
+export async function getAutoCompleteList () {
+ const models = [...(await api.rooms.get()), ...(await api.contacts.get())];
+ const jids = [...new Set(models.map(o => Strophe.getDomainFromJid(o.get('jid'))))];
+ return jids;
+}
+
+function setRole (muc, command, args, required_affiliations = [], required_roles = []) {
+ const role = COMMAND_TO_ROLE[command];
+ if (!role) {
+ throw Error(`ChatRoomView#setRole called with invalid command: ${command}`);
+ }
+ if (!muc.verifyAffiliations(required_affiliations) || !muc.verifyRoles(required_roles)) {
+ return false;
+ }
+ if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
+ return false;
+ }
+ const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
+ if (!nick_or_jid) {
+ return false;
+ }
+ const reason = args.split(nick_or_jid, 2)[1].trim();
+ // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs
+ const occupant = muc.getOccupant(nick_or_jid);
+ muc.setRole(occupant, role, reason, undefined, e => muc.onCommandError(e));
+ return true;
+}
+
+
+function verifyAndSetAffiliation (muc, command, args, required_affiliations) {
+ const affiliation = COMMAND_TO_AFFILIATION[command];
+ if (!affiliation) {
+ throw Error(`verifyAffiliations called with invalid command: ${command}`);
+ }
+ if (!muc.verifyAffiliations(required_affiliations)) {
+ return false;
+ }
+ if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
+ return false;
+ }
+ const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
+ if (!nick_or_jid) {
+ return false;
+ }
+
+ let jid;
+ const reason = args.split(nick_or_jid, 2)[1].trim();
+ const occupant = muc.getOccupant(nick_or_jid);
+ if (occupant) {
+ jid = occupant.get('jid');
+ } else {
+ if (u.isValidJID(nick_or_jid)) {
+ jid = nick_or_jid;
+ } else {
+ const message = __(
+ "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.'
+ );
+ muc.createMessage({ message, 'type': 'error' });
+ return;
+ }
+ }
+ const attrs = { jid, reason };
+ if (occupant && api.settings.get('auto_register_muc_nickname')) {
+ attrs['nick'] = occupant.get('nick');
+ }
+
+ setAffiliation(affiliation, muc.get('jid'), [attrs])
+ .then(() => muc.occupants.fetchMembers())
+ .catch(err => muc.onCommandError(err));
+}
+
+
+export function showModeratorToolsModal (muc, affiliation) {
+ if (!muc.verifyRoles(['moderator'])) {
+ return;
+ }
+ let modal = api.modal.get('converse-modtools-modal');
+ if (modal) {
+ modal.affiliation = affiliation;
+ modal.render();
+ } else {
+ modal = api.modal.create('converse-modtools-modal', { affiliation, 'jid': muc.get('jid') });
+ }
+ modal.show();
+}
+
+
+export function showOccupantModal (ev, occupant) {
+ api.modal.show('converse-muc-occupant-modal', { 'model': occupant }, ev);
+}
+
+
+export function parseMessageForMUCCommands (data, handled) {
+ const model = data.model;
+ if (handled ||
+ model.get('type') !== _converse.CHATROOMS_TYPE || (
+ api.settings.get('muc_disable_slash_commands') &&
+ !Array.isArray(api.settings.get('muc_disable_slash_commands'))
+ )) {
+ return handled;
+ }
+
+ let text = data.text;
+ text = text.replace(/^\s*/, '');
+ const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
+ if (!command) {
+ return false;
+ }
+
+ const args = text.slice(('/' + command).length + 1).trim();
+ const allowed_commands = model.getAllowedCommands() ?? [];
+
+ if (command === 'admin' && allowed_commands.includes(command)) {
+ verifyAndSetAffiliation(model, command, args, ['owner']);
+ return true;
+ } else if (command === 'ban' && allowed_commands.includes(command)) {
+ verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
+ return true;
+ } else if (command === 'modtools' && allowed_commands.includes(command)) {
+ showModeratorToolsModal(model, args);
+ return true;
+ } else if (command === 'deop' && allowed_commands.includes(command)) {
+ // FIXME: /deop only applies to setting a moderators
+ // role to "participant" (which only admin/owner can
+ // do). Moderators can however set non-moderator's role
+ // to participant (e.g. visitor => participant).
+ // Currently we don't distinguish between these two
+ // cases.
+ setRole(model, command, args, ['admin', 'owner']);
+ return true;
+ } else if (command === 'destroy' && allowed_commands.includes(command)) {
+ if (!model.verifyAffiliations(['owner'])) {
+ return true;
+ }
+ destroyMUC(model).catch(e => model.onCommandError(e));
+ return true;
+ } else if (command === 'help' && allowed_commands.includes(command)) {
+ model.set({ 'show_help_messages': false }, { 'silent': true });
+ model.set({ 'show_help_messages': true });
+ return true;
+ } else if (command === 'kick' && allowed_commands.includes(command)) {
+ setRole(model, command, args, [], ['moderator']);
+ return true;
+ } else if (command === 'mute' && allowed_commands.includes(command)) {
+ setRole(model, command, args, [], ['moderator']);
+ return true;
+ } else if (command === 'member' && allowed_commands.includes(command)) {
+ verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
+ return true;
+ } else if (command === 'nick' && allowed_commands.includes(command)) {
+ if (!model.verifyRoles(['visitor', 'participant', 'moderator'])) {
+ return true;
+ } else if (args.length === 0) {
+ // e.g. Your nickname is "coolguy69"
+ const message = __('Your nickname is "%1$s"', model.get('nick'));
+ model.createMessage({ message, 'type': 'error' });
+ } else {
+ model.setNickname(args);
+ }
+ return true;
+ } else if (command === 'owner' && allowed_commands.includes(command)) {
+ verifyAndSetAffiliation(model, command, args, ['owner']);
+ return true;
+ } else if (command === 'op' && allowed_commands.includes(command)) {
+ setRole(model, command, args, ['admin', 'owner']);
+ return true;
+ } else if (command === 'register' && allowed_commands.includes(command)) {
+ if (args.length > 1) {
+ model.createMessage({
+ 'message': __('Error: invalid number of arguments'),
+ 'type': 'error'
+ });
+ } else {
+ model.registerNickname().then(err_msg => {
+ err_msg && model.createMessage({ 'message': err_msg, 'type': 'error' });
+ });
+ }
+ return true;
+ } else if (command === 'revoke' && allowed_commands.includes(command)) {
+ verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
+ return true;
+ } else if (command === 'topic' && allowed_commands.includes(command) ||
+ command === 'subject' && allowed_commands.includes(command)) {
+ model.setSubject(args);
+ return true;
+ } else if (command === 'voice' && allowed_commands.includes(command)) {
+ setRole(model, command, args, [], ['moderator']);
+ return true;
+ } else {
+ return false;
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js
new file mode 100644
index 0000000..642ac4b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js
@@ -0,0 +1,53 @@
+/**
+ * @module converse-notification
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import { _converse, api, converse } from '@converse/headless/core';
+import {
+ clearFavicon,
+ handleChatStateNotification,
+ handleContactRequestNotification,
+ handleFeedback,
+ handleMessageNotification,
+ requestPermission,
+ updateUnreadFavicon
+} from './utils.js';
+
+converse.plugins.add('converse-notification', {
+ dependencies: ['converse-chatboxes'],
+
+ initialize () {
+ api.settings.extend({
+ // ^ a list of JIDs to ignore concerning chat state notifications
+ chatstate_notification_blacklist: [],
+ notification_delay: 5000,
+ notification_icon: '/images/logo/conversejs-filled.svg',
+ notify_all_room_messages: false,
+ notify_nicknames_without_references: false,
+ play_sounds: true,
+ show_chat_state_notifications: false,
+ show_desktop_notifications: true,
+ show_tab_notifications: true,
+ sounds_path: api.settings.get('assets_path') + '/sounds/'
+ });
+
+ /************************ Event Handlers ************************/
+ api.listen.on('clearSession', clearFavicon); // Needed for tests
+
+ api.waitUntil('chatBoxesInitialized').then(() =>
+ _converse.chatboxes.on('change:num_unread', updateUnreadFavicon)
+ );
+
+ api.listen.on('pluginsInitialized', function () {
+ // We only register event handlers after all plugins are
+ // registered, because other plugins might override some of our
+ // handlers.
+ api.listen.on('contactRequest', handleContactRequestNotification);
+ api.listen.on('contactPresenceChanged', handleChatStateNotification);
+ api.listen.on('message', handleMessageNotification);
+ api.listen.on('feedback', handleFeedback);
+ api.listen.on('connected', requestPermission);
+ });
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js
new file mode 100644
index 0000000..a9e76d4
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js
@@ -0,0 +1,330 @@
+/*global mock, converse */
+
+const { Strophe } = converse.env;
+const $msg = converse.env.$msg;
+const u = converse.env.utils;
+
+describe("Notifications", function () {
+ // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
+
+ describe("When show_desktop_notifications is set to true", function () {
+ describe("And the desktop is not focused", function () {
+ describe("an HTML5 Notification", function () {
+
+ it("is shown when a new private message is received",
+ mock.initConverse([], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+
+ const message = 'This message will show a desktop notification';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg); // This will emit 'message'
+ await u.waitUntil(() => _converse.chatboxviews.get(sender_jid));
+ expect(window.Notification).toHaveBeenCalled();
+ }));
+
+ it("is shown when you are mentioned in a groupchat",
+ mock.initConverse([], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+
+ // Test mention with setting false
+ const nick = mock.chatroom_names[0];
+ const makeMsg = text => $msg({
+ from: 'lounge@montague.lit/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(text).tree();
+ _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will NOT show a notification')));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(window.Notification).not.toHaveBeenCalled();
+
+ // Test reference
+ const message_with_ref = $msg({
+ from: 'lounge@montague.lit/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('romeo: this will show a notification').up()
+ .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'5', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).tree();
+ _converse.connection._dataRecv(mock.createRequest(message_with_ref));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(window.Notification.calls.count()).toBe(1);
+
+ // Test mention with setting true
+ _converse.api.settings.set('notify_all_room_messages', true);
+ _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will show a notification')));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(window.Notification.calls.count()).toBe(2);
+ }));
+
+ it("is shown for headline messages", mock.initConverse([], {}, async (_converse) => {
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'notify.example.com',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('subject').t('SIEVE').up()
+ .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+ .c('x', {'xmlns': 'jabber:x:oob'})
+ .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => _converse.chatboxviews.keys().length === 2);
+ expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy();
+ expect(window.Notification).toHaveBeenCalled();
+ }));
+
+ it("is not shown for full JID headline messages if allow_non_roster_messaging is false",
+ mock.initConverse([], {'allow_non_roster_messaging': false}, (_converse) => {
+
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+ const stanza = $msg({
+ 'type': 'headline',
+ 'from': 'someone@notify.example.com',
+ 'to': 'romeo@montague.lit',
+ 'xml:lang': 'en'
+ })
+ .c('subject').t('SIEVE').up()
+ .c('body').t('&lt;juliet@example.com&gt; You got mail.').up()
+ .c('x', {'xmlns': 'jabber:x:oob'})
+ .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_converse.chatboxviews.keys().includes('someone@notify.example.com')).toBeFalsy();
+ expect(window.Notification).not.toHaveBeenCalled();
+ }));
+
+ it("is shown when a user changes their chat state (if show_chat_state_notifications is true)",
+ mock.initConverse([], {show_chat_state_notifications: true},
+ async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+ const jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ expect(window.Notification).toHaveBeenCalled();
+ }));
+ });
+ });
+
+ describe("When a new contact request is received", function () {
+ it("an HTML5 Notification is received", mock.initConverse((_converse) => {
+ const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+ spyOn(window, 'Notification').and.returnValue(stub);
+ _converse.api.trigger('contactRequest', {'getDisplayName': () => 'Peter Parker'});
+ expect(window.Notification).toHaveBeenCalled();
+ }));
+ });
+ });
+
+ describe("When play_sounds is set to true", function () {
+ describe("A notification sound", function () {
+
+ it("is played when the current user is mentioned in a groupchat", mock.initConverse([], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ const { api } = _converse;
+ api.settings.set('play_sounds', true);
+
+ const stub = jasmine.createSpyObj('MyAudio', ['play', 'canPlayType']);
+ spyOn(window, 'Audio').and.returnValue(stub);
+
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ if (!view.querySelectorAll('.chat-area').length) {
+ view.renderChatArea();
+ }
+ let text = 'This message will play a sound because it mentions romeo';
+ let message = $msg({
+ from: 'lounge@montague.lit/otheruser',
+ id: '1',
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(text);
+ _converse.api.settings.set('notify_all_room_messages', true);
+ await view.model.handleMessageStanza(message.nodeTree);
+ await u.waitUntil(() => window.Audio.calls.count());
+ expect(window.Audio).toHaveBeenCalled();
+
+ text = "This message won't play a sound";
+ message = $msg({
+ from: 'lounge@montague.lit/otheruser',
+ id: '2',
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(text);
+ await view.model.handleMessageStanza(message.nodeTree);
+ expect(window.Audio, 1);
+ api.settings.set('play_sounds', false);
+
+ text = "This message won't play a sound because it is sent by romeo";
+ message = $msg({
+ from: 'lounge@montague.lit/romeo',
+ id: '3',
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(text);
+ await view.model.handleMessageStanza(message.nodeTree);
+ expect(window.Audio, 1);
+ }));
+ });
+ });
+
+
+ describe("A Favicon Message Counter", function () {
+
+ it("is incremented when the message is received and the window is not focused",
+ mock.initConverse([], {'show_tab_notifications': false}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const favico = jasmine.createSpyObj('favico', ['badge']);
+ spyOn(converse.env, 'Favico').and.returnValue(favico);
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const previous_state = _converse.windowState;
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('This message will increment the message counter').up()
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.windowState = 'hidden';
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ await _converse.handleMessageStanza(msg);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+
+ expect(favico.badge.calls.count()).toBe(0);
+
+ _converse.api.settings.set('show_tab_notifications', true);
+ const msg2 = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('This message increment the message counter AND update the page title').up()
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ await _converse.handleMessageStanza(msg2);
+ await u.waitUntil(() => favico.badge.calls.count() === 1);
+ expect(favico.badge.calls.mostRecent().args.pop()).toBe(2);
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view.model.get('num_unread')).toBe(2);
+
+ // Check that it's cleared when the window is focused
+ _converse.windowState = 'hidden';
+ u.saveWindowState({'type': 'focus'});
+ await u.waitUntil(() => favico.badge.calls.count() === 2);
+ expect(favico.badge.calls.mostRecent().args.pop()).toBe(0);
+
+ expect(view.model.get('num_unread')).toBe(0);
+ _converse.windowSate = previous_state;
+ }));
+
+ it("is not incremented when the message is received and the window is focused",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const favico = jasmine.createSpyObj('favico', ['badge']);
+ spyOn(converse.env, 'Favico').and.returnValue(favico);
+
+ u.saveWindowState({'type': 'focus'});
+ const message = 'This message will not increment the message counter';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ const promise = u.getOpenPromise();
+ setTimeout(() => {
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view.model.get('num_unread')).toBe(0);
+ expect(favico.badge.calls.count()).toBe(0);
+ promise.resolve();
+ }, 500);
+ return promise;
+ }));
+
+ it("is incremented from zero when chatbox was closed after viewing previously received messages and the window is not focused now",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const favico = jasmine.createSpyObj('favico', ['badge']);
+ spyOn(converse.env, 'Favico').and.returnValue(favico);
+ const message = 'This message will always increment the message counter from zero';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ })
+ .c('body').t(message).up()
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES})
+ .tree();
+
+ // leave converse-chat page
+ _converse.windowState = 'hidden';
+ await _converse.handleMessageStanza(msgFactory());
+ let view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => favico.badge.calls.count() === 1, 1000);
+ expect(favico.badge.calls.mostRecent().args.pop()).toBe(1);
+ expect(view.model.get('num_unread')).toBe(1);
+
+ // come back to converse-chat page
+ u.saveWindowState({'type': 'focus'});
+
+
+ await u.waitUntil(() => u.isVisible(view));
+ expect(view.model.get('num_unread')).toBe(0);
+
+ await u.waitUntil(() => favico.badge.calls.count() === 2);
+ expect(favico.badge.calls.mostRecent().args.pop()).toBe(0);
+
+ // close chatbox and leave converse-chat page again
+ view.close();
+ _converse.windowState = 'hidden';
+
+ // check that msg_counter is incremented from zero again
+ await _converse.handleMessageStanza(msgFactory());
+ view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => u.isVisible(view));
+ await u.waitUntil(() => favico.badge.calls.count() === 3);
+ expect(favico.badge.calls.mostRecent().args.pop()).toBe(1);
+ }));
+ });
+
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js
new file mode 100644
index 0000000..4ea90c0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js
@@ -0,0 +1,334 @@
+import Favico from 'favico.js-slevomat';
+import log from '@converse/headless/log';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core.js';
+import { isEmptyMessage } from '@converse/headless/utils/core.js';
+
+const { Strophe } = converse.env;
+const supports_html5_notification = 'Notification' in window;
+
+converse.env.Favico = Favico;
+
+let favicon;
+
+
+export function isMessageToHiddenChat (attrs) {
+ return _converse.isTestEnv() || (_converse.chatboxes.get(attrs.from)?.isHidden() ?? false);
+}
+
+export function areDesktopNotificationsEnabled () {
+ return _converse.isTestEnv() || (
+ supports_html5_notification &&
+ api.settings.get('show_desktop_notifications') &&
+ Notification.permission === 'granted'
+ );
+}
+
+export function clearFavicon () {
+ favicon = null;
+ navigator.clearAppBadge?.()
+ .catch(e => log.error("Could not clear unread count in app badge " + e));
+}
+
+export function updateUnreadFavicon () {
+ if (api.settings.get('show_tab_notifications')) {
+ favicon = favicon ?? new converse.env.Favico({ type: 'circle', animation: 'pop' });
+ const chats = _converse.chatboxes.models;
+ const num_unread = chats.reduce((acc, chat) => acc + (chat.get('num_unread') || 0), 0);
+ favicon.badge(num_unread);
+ navigator.setAppBadge?.(num_unread)
+ .catch(e => log.error("Could set unread count in app badge - " + e));
+ }
+}
+
+
+function isReferenced (references, muc_jid, nick) {
+ const check = r => [_converse.bare_jid, `${muc_jid}/${nick}`].includes(r.uri.replace(/^xmpp:/, ''));
+ return references.reduce((acc, r) => acc || check(r), false);
+}
+
+
+/**
+ * Is this a group message for which we should notify the user?
+ * @private
+ * @param { MUCMessageAttributes } attrs
+ */
+export async function shouldNotifyOfGroupMessage (attrs) {
+ if (!attrs?.body && !attrs?.message) {
+ // attrs.message is used by 'info' messages
+ return false;
+ }
+ const jid = attrs.from;
+ const muc_jid = attrs.from_muc;
+ const notify_all = api.settings.get('notify_all_room_messages');
+ const room = _converse.chatboxes.get(muc_jid);
+ const resource = Strophe.getResourceFromJid(jid);
+ const sender = (resource && Strophe.unescapeNode(resource)) || '';
+ let is_mentioned = false;
+ const nick = room.get('nick');
+
+ if (api.settings.get('notify_nicknames_without_references')) {
+ is_mentioned = new RegExp(`\\b${nick}\\b`).test(attrs.body);
+ }
+
+ const is_not_mine = sender !== nick;
+ const should_notify_user =
+ notify_all === true ||
+ (Array.isArray(notify_all) && notify_all.includes(muc_jid)) ||
+ isReferenced(attrs.references, muc_jid, nick) ||
+ is_mentioned;
+
+ if (is_not_mine && !!should_notify_user) {
+ /**
+ * *Hook* which allows plugins to run further logic to determine
+ * whether a notification should be sent out for this message.
+ * @event _converse#shouldNotifyOfGroupMessage
+ * @example
+ * api.listen.on('shouldNotifyOfGroupMessage', (should_notify) => {
+ * return should_notify && flurb === floob;
+ * });
+ */
+ const should_notify = await api.hook('shouldNotifyOfGroupMessage', attrs, true);
+ return should_notify;
+ }
+ return false;
+}
+
+async function shouldNotifyOfInfoMessage (attrs) {
+ if (!attrs.from_muc) {
+ return false;
+ }
+ const room = await api.rooms.get(attrs.from_muc);
+ if (!room) {
+ return false;
+ }
+ const nick = room.get('nick');
+ const muc_jid = attrs.from_muc;
+ const notify_all = api.settings.get('notify_all_room_messages');
+ return (
+ notify_all === true ||
+ (Array.isArray(notify_all) && notify_all.includes(muc_jid)) ||
+ isReferenced(attrs.references, muc_jid, nick)
+ );
+}
+
+/**
+ * @private
+ * @async
+ * @method shouldNotifyOfMessage
+ * @param { MessageData|MUCMessageData } data
+ */
+function shouldNotifyOfMessage (data) {
+ const { attrs } = data;
+ if (!attrs || attrs.is_forwarded) {
+ return false;
+ }
+ if (attrs['type'] === 'groupchat') {
+ return shouldNotifyOfGroupMessage(attrs);
+ } else if (attrs['type'] === 'info') {
+ return shouldNotifyOfInfoMessage(attrs);
+ } else if (attrs.is_headline) {
+ // We want to show notifications for headline messages.
+ return isMessageToHiddenChat(attrs);
+ }
+ const is_me = Strophe.getBareJidFromJid(attrs.from) === _converse.bare_jid;
+ return (
+ !isEmptyMessage(attrs) &&
+ !is_me &&
+ (api.settings.get('show_desktop_notifications') === 'all' || isMessageToHiddenChat(attrs))
+ );
+}
+
+export function showFeedbackNotification (data) {
+ if (data.klass === 'error' || data.klass === 'warn') {
+ const n = new Notification(data.subject, {
+ body: data.message,
+ lang: _converse.locale,
+ icon: api.settings.get('notification_icon')
+ });
+ setTimeout(n.close.bind(n), 5000);
+ }
+}
+
+/**
+ * Creates an HTML5 Notification to inform of a change in a
+ * contact's chat state.
+ */
+function showChatStateNotification (contact) {
+ if (api.settings.get('chatstate_notification_blacklist')?.includes(contact.jid)) {
+ // Don't notify if the user is being ignored.
+ return;
+ }
+ const chat_state = contact.presence.get('show');
+ let message = null;
+ if (chat_state === 'offline') {
+ message = __('has gone offline');
+ } else if (chat_state === 'away') {
+ message = __('has gone away');
+ } else if (chat_state === 'dnd') {
+ message = __('is busy');
+ } else if (chat_state === 'online') {
+ message = __('has come online');
+ }
+ if (message === null) {
+ return;
+ }
+ const n = new Notification(contact.getDisplayName(), {
+ body: message,
+ lang: _converse.locale,
+ icon: api.settings.get('notification_icon')
+ });
+ setTimeout(() => n.close(), 5000);
+}
+
+
+/**
+ * Shows an HTML5 Notification with the passed in message
+ * @private
+ * @param { MessageData|MUCMessageData } data
+ */
+function showMessageNotification (data) {
+ const { attrs } = data;
+ if (attrs.is_error) {
+ return;
+ }
+
+ if (!areDesktopNotificationsEnabled()) {
+ return;
+ }
+ let title, roster_item;
+ const full_from_jid = attrs.from;
+ const from_jid = Strophe.getBareJidFromJid(full_from_jid);
+ if (attrs.type == 'info') {
+ title = attrs.message;
+ } else if (attrs.type === 'headline') {
+ if (!from_jid.includes('@') || api.settings.get('allow_non_roster_messaging')) {
+ title = __('Notification from %1$s', from_jid);
+ } else {
+ return;
+ }
+ } else if (!from_jid.includes('@')) {
+ // workaround for Prosody which doesn't give type "headline"
+ title = __('Notification from %1$s', from_jid);
+ } else if (attrs.type === 'groupchat') {
+ title = __('%1$s says', Strophe.getResourceFromJid(full_from_jid));
+ } else {
+ if (_converse.roster === undefined) {
+ log.error('Could not send notification, because roster is undefined');
+ return;
+ }
+ roster_item = _converse.roster.get(from_jid);
+ if (roster_item !== undefined) {
+ title = __('%1$s says', roster_item.getDisplayName());
+ } else {
+ if (api.settings.get('allow_non_roster_messaging')) {
+ title = __('%1$s says', from_jid);
+ } else {
+ return;
+ }
+ }
+ }
+
+ let body;
+ if (attrs.type == 'info') {
+ body = attrs.reason;
+ } else {
+ body = attrs.is_encrypted ? attrs.plaintext : attrs.body;
+ if (!body) {
+ return;
+ }
+ }
+
+ const n = new Notification(title, {
+ 'body': body,
+ 'lang': _converse.locale,
+ 'icon': api.settings.get('notification_icon'),
+ 'requireInteraction': !api.settings.get('notification_delay')
+ });
+ if (api.settings.get('notification_delay')) {
+ setTimeout(() => n.close(), api.settings.get('notification_delay'));
+ }
+ n.onclick = function (event) {
+ event.preventDefault();
+ window.focus();
+ const chat = _converse.chatboxes.get(from_jid);
+ chat.maybeShow(true);
+ }
+}
+
+function playSoundNotification () {
+ if (api.settings.get('play_sounds') && window.Audio !== undefined) {
+ const audioOgg = new Audio(api.settings.get('sounds_path') + 'msg_received.ogg');
+ const canPlayOgg = audioOgg.canPlayType('audio/ogg');
+ if (canPlayOgg === 'probably') {
+ return audioOgg.play();
+ }
+ const audioMp3 = new Audio(api.settings.get('sounds_path') + 'msg_received.mp3');
+ const canPlayMp3 = audioMp3.canPlayType('audio/mp3');
+ if (canPlayMp3 === 'probably') {
+ audioMp3.play();
+ } else if (canPlayOgg === 'maybe') {
+ audioOgg.play();
+ } else if (canPlayMp3 === 'maybe') {
+ audioMp3.play();
+ }
+ }
+}
+
+/**
+ * Event handler for the on('message') event. Will call methods
+ * to play sounds and show HTML5 notifications.
+ */
+export async function handleMessageNotification (data) {
+ if (!await shouldNotifyOfMessage(data)) {
+ return false;
+ }
+ /**
+ * Triggered when a notification (sound or HTML5 notification) for a new
+ * message has will be made.
+ * @event _converse#messageNotification
+ * @type { MessageData|MUCMessageData}
+ * @example _converse.api.listen.on('messageNotification', data => { ... });
+ */
+ api.trigger('messageNotification', data);
+ playSoundNotification();
+ showMessageNotification(data);
+}
+
+export function handleFeedback (data) {
+ if (areDesktopNotificationsEnabled(true)) {
+ showFeedbackNotification(data);
+ }
+}
+
+/**
+ * Event handler for on('contactPresenceChanged').
+ * Will show an HTML5 notification to indicate that the chat status has changed.
+ */
+export function handleChatStateNotification (contact) {
+ if (areDesktopNotificationsEnabled() && api.settings.get('show_chat_state_notifications')) {
+ showChatStateNotification(contact);
+ }
+}
+
+function showContactRequestNotification (contact) {
+ const n = new Notification(contact.getDisplayName(), {
+ body: __('wants to be your contact'),
+ lang: _converse.locale,
+ icon: api.settings.get('notification_icon')
+ });
+ setTimeout(() => n.close(), 5000);
+}
+
+export function handleContactRequestNotification (contact) {
+ if (areDesktopNotificationsEnabled(true)) {
+ showContactRequestNotification(contact);
+ }
+}
+
+export function requestPermission () {
+ if (supports_html5_notification && !['denied', 'granted'].includes(Notification.permission)) {
+ // Ask user to enable HTML5 notifications
+ Notification.requestPermission();
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js
new file mode 100644
index 0000000..1179f6d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js
@@ -0,0 +1,83 @@
+import { _converse, api } from '@converse/headless/core';
+import { generateFingerprint } from './utils.js';
+
+export default {
+ /**
+ * The "omemo" namespace groups methods relevant to OMEMO
+ * encryption.
+ *
+ * @namespace _converse.api.omemo
+ * @memberOf _converse.api
+ */
+ 'omemo': {
+ /**
+ * Returns the device ID of the current device.
+ */
+ async getDeviceID () {
+ await api.waitUntil('OMEMOInitialized');
+ return _converse.omemo_store.get('device_id');
+ },
+
+ /**
+ * The "devicelists" namespace groups methods related to OMEMO device lists
+ *
+ * @namespace _converse.api.omemo.devicelists
+ * @memberOf _converse.api.omemo
+ */
+ 'devicelists': {
+ /**
+ * Returns the {@link _converse.DeviceList} for a particular JID.
+ * The device list will be created if it doesn't exist already.
+ * @method _converse.api.omemo.devicelists.get
+ * @param { String } jid - The Jabber ID for which the device list will be returned.
+ * @param { bool } create=false - Set to `true` if the device list
+ * should be created if it cannot be found.
+ */
+ async get (jid, create=false) {
+ const list = _converse.devicelists.get(jid) ||
+ (create ? _converse.devicelists.create({ jid }) : null);
+ await list?.initialized;
+ return list;
+ }
+ },
+
+ /**
+ * The "bundle" namespace groups methods relevant to the user's
+ * OMEMO bundle.
+ *
+ * @namespace _converse.api.omemo.bundle
+ * @memberOf _converse.api.omemo
+ */
+ 'bundle': {
+ /**
+ * Lets you generate a new OMEMO device bundle
+ *
+ * @method _converse.api.omemo.bundle.generate
+ * @returns {promise} Promise which resolves once we have a result from the server.
+ */
+ 'generate': async () => {
+ await api.waitUntil('OMEMOInitialized');
+ // Remove current device
+ const devicelist = await api.omemo.devicelists.get(_converse.bare_jid);
+
+ const device_id = _converse.omemo_store.get('device_id');
+ if (device_id) {
+ const device = devicelist.devices.get(device_id);
+ _converse.omemo_store.unset(device_id);
+ if (device) {
+ await new Promise(done => device.destroy({ 'success': done, 'error': done }));
+ }
+ devicelist.devices.trigger('remove');
+ }
+ // Generate new device bundle and publish
+ // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing
+ await _converse.omemo_store.generateBundle();
+ await devicelist.publishDevices();
+ const device = devicelist.devices.get(_converse.omemo_store.get('device_id'));
+ const fp = generateFingerprint(device);
+ await _converse.omemo_store.publishBundle();
+ return fp;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js
new file mode 100644
index 0000000..a5d0ef6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js
@@ -0,0 +1,10 @@
+export const UNDECIDED = 0;
+export const TRUSTED = 1;
+export const UNTRUSTED = -1;
+
+export const TAG_LENGTH = 128;
+
+export const KEY_ALGO = {
+ 'name': 'AES-GCM',
+ 'length': 128
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js
new file mode 100644
index 0000000..05b76ec
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js
@@ -0,0 +1,69 @@
+import log from '@converse/headless/log';
+import { IQError } from './errors.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { UNDECIDED } from './consts.js';
+import { _converse, api, converse } from '@converse/headless/core.js';
+import { getRandomInt } from '@converse/headless/utils/core.js';
+import { parseBundle } from './utils.js';
+
+const { Strophe, sizzle, $iq } = converse.env;
+
+
+/**
+ * @class
+ * @namespace _converse.Device
+ * @memberOf _converse
+ */
+const Device = Model.extend({
+ defaults: {
+ 'trusted': UNDECIDED,
+ 'active': true
+ },
+
+ getRandomPreKey () {
+ // XXX: assumes that the bundle has already been fetched
+ const bundle = this.get('bundle');
+ return bundle.prekeys[getRandomInt(bundle.prekeys.length)];
+ },
+
+ async fetchBundleFromServer () {
+ const stanza = $iq({
+ 'type': 'get',
+ 'from': _converse.bare_jid,
+ 'to': this.get('jid')
+ }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
+ .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
+
+ let iq;
+ try {
+ iq = await api.sendIQ(stanza);
+ } catch (iq) {
+ log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`);
+ log.error(iq);
+ return null;
+ }
+ if (iq.querySelector('error')) {
+ throw new IQError('Could not fetch bundle', iq);
+ }
+ const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop();
+ const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop();
+ const bundle = parseBundle(bundle_el);
+ this.save('bundle', bundle);
+ return bundle;
+ },
+
+ /**
+ * Fetch and save the bundle information associated with
+ * this device, if the information is not cached already.
+ * @method _converse.Device#getBundle
+ */
+ getBundle () {
+ if (this.get('bundle')) {
+ return Promise.resolve(this.get('bundle'), this);
+ } else {
+ return this.fetchBundleFromServer();
+ }
+ }
+});
+
+export default Device;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js
new file mode 100644
index 0000000..8960875
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js
@@ -0,0 +1,134 @@
+import log from '@converse/headless/log';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { getOpenPromise } from '@converse/openpromise';
+import { initStorage } from '@converse/headless/utils/storage.js';
+import { restoreOMEMOSession } from './utils.js';
+
+const { Strophe, $build, $iq, sizzle } = converse.env;
+
+/**
+ * @class
+ * @namespace _converse.DeviceList
+ * @memberOf _converse
+ */
+const DeviceList = Model.extend({
+ idAttribute: 'jid',
+
+ async initialize () {
+ this.initialized = getOpenPromise();
+ await this.initDevices();
+ this.initialized.resolve();
+ },
+
+ initDevices () {
+ this.devices = new _converse.Devices();
+ const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`;
+ initStorage(this.devices, id);
+ return this.fetchDevices();
+ },
+
+ async onDevicesFound (collection) {
+ if (collection.length === 0) {
+ let ids = [];
+ try {
+ ids = await this.fetchDevicesFromServer();
+ } catch (e) {
+ if (e === null) {
+ log.error(`Timeout error while fetching devices for ${this.get('jid')}`);
+ } else {
+ log.error(`Could not fetch devices for ${this.get('jid')}`);
+ log.error(e);
+ }
+ this.destroy();
+ }
+ if (this.get('jid') === _converse.bare_jid) {
+ this.publishCurrentDevice(ids);
+ }
+ }
+ },
+
+ fetchDevices () {
+ if (this._devices_promise === undefined) {
+ this._devices_promise = new Promise(resolve => {
+ this.devices.fetch({
+ 'success': c => resolve(this.onDevicesFound(c)),
+ 'error': (_, e) => {
+ log.error(e);
+ resolve();
+ }
+ });
+ });
+ }
+ return this._devices_promise;
+ },
+
+ async getOwnDeviceId () {
+ let device_id = _converse.omemo_store.get('device_id');
+ if (!this.devices.get(device_id)) {
+ // Generate a new bundle if we cannot find our device
+ await _converse.omemo_store.generateBundle();
+ device_id = _converse.omemo_store.get('device_id');
+ }
+ return device_id;
+ },
+
+ async publishCurrentDevice (device_ids) {
+ if (this.get('jid') !== _converse.bare_jid) {
+ return; // We only publish for ourselves.
+ }
+ await restoreOMEMOSession();
+
+ if (!_converse.omemo_store) {
+ // Happens during tests. The connection gets torn down
+ // before publishCurrentDevice has time to finish.
+ log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue');
+ return;
+ }
+ if (!device_ids.includes(await this.getOwnDeviceId())) {
+ return this.publishDevices();
+ }
+ },
+
+ async fetchDevicesFromServer () {
+ const stanza = $iq({
+ 'type': 'get',
+ 'from': _converse.bare_jid,
+ 'to': this.get('jid')
+ }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
+ .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST });
+
+ const iq = await api.sendIQ(stanza);
+ const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`;
+ const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id'));
+ const jid = this.get('jid');
+ return Promise.all(device_ids.map(id => this.devices.create({ id, jid }, { 'promise': true })));
+ },
+
+ /**
+ * Send an IQ stanza to the current user's "devices" PEP node to
+ * ensure that all devices are published for potential chat partners to see.
+ * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing
+ */
+ publishDevices () {
+ const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO });
+ this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up());
+ const options = { 'pubsub#access_model': 'open' };
+ return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false);
+ },
+
+ async removeOwnDevices (device_ids) {
+ if (this.get('jid') !== _converse.bare_jid) {
+ throw new Error("Cannot remove devices from someone else's device list");
+ }
+ await Promise.all(device_ids.map(id => this.devices.get(id)).map(d =>
+ new Promise(resolve => d.destroy({
+ 'success': resolve,
+ 'error': (_, e) => { log.error(e); resolve(); }
+ }))
+ ));
+ return this.publishDevices();
+ }
+});
+
+export default DeviceList;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js
new file mode 100644
index 0000000..a42462a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js
@@ -0,0 +1,11 @@
+import DeviceList from './devicelist.js';
+import { Collection } from '@converse/skeletor/src/collection';
+
+/**
+ * @class
+ * @namespace _converse.DeviceLists
+ * @memberOf _converse
+ */
+const DeviceLists = Collection.extend({ model: DeviceList });
+
+export default DeviceLists;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js
new file mode 100644
index 0000000..991fed1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js
@@ -0,0 +1,4 @@
+import Device from './device.js';
+import { Collection } from '@converse/skeletor/src/collection';
+
+export default Collection.extend({ model: Device });
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js
new file mode 100644
index 0000000..b99c501
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js
@@ -0,0 +1,7 @@
+export class IQError extends Error {
+ constructor (message, iq) {
+ super(message, iq);
+ this.name = 'IQError';
+ this.iq = iq;
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js
new file mode 100644
index 0000000..00b9f86
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js
@@ -0,0 +1,34 @@
+import tplFingerprints from './templates/fingerprints.js';
+import { CustomElement } from 'shared/components/element.js';
+import { api } from "@converse/headless/core";
+
+export class Fingerprints extends CustomElement {
+
+ static get properties () {
+ return {
+ 'jid': { type: String }
+ }
+ }
+
+ async initialize () {
+ this.devicelist = await api.omemo.devicelists.get(this.jid, true);
+ this.listenTo(this.devicelist.devices, 'change:bundle', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'change:trusted', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'remove', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'add', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate());
+ this.requestUpdate();
+ }
+
+ render () {
+ return this.devicelist ? tplFingerprints(this) : '';
+ }
+
+ toggleDeviceTrust (ev) {
+ const radio = ev.target;
+ const device = this.devicelist.devices.get(radio.getAttribute('name'));
+ device.save('trusted', parseInt(radio.value, 10));
+ }
+}
+
+api.elements.define('converse-omemo-fingerprints', Fingerprints);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js
new file mode 100644
index 0000000..7cdb4b7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js
@@ -0,0 +1,119 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './fingerprints.js';
+import './profile.js';
+import 'shared/modals/user-details.js';
+import ConverseMixins from './mixins/converse.js';
+import Device from './device.js';
+import DeviceList from './devicelist.js';
+import DeviceLists from './devicelists.js';
+import Devices from './devices.js';
+import OMEMOStore from './store.js';
+import log from '@converse/headless/log';
+import omemo_api from './api.js';
+import { _converse, api, converse } from '@converse/headless/core.js';
+import { shouldClearCache } from '@converse/headless/utils/core.js';
+import {
+ createOMEMOMessageStanza,
+ encryptFile,
+ getOMEMOToolbarButton,
+ getOutgoingMessageAttributes,
+ handleEncryptedFiles,
+ handleMessageSendError,
+ initOMEMO,
+ omemo,
+ onChatBoxesInitialized,
+ onChatInitialized,
+ parseEncryptedMessage,
+ registerPEPPushHandler,
+ setEncryptedFileURL,
+} from './utils.js';
+
+const { Strophe } = converse.env;
+
+converse.env.omemo = omemo;
+
+Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist');
+Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification');
+Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted');
+Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles');
+
+
+converse.plugins.add('converse-omemo', {
+ enabled (_converse) {
+ return (
+ window.libsignal &&
+ _converse.config.get('trusted') &&
+ !api.settings.get('clear_cache_on_logout') &&
+ !_converse.api.settings.get('blacklisted_plugins').includes('converse-omemo')
+ );
+ },
+
+ dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'],
+
+ initialize () {
+ api.settings.extend({ 'omemo_default': false });
+ api.promises.add(['OMEMOInitialized']);
+
+ _converse.NUM_PREKEYS = 100; // Set here so that tests can override
+
+ Object.assign(_converse, ConverseMixins);
+ Object.assign(_converse.api, omemo_api);
+
+ _converse.OMEMOStore = OMEMOStore;
+ _converse.Device = Device;
+ _converse.Devices = Devices;
+ _converse.DeviceList = DeviceList;
+ _converse.DeviceLists = DeviceLists;
+
+ /******************** Event Handlers ********************/
+ api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
+
+ api.listen.on('getOutgoingMessageAttributes', getOutgoingMessageAttributes);
+
+ api.listen.on('createMessageStanza', async (chat, data) => {
+ try {
+ data = await createOMEMOMessageStanza(chat, data);
+ } catch (e) {
+ handleMessageSendError(e, chat);
+ }
+ return data;
+ });
+
+ api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs);
+ api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file);
+
+ api.listen.on('parseMessage', parseEncryptedMessage);
+ api.listen.on('parseMUCMessage', parseEncryptedMessage);
+
+ api.listen.on('chatBoxViewInitialized', onChatInitialized);
+ api.listen.on('chatRoomViewInitialized', onChatInitialized);
+
+ api.listen.on('connected', registerPEPPushHandler);
+ api.listen.on('getToolbarButtons', getOMEMOToolbarButton);
+
+ api.listen.on('statusInitialized', initOMEMO);
+ api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`));
+
+ api.listen.on('afterMessageBodyTransformed', handleEncryptedFiles);
+
+ api.listen.on('userDetailsModalInitialized', contact => {
+ const jid = contact.get('jid');
+ _converse.generateFingerprints(jid).catch(e => log.error(e));
+ });
+
+ api.listen.on('profileModalInitialized', () => {
+ _converse.generateFingerprints(_converse.bare_jid).catch(e => log.error(e));
+ });
+
+ api.listen.on('clearSession', () => {
+ delete _converse.omemo_store
+ if (shouldClearCache() && _converse.devicelists) {
+ _converse.devicelists.clearStore();
+ delete _converse.devicelists;
+ }
+ });
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js
new file mode 100644
index 0000000..575f13e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js
@@ -0,0 +1,22 @@
+import { generateFingerprint, getDevicesForContact, } from '../utils.js';
+
+
+const ConverseMixins = {
+
+ async generateFingerprints (jid) {
+ const devices = await getDevicesForContact(jid);
+ return Promise.all(devices.map(d => generateFingerprint(d)));
+ },
+
+ getDeviceForContact (jid, device_id) {
+ return getDevicesForContact(jid).then(devices => devices.get(device_id));
+ },
+
+ async contactHasOMEMOSupport (jid) {
+ /* Checks whether the contact advertises any OMEMO-compatible devices. */
+ const devices = await getDevicesForContact(jid);
+ return devices.length > 0;
+ }
+}
+
+export default ConverseMixins;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js
new file mode 100644
index 0000000..fb5a135
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js
@@ -0,0 +1,78 @@
+import log from '@converse/headless/log';
+import tplProfile from './templates/profile.js';
+import tplSpinner from "templates/spinner.js";
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { Strophe, sizzle, u } = converse.env;
+
+
+export class Profile extends CustomElement {
+
+ async initialize () {
+ this.devicelist = await api.omemo.devicelists.get(_converse.bare_jid, true);
+ await this.setAttributes();
+ this.listenTo(this.devicelist.devices, 'change:bundle', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'remove', () => this.requestUpdate());
+ this.listenTo(this.devicelist.devices, 'add', () => this.requestUpdate());
+ this.requestUpdate();
+ }
+
+ async setAttributes () {
+ this.device_id = await api.omemo.getDeviceID();
+ this.current_device = this.devicelist.devices.get(this.device_id);
+ this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== this.device_id);
+ }
+
+ render () {
+ return this.devicelist ? tplProfile(this) : tplSpinner();
+ }
+
+ selectAll (ev) { // eslint-disable-line class-methods-use-this
+ let sibling = u.ancestor(ev.target, 'li');
+ while (sibling) {
+ sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked;
+ sibling = sibling.nextElementSibling;
+ }
+ }
+
+ async removeSelectedFingerprints (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.target.querySelector('.select-all').checked = false;
+ const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map(
+ c => c.value
+ );
+
+ try {
+ await this.devicelist.removeOwnDevices(device_ids);
+ } catch (err) {
+ log.error(err);
+ _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [
+ __('Sorry, an error occurred while trying to remove the devices.')
+ ]);
+ }
+ await this.setAttributes();
+ this.requestUpdate();
+ }
+
+ async generateOMEMODeviceBundle (ev) {
+ ev.preventDefault();
+
+ const result = await api.confirm(__(
+ 'Are you sure you want to generate new OMEMO keys? ' +
+ 'This will remove your old keys and all previously ' +
+ 'encrypted messages will no longer be decryptable on this device.'));
+
+ if (result) {
+ await api.omemo.bundle.generate();
+ await this.setAttributes();
+ this.requestUpdate();
+ }
+ }
+}
+
+api.elements.define('converse-omemo-profile', Profile);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js
new file mode 100644
index 0000000..3ffa279
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js
@@ -0,0 +1,305 @@
+/* global libsignal */
+import difference from 'lodash-es/difference';
+import invokeMap from 'lodash-es/invokeMap';
+import log from '@converse/headless/log';
+import range from 'lodash-es/range';
+import omit from 'lodash-es/omit';
+import { Model } from '@converse/skeletor/src/model.js';
+import { generateDeviceID } from './utils.js';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $build, u } = converse.env;
+
+
+const OMEMOStore = Model.extend({
+ Direction: {
+ SENDING: 1,
+ RECEIVING: 2
+ },
+
+ getIdentityKeyPair () {
+ const keypair = this.get('identity_keypair');
+ return Promise.resolve({
+ 'privKey': u.base64ToArrayBuffer(keypair.privKey),
+ 'pubKey': u.base64ToArrayBuffer(keypair.pubKey)
+ });
+ },
+
+ getLocalRegistrationId () {
+ return Promise.resolve(parseInt(this.get('device_id'), 10));
+ },
+
+ isTrustedIdentity (identifier, identity_key, direction) { // eslint-disable-line no-unused-vars
+ if (identifier === null || identifier === undefined) {
+ throw new Error("Can't check identity key for invalid key");
+ }
+ if (!(identity_key instanceof ArrayBuffer)) {
+ throw new Error('Expected identity_key to be an ArrayBuffer');
+ }
+ const trusted = this.get('identity_key' + identifier);
+ if (trusted === undefined) {
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted);
+ },
+
+ loadIdentityKey (identifier) {
+ if (identifier === null || identifier === undefined) {
+ throw new Error("Can't load identity_key for invalid identifier");
+ }
+ return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier)));
+ },
+
+ saveIdentity (identifier, identity_key) {
+ if (identifier === null || identifier === undefined) {
+ throw new Error("Can't save identity_key for invalid identifier");
+ }
+ const address = new libsignal.SignalProtocolAddress.fromString(identifier);
+ const existing = this.get('identity_key' + address.getName());
+ const b64_idkey = u.arrayBufferToBase64(identity_key);
+ this.save('identity_key' + address.getName(), b64_idkey);
+
+ if (existing && b64_idkey !== existing) {
+ return Promise.resolve(true);
+ } else {
+ return Promise.resolve(false);
+ }
+ },
+
+ getPreKeys () {
+ return this.get('prekeys') || {};
+ },
+
+ loadPreKey (key_id) {
+ const res = this.getPreKeys()[key_id];
+ if (res) {
+ return Promise.resolve({
+ 'privKey': u.base64ToArrayBuffer(res.privKey),
+ 'pubKey': u.base64ToArrayBuffer(res.pubKey)
+ });
+ }
+ return Promise.resolve();
+ },
+
+ storePreKey (key_id, key_pair) {
+ const prekey = {};
+ prekey[key_id] = {
+ 'pubKey': u.arrayBufferToBase64(key_pair.pubKey),
+ 'privKey': u.arrayBufferToBase64(key_pair.privKey)
+ };
+ this.save('prekeys', Object.assign(this.getPreKeys(), prekey));
+ return Promise.resolve();
+ },
+
+ removePreKey (key_id) {
+ this.save('prekeys', omit(this.getPreKeys(), key_id));
+ return Promise.resolve();
+ },
+
+ loadSignedPreKey (keyId) { // eslint-disable-line no-unused-vars
+ const res = this.get('signed_prekey');
+ if (res) {
+ return Promise.resolve({
+ 'privKey': u.base64ToArrayBuffer(res.privKey),
+ 'pubKey': u.base64ToArrayBuffer(res.pubKey)
+ });
+ }
+ return Promise.resolve();
+ },
+
+ storeSignedPreKey (spk) {
+ if (typeof spk !== 'object') {
+ // XXX: We've changed the signature of this method from the
+ // example given in InMemorySignalProtocolStore.
+ // Should be fine because the libsignal code doesn't
+ // actually call this method.
+ throw new Error('storeSignedPreKey: expected an object');
+ }
+ this.save('signed_prekey', {
+ 'id': spk.keyId,
+ 'privKey': u.arrayBufferToBase64(spk.keyPair.privKey),
+ 'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey),
+ // XXX: The InMemorySignalProtocolStore does not pass
+ // in or store the signature, but we need it when we
+ // publish our bundle and this method isn't called from
+ // within libsignal code, so we modify it to also store
+ // the signature.
+ 'signature': u.arrayBufferToBase64(spk.signature)
+ });
+ return Promise.resolve();
+ },
+
+ removeSignedPreKey (key_id) {
+ if (this.get('signed_prekey')['id'] === key_id) {
+ this.unset('signed_prekey');
+ this.save();
+ }
+ return Promise.resolve();
+ },
+
+ loadSession (identifier) {
+ return Promise.resolve(this.get('session' + identifier));
+ },
+
+ storeSession (identifier, record) {
+ return Promise.resolve(this.save('session' + identifier, record));
+ },
+
+ removeSession (identifier) {
+ return Promise.resolve(this.unset('session' + identifier));
+ },
+
+ removeAllSessions (identifier) {
+ const keys = Object.keys(this.attributes).filter(key =>
+ key.startsWith('session' + identifier) ? key : false
+ );
+ const attrs = {};
+ keys.forEach(key => { attrs[key] = undefined; });
+ this.save(attrs);
+ return Promise.resolve();
+ },
+
+ publishBundle () {
+ const signed_prekey = this.get('signed_prekey');
+ const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`;
+ const item = $build('item')
+ .c('bundle', { 'xmlns': Strophe.NS.OMEMO })
+ .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id })
+ .t(signed_prekey.pubKey).up()
+ .c('signedPreKeySignature')
+ .t(signed_prekey.signature).up()
+ .c('identityKey')
+ .t(this.get('identity_keypair').pubKey).up()
+ .c('prekeys');
+
+ Object.values(this.get('prekeys')).forEach((prekey, id) =>
+ item
+ .c('preKeyPublic', { 'preKeyId': id })
+ .t(prekey.pubKey)
+ .up()
+ );
+ const options = { 'pubsub#access_model': 'open' };
+ return api.pubsub.publish(null, node, item, options, false);
+ },
+
+ async generateMissingPreKeys () {
+ const missing_keys = difference(
+ invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString),
+ Object.keys(this.getPreKeys())
+ );
+ if (missing_keys.length < 1) {
+ log.warn('No missing prekeys to generate for our own device');
+ return Promise.resolve();
+ }
+ const keys = await Promise.all(
+ missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10)))
+ );
+ keys.forEach(k => this.storePreKey(k.keyId, k.keyPair));
+ const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({
+ 'id': k.keyId,
+ 'key': u.arrayBufferToBase64(k.pubKey)
+ }));
+ const devicelist = await api.omemo.devicelists.get(_converse.bare_jid);
+ const device = devicelist.devices.get(this.get('device_id'));
+ const bundle = await device.getBundle();
+ device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys }));
+ },
+
+ /**
+ * Generates, stores and then returns pre-keys.
+ *
+ * Pre-keys are one half of a X3DH key exchange and are published as part
+ * of the device bundle.
+ *
+ * For a new contact or device to establish an encrypted session, it needs
+ * to use a pre-key, which it chooses randomly from the list of available
+ * ones.
+ */
+ async generatePreKeys () {
+ const amount = _converse.NUM_PREKEYS;
+ const { KeyHelper } = libsignal;
+ const keys = await Promise.all(
+ range(0, amount).map(id => KeyHelper.generatePreKey(id))
+ );
+
+ keys.forEach(k => this.storePreKey(k.keyId, k.keyPair));
+
+ return keys.map(k => ({
+ 'id': k.keyId,
+ 'key': u.arrayBufferToBase64(k.keyPair.pubKey)
+ }));
+ },
+
+ /**
+ * Generate the cryptographic data used by the X3DH key agreement protocol
+ * in order to build a session with other devices.
+ *
+ * By generating a bundle, and publishing it via PubSub, we allow other
+ * clients to download it and start asynchronous encrypted sessions with us,
+ * even if we're offline at that time.
+ */
+ async generateBundle () {
+ // The first thing that needs to happen if a client wants to
+ // start using OMEMO is they need to generate an IdentityKey
+ // and a Device ID.
+
+ // The IdentityKey is a Curve25519 public/private Key pair.
+ const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair();
+ const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey);
+
+ // The Device ID is a randomly generated integer between 1 and 2^31 - 1.
+ const device_id = await generateDeviceID();
+
+ this.save({
+ 'device_id': device_id,
+ 'identity_keypair': {
+ 'privKey': u.arrayBufferToBase64(identity_keypair.privKey),
+ 'pubKey': identity_key
+ },
+ 'identity_key': identity_key
+ });
+
+ const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0);
+ this.storeSignedPreKey(signed_prekey);
+
+ const prekeys = await this.generatePreKeys();
+
+ const bundle = { identity_key, device_id, prekeys };
+ bundle['signed_prekey'] = {
+ 'id': signed_prekey.keyId,
+ 'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.pubKey),
+ 'signature': u.arrayBufferToBase64(signed_prekey.signature)
+ };
+
+ const devicelist = await api.omemo.devicelists.get(_converse.bare_jid);
+ const device = await devicelist.devices.create(
+ { 'id': bundle.device_id, 'jid': _converse.bare_jid },
+ { 'promise': true }
+ );
+ device.save('bundle', bundle);
+ },
+
+ fetchSession () {
+ if (this._setup_promise === undefined) {
+ this._setup_promise = new Promise((resolve, reject) => {
+ this.fetch({
+ 'success': () => {
+ if (!this.get('device_id')) {
+ this.generateBundle().then(resolve).catch(reject);
+ } else {
+ resolve();
+ }
+ },
+ 'error': (model, resp) => {
+ log.warn("Could not fetch OMEMO session from cache, we'll generate a new one.");
+ log.warn(resp);
+ this.generateBundle().then(resolve).catch(reject);
+ }
+ });
+ });
+ }
+ return this._setup_promise;
+ }
+});
+
+export default OMEMOStore;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js
new file mode 100644
index 0000000..c30493d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js
@@ -0,0 +1,46 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+import { formatFingerprint } from '../utils.js';
+
+const device_fingerprint = (el, device) => {
+ const i18n_trusted = __('Trusted');
+ const i18n_untrusted = __('Untrusted');
+ if (device.get('bundle') && device.get('bundle').fingerprint) {
+ return html`
+ <li class="list-group-item">
+ <form class="fingerprint-trust">
+ <div class="btn-group btn-group-toggle">
+ <label class="btn btn--small ${(device.get('trusted') === 1) ? 'btn-primary active' : 'btn-secondary'}"
+ @click=${el.toggleDeviceTrust}>
+ <input type="radio" name="${device.get('id')}" value="1"
+ ?checked=${device.get('trusted') !== -1}>${i18n_trusted}
+ </label>
+ <label class="btn btn--small ${(device.get('trusted') === -1) ? 'btn-primary active' : 'btn-secondary'}"
+ @click=${el.toggleDeviceTrust}>
+ <input type="radio" name="${device.get('id')}" value="-1"
+ ?checked=${device.get('trusted') === -1}>${i18n_untrusted}
+ </label>
+ </div>
+ <code class="fingerprint">${formatFingerprint(device.get('bundle').fingerprint)}</code>
+ </form>
+ </li>
+ `;
+ } else {
+ return ''
+ }
+}
+
+export default (el) => {
+ const i18n_fingerprints = __('OMEMO Fingerprints');
+ const i18n_no_devices = __("No OMEMO-enabled devices found");
+ const devices = el.devicelist.devices;
+ return html`
+ <hr/>
+ <ul class="list-group fingerprints">
+ <li class="list-group-item active">${i18n_fingerprints}</li>
+ ${ devices.length ?
+ devices.map(device => device_fingerprint(el, device)) :
+ html`<li class="list-group-item"> ${i18n_no_devices} </li>` }
+ </ul>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js
new file mode 100644
index 0000000..42278c1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js
@@ -0,0 +1,81 @@
+import spinner from "templates/spinner.js";
+import { formatFingerprint } from 'plugins/omemo/utils.js';
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+const fingerprint = (el) => html`
+ <span class="fingerprint">${formatFingerprint(el.current_device.get('bundle').fingerprint)}</span>`;
+
+
+const device_with_fingerprint = (el) => {
+ const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following fingerprint');
+ return html`
+ <li class="fingerprint-removal-item list-group-item">
+ <label>
+ <input type="checkbox" value="${el.device.get('id')}"
+ aria-label="${i18n_fingerprint_checkbox_label}"/>
+ <span class="fingerprint">${formatFingerprint(el.device.get('bundle').fingerprint)}</span>
+ </label>
+ </li>
+ `;
+}
+
+
+const device_without_fingerprint = (el) => {
+ const i18n_device_without_fingerprint = __('Device without a fingerprint');
+ const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following device');
+ return html`
+ <li class="fingerprint-removal-item list-group-item">
+ <label>
+ <input type="checkbox" value="${el.device.get('id')}"
+ aria-label="${i18n_fingerprint_checkbox_label}"/>
+ <span>${i18n_device_without_fingerprint}</span>
+ </label>
+ </li>
+ `;
+}
+
+
+const device_item = (el) => html`
+ ${(el.device.get('bundle') && el.device.get('bundle').fingerprint) ? device_with_fingerprint(el) : device_without_fingerprint(el) }
+`;
+
+
+const device_list = (el) => {
+ const i18n_other_devices = __('Other OMEMO-enabled devices');
+ const i18n_other_devices_label = __('Checkbox to select fingerprints of all other OMEMO devices');
+ const i18n_remove_devices = __('Remove checked devices and close');
+ const i18n_select_all = __('Select all');
+ return html`
+ <ul class="list-group fingerprints">
+ <li class="list-group-item active">
+ <label>
+ <input type="checkbox" class="select-all" @change=${el.selectAll} title="${i18n_select_all}" aria-label="${i18n_other_devices_label}"/>
+ ${i18n_other_devices}
+ </label>
+ </li>
+ ${ el.other_devices?.map(device => device_item(Object.assign({device}, el))) }
+ </ul>
+ <div class="form-group"><button type="submit" class="save-form btn btn-primary">${i18n_remove_devices}</button></div>
+ `;
+}
+
+
+export default (el) => {
+ const i18n_fingerprint = __("This device's OMEMO fingerprint");
+ const i18n_generate = __('Generate new keys and fingerprint');
+ return html`
+ <form class="converse-form fingerprint-removal" @submit=${el.removeSelectedFingerprints}>
+ <ul class="list-group fingerprints">
+ <li class="list-group-item active">${i18n_fingerprint}</li>
+ <li class="list-group-item">
+ ${ (el.current_device && el.current_device.get('bundle') && el.current_device.get('bundle').fingerprint) ? fingerprint(el) : spinner() }
+ </li>
+ </ul>
+ <div class="form-group">
+ <button type="button" class="generate-bundle btn btn-danger" @click=${el.generateOMEMODeviceBundle}>${i18n_generate}</button>
+ </div>
+ ${ el.other_devices?.length ? device_list(el) : '' }
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js
new file mode 100644
index 0000000..0faddc0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js
@@ -0,0 +1,464 @@
+/*global mock, converse */
+
+const { Strophe, $iq, $msg, $pres, u, omemo } = converse.env;
+
+describe("An OMEMO encrypted message", function() {
+
+ it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.initializedOMEMO(_converse);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ let stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ const devicelist = _converse.devicelists.get({'jid': contact_jid});
+ await u.waitUntil(() => devicelist.devices.length === 1);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ view.model.set('omemo_active', true);
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t(btoa('3333')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ await u.waitUntil(() => textarea.value === '');
+
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+
+ const newer_text = 'But soft, what light through yonder door breaks?';
+ textarea.value = newer_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === newer_text);
+
+ await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 3);
+ const msg = _converse.connection.sent_stanzas.pop();
+ const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>${fallback_text}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="555">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${msg.querySelector('header iv').textContent}</iv>`+
+ `</header>`+
+ `<payload>${msg.querySelector('payload').textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+ let older_versions = first_msg.get('older_versions');
+ let keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+ expect(first_msg.get('plaintext')).toBe(newer_text);
+ expect(first_msg.get('is_encrypted')).toBe(true);
+ expect(first_msg.get('body')).toBe(fallback_text);
+ expect(first_msg.get('message')).toBe(fallback_text);
+
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder door breaks?');
+
+ const newest_text = 'But soft, what light through yonder window breaks?';
+ textarea.value = newest_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === newest_text);
+
+ keys = Object.keys(older_versions);
+ expect(keys.length).toBe(2);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+ expect(older_versions[keys[1]]).toBe('But soft, what light through yonder door breaks?');
+
+ const first_rcvd_msg_id = u.getUniqueId();
+ let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': first_rcvd_msg_id
+ }).c('body').t(fallback_text).up()
+ .c('origin-id', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload)));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
+ .toBe('This is an encrypted message from the contact');
+
+ const msg_id = u.getUniqueId();
+ obj = await omemo.encryptMessage('This is an edited encrypted message from the contact')
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id
+ }).c('body').t(fallback_text).up()
+ .c('replace', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).up()
+ .c('origin-id', {'id': msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload)));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
+ .toBe('This is an edited encrypted message from the contact');
+
+ const message = view.model.messages.at(1);
+ older_versions = message.get('older_versions');
+ keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('This is an encrypted message from the contact');
+ expect(message.get('plaintext')).toBe('This is an edited encrypted message from the contact');
+ expect(message.get('is_encrypted')).toBe(true);
+ expect(message.get('body')).toBe(fallback_text);
+ expect(message.get('message')).toBe(fallback_text);
+ expect(message.get('msgid')).toBe(first_rcvd_msg_id);
+ }));
+});
+
+describe("An OMEMO encrypted MUC message", function() {
+
+ it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ // MEMO encryption works only in members only conferences
+ // that are non-anonymous.
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ const nick = 'romeo';
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+ const view = _converse.chatboxviews.get(muc_jid);
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ const omemo_toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
+ omemo_toggle.click();
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ // newguy enters the room
+ const contact_jid = 'newguy@montague.lit';
+ let stanza = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // Wait for Converse to fetch newguy's device list
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ // The server returns his device list
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(2);
+
+ await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ const devicelist = _converse.devicelists.get(contact_jid);
+ expect(devicelist.devices.length).toBe(1);
+ expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ const original_text = 'This message will be encrypted';
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = original_text;
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t(btoa('3333')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+ spyOn(_converse.connection, 'send').and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.connection.send.calls.count(), 1000);
+ const sent_stanza = _converse.connection.send.calls.all()[0].args[0];
+
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lounge@montague.lit" `+
+ `type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+
+ `</header>`+
+ `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+ await u.waitUntil(() => textarea.value === '');
+
+ const first_msg = view.model.messages.findWhere({'message': original_text});
+
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe(original_text);
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+
+ const new_text = 'This is an edit of the encrypted message';
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text);
+
+ const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+ let older_versions = first_msg.get('older_versions');
+ let keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe(original_text);
+ expect(first_msg.get('plaintext')).toBe(new_text);
+ expect(first_msg.get('is_encrypted')).toBe(true);
+ expect(first_msg.get('body')).toBe(fallback_text);
+ expect(first_msg.get('message')).toBe(fallback_text);
+
+ await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+ const msg = _converse.connection.sent_stanzas.pop();
+
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="${_converse.jid}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+ `<body>${fallback_text}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${msg.querySelector("iv").textContent}</iv>`+
+ `</header>`+
+ `<payload>${msg.querySelector("payload").textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+
+ // Test reception of an encrypted message
+ const first_received_id = _converse.connection.getUniqueId()
+ const first_received_message = 'This is an encrypted message from the contact';
+ const first_obj = await omemo.encryptMessage(first_received_message)
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': `${muc_jid}/newguy`,
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': first_received_id
+ }).c('body').t(fallback_text).up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(first_obj.key_and_tag)).up()
+ .c('iv').t(first_obj.iv)
+ .up().up()
+ .c('payload').t(first_obj.payload)));
+
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()).toBe(first_received_message);
+ expect(_converse.devicelists.length).toBe(2);
+ expect(_converse.devicelists.at(0).get('jid')).toBe(_converse.bare_jid);
+ expect(_converse.devicelists.at(1).get('jid')).toBe(contact_jid);
+
+ const second_received_message = 'This is an edited encrypted message from the contact';
+ const second_obj = await omemo.encryptMessage(second_received_message)
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': `${muc_jid}/newguy`,
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t(fallback_text).up()
+ .c('replace', {'id':first_received_id, 'xmlns': 'urn:xmpp:message-correct:0'})
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(second_obj.key_and_tag)).up()
+ .c('iv').t(second_obj.iv)
+ .up().up()
+ .c('payload').t(second_obj.payload)));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()).toBe(second_received_message);
+
+ const message = view.model.messages.at(1);
+ older_versions = message.get('older_versions');
+ keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('This is an encrypted message from the contact');
+ expect(message.get('plaintext')).toBe('This is an edited encrypted message from the contact');
+ expect(message.get('is_encrypted')).toBe(true);
+ expect(message.get('body')).toBe(fallback_text);
+ expect(message.get('msgid')).toBe(first_received_id);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js
new file mode 100644
index 0000000..46b4b48
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js
@@ -0,0 +1,155 @@
+/*global mock, converse */
+
+const { $iq, Strophe, u } = converse.env;
+
+
+describe("The OMEMO module", function() {
+
+ it("implements XEP-0454 to encrypt uploaded files",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const base_url = 'https://example.org/';
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const send_backup = XMLHttpRequest.prototype.send;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.waitForRoster(_converse, 'current', 3);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+ await mock.openChatBoxFor(_converse, contact_jid);
+
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ let stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ const devicelist = _converse.devicelists.get({'jid': contact_jid});
+ await u.waitUntil(() => devicelist.devices.length === 1);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = new File(['secret'], 'secret.txt', { type: 'text/plain' })
+ view.model.set('omemo_active', true);
+ view.model.sendFiles([file]);
+
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+ const iq = IQ_stanzas.pop();
+ const url = base_url+"/secret.txt";
+ stanza = u.toStanza(`
+ <iq from="upload.montague.tld"
+ id="${iq.getAttribute("id")}"
+ to="romeo@montague.lit/orchard"
+ type="result">
+ <slot xmlns="urn:xmpp:http:upload:0">
+ <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/secret.txt">
+ <header name="Authorization">Basic Base64String==</header>
+ <header name="Cookie">foo=bar; user=romeo</header>
+ </put>
+ <get url="${url}" />
+ </slot>
+ </iq>`);
+
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
+ const message = view.model.messages.at(0);
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
+ message.save({
+ 'upload': _converse.SUCCESS,
+ 'oob_url': message.get('get'),
+ 'body': message.get('get')
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ });
+ let sent_stanza;
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t(btoa('3333')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+ spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => sent_stanza);
+
+ const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lady.montague@montague.lit" `+
+ `type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>${fallback}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="555">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${sent_stanza.querySelector('header iv').textContent}</iv>`+
+ `</header>`+
+ `<payload>${sent_stanza.querySelector('payload').textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+ const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(link_el.textContent.trim()).toBe(url);
+
+ const message = view.model.messages.at(0);
+ expect(message.get('is_encrypted')).toBe(true);
+
+ XMLHttpRequest.prototype.send = send_backup;
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js
new file mode 100644
index 0000000..65aa687
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js
@@ -0,0 +1,478 @@
+/*global mock, converse */
+
+const { $iq, $msg, $pres, Strophe, omemo } = converse.env;
+const u = converse.env.utils;
+
+describe("The OMEMO module", function() {
+
+ it("enables encrypted groupchat messages to be sent and received",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ // MEMO encryption works only in members only conferences
+ // that are non-anonymous.
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ const muc_jid = 'lounge@montague.lit';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ const el = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
+ el.click();
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ // newguy enters the room
+ const contact_jid = 'newguy@montague.lit';
+ let stanza = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // Wait for Converse to fetch newguy's device list
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ // The server returns his device list
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(2);
+
+ await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ const devicelist = _converse.devicelists.get(contact_jid);
+ expect(devicelist.devices.length).toBe(1);
+ expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ const icon = toolbar.querySelector('.toggle-omemo converse-icon');
+ expect(u.hasClass('fa-unlock', icon)).toBe(false);
+ expect(u.hasClass('fa-lock', icon)).toBe(true);
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This message will be encrypted';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t(btoa('3333')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+ spyOn(_converse.connection, 'send');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.connection.send.calls.count(), 1000);
+ const sent_stanza = _converse.connection.send.calls.all()[0].args[0];
+
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lounge@montague.lit" `+
+ `type="groupchat" `+
+ `xmlns="jabber:client">`+
+ `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+
+ `</header>`+
+ `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+ // Test reception of an encrypted message
+ const obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+ // XXX: Normally the key will be encrypted via libsignal.
+ // However, we're mocking libsignal in the tests, so we include it as plaintext in the message.
+ stanza = $msg({
+ 'from': `${muc_jid}/newguy`,
+ 'to': _converse.connection.jid,
+ 'type': 'groupchat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('This is a fallback message').up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
+ .toBe('This is an encrypted message from the contact');
+
+ expect(_converse.devicelists.length).toBe(2);
+ expect(_converse.devicelists.at(0).get('jid')).toBe(_converse.bare_jid);
+ expect(_converse.devicelists.at(1).get('jid')).toBe(contact_jid);
+ }));
+
+ it("gracefully handles auth errors when trying to send encrypted groupchat messages",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ // MEMO encryption works only in members only conferences
+ // that are non-anonymous.
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+ const contact_jid = 'newguy@montague.lit';
+ let stanza = $pres({
+ 'to': 'romeo@montague.lit/orchard',
+ 'from': 'lounge@montague.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ const toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
+ toggle.click();
+ expect(view.model.get('omemo_active')).toBe(true);
+ expect(view.model.get('omemo_supported')).toBe(true);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'This message will be encrypted';
+ const message_form = view.querySelector('converse-muc-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(2);
+
+ const devicelist = _converse.devicelists.get(contact_jid);
+ await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(devicelist.devices.length).toBe(1);
+ expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'));
+
+ /* <iq xmlns="jabber:client" to="jc@opkode.com/converse.js-34183907" type="error" id="945c8ab3-b561-4d8a-92da-77c226bb1689:sendIQ" from="joris@konuro.net">
+ * <pubsub xmlns="http://jabber.org/protocol/pubsub">
+ * <items node="eu.siacs.conversations.axolotl.bundles:7580"/>
+ * </pubsub>
+ * <error code="401" type="auth">
+ * <presence-subscription-required xmlns="http://jabber.org/protocol/pubsub#errors"/>
+ * <not-authorized xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ * </error>
+ * </iq>
+ */
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': 'http://jabber.org/protocol/pubsub'})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"}).up().up()
+ .c('error', {'code': '401', 'type': 'auth'})
+ .c('presence-subscription-required', {'xmlns':"http://jabber.org/protocol/pubsub#errors" }).up()
+ .c('not-authorized', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => document.querySelectorAll('.alert-danger').length, 2000);
+ const header = document.querySelector('.alert-danger .modal-title');
+ expect(header.textContent).toBe("Error");
+ expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim())
+ .toBe("Sorry, we're unable to send an encrypted message because newguy@montague.lit requires you "+
+ "to be subscribed to their presence in order to see their OMEMO information");
+
+ expect(view.model.get('omemo_supported')).toBe(false);
+ expect(view.querySelector('.chat-textarea').value).toBe('This message will be encrypted');
+ }));
+
+
+ it("adds a toolbar button for starting an encrypted groupchat session",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ // MEMO encryption works only in members-only conferences that are non-anonymous.
+ const features = [
+ 'http://jabber.org/protocol/muc',
+ 'jabber:iq:register',
+ 'muc_passwordprotected',
+ 'muc_hidden',
+ 'muc_temporary',
+ 'muc_membersonly',
+ 'muc_unmoderated',
+ 'muc_nonanonymous'
+ ];
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features);
+ const view = _converse.chatboxviews.get('lounge@montague.lit');
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
+ expect(view.model.get('omemo_active')).toBe(undefined);
+ expect(view.model.get('omemo_supported')).toBe(true);
+ await u.waitUntil(() => toggle.dataset.disabled === "false");
+
+ let icon = toolbar.querySelector('.toggle-omemo converse-icon');
+ expect(u.hasClass('fa-unlock', icon)).toBe(true);
+ expect(u.hasClass('fa-lock', icon)).toBe(false);
+
+ toggle.click();
+ toggle = toolbar.querySelector('.toggle-omemo');
+ expect(toggle.dataset.disabled).toBe("false");
+ expect(view.model.get('omemo_active')).toBe(true);
+ expect(view.model.get('omemo_supported')).toBe(true);
+
+ await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon')));
+ expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+
+ let contact_jid = 'newguy@montague.lit';
+ let stanza = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'lounge@montague.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+ .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(2);
+
+ await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ const devicelist = _converse.devicelists.get(contact_jid);
+ expect(devicelist.devices.length).toBe(2);
+ expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+ expect(devicelist.devices.at(1).get('id')).toBe('ae890ac52d0df67ed7cfdf51b644e901');
+
+ expect(view.model.get('omemo_active')).toBe(true);
+ toggle = toolbar.querySelector('.toggle-omemo');
+ expect(toggle === null).toBe(false);
+ expect(toggle.dataset.disabled).toBe("false");
+ expect(view.model.get('omemo_supported')).toBe(true);
+
+ await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon')));
+ expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+
+ // Test that the button gets disabled when the room becomes
+ // anonymous or semi-anonymous
+ view.model.features.save({'nonanonymous': false, 'semianonymous': true});
+ await u.waitUntil(() => !view.model.get('omemo_supported'));
+ await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true");
+
+ view.model.features.save({'nonanonymous': true, 'semianonymous': false});
+ await u.waitUntil(() => view.model.get('omemo_supported'));
+ await u.waitUntil(() => view.querySelector('.toggle-omemo') !== null);
+ expect(u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+ expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(false);
+ expect(view.querySelector('.toggle-omemo').dataset.disabled).toBe("false");
+
+ // Test that the button gets disabled when the room becomes open
+ view.model.features.save({'membersonly': false, 'open': true});
+ await u.waitUntil(() => !view.model.get('omemo_supported'));
+ await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true");
+
+ view.model.features.save({'membersonly': true, 'open': false});
+ await u.waitUntil(() => view.model.get('omemo_supported'));
+ await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "false");
+
+ expect(u.hasClass('fa-unlock', view.querySelector('.toggle-omemo converse-icon'))).toBe(true);
+ expect(u.hasClass('fa-lock', view.querySelector('.toggle-omemo converse-icon'))).toBe(false);
+
+ expect(view.model.get('omemo_supported')).toBe(true);
+ expect(view.model.get('omemo_active')).toBe(false);
+
+ view.querySelector('.toggle-omemo').click();
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ // Someone enters the room who doesn't have OMEMO support, while we
+ // have OMEMO activated...
+ contact_jid = 'oldguy@montague.lit';
+ stanza = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'lounge@montague.lit/oldguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': `${contact_jid}/_converse.js-290929788`,
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'error'
+ }).c('error', {'type': 'cancel'})
+ .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => !view.model.get('omemo_supported'));
+ await u.waitUntil(() => view.querySelector('.chat-error .chat-info__message')?.textContent.trim() ===
+ "oldguy doesn't appear to have a client that supports OMEMO. "+
+ "Encrypted chat will no longer be possible in this grouchat."
+ );
+
+ await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').dataset.disabled === "true");
+ icon = view.querySelector('.toggle-omemo converse-icon');
+ expect(u.hasClass('fa-unlock', icon)).toBe(true);
+ expect(u.hasClass('fa-lock', icon)).toBe(false);
+ expect(toolbar.querySelector('.toggle-omemo').title).toBe('This groupchat needs to be members-only and non-anonymous in order to support OMEMO encrypted messages');
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js
new file mode 100644
index 0000000..0ed7138
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js
@@ -0,0 +1,1130 @@
+/*global mock, converse */
+
+const { $iq, $msg, omemo, Strophe } = converse.env;
+const u = converse.env.utils;
+
+describe("The OMEMO module", function() {
+
+ it("adds methods for encrypting and decrypting messages via AES GCM",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const message = 'This message will be encrypted'
+ await mock.waitForRoster(_converse, 'current', 1);
+ const payload = await omemo.encryptMessage(message);
+ const result = await omemo.decryptMessage(payload);
+ expect(result).toBe(message);
+ }));
+
+ it("enables encrypted messages to be sent and received",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ let sent_stanza;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.initializedOMEMO(_converse);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ let stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ const devicelist = _converse.devicelists.get({'jid': contact_jid});
+ await u.waitUntil(() => devicelist.devices.length === 1);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ view.model.set('omemo_active', true);
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This message will be encrypted';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t(btoa('3333')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+ .c('signedPreKeySignature').t(btoa('200000')).up()
+ .c('identityKey').t(btoa('300000')).up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+ spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => sent_stanza);
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" `+
+ `type="chat" xmlns="jabber:client">`+
+ `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+ `<header sid="123456789">`+
+ `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+ `<key rid="555">YzFwaDNSNzNYNw==</key>`+
+ `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+
+ `</header>`+
+ `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+
+ `</encrypted>`+
+ `<store xmlns="urn:xmpp:hints"/>`+
+ `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+ `</message>`);
+
+ // Test reception of an encrypted message
+ let obj = await omemo.encryptMessage('This is an encrypted message from the contact')
+ // XXX: Normally the key will be encrypted via libsignal.
+ // However, we're mocking libsignal in the tests, so we include it as plaintext in the message.
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('This is a fallback message').up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(2);
+ expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim())
+ .toBe('This is an encrypted message from the contact');
+
+ // #1193 Check for a received message without <body> tag
+ obj = await omemo.encryptMessage('Another received encrypted message without fallback')
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.model.messages.length > 1);
+ expect(view.model.messages.length).toBe(3);
+ expect(view.querySelectorAll('.chat-msg__body')[2].textContent.trim())
+ .toBe('Another received encrypted message without fallback');
+ }));
+
+ it("properly handles an already decrypted message being received again",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.initializedOMEMO(_converse);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ let stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => _converse.omemo_store);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ view.model.set('omemo_active', true);
+
+ // Test reception of an encrypted message
+ const msg_txt = 'This is an encrypted message from the contact';
+ const obj = await omemo.encryptMessage(msg_txt)
+ const id = _converse.connection.getUniqueId();
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ id
+ }).c('body').t('This is a fallback message').up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')})
+ .t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // Test reception of the same message, but the decryption fails.
+ // The properly decrypted message should still show to the user.
+ // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ id
+ }).c('body').t('This is a fallback message').up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {'rid': _converse.omemo_store.get('device_id')})
+ .t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload+'x'); // Hack to break decryption.
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__text')?.textContent.trim() === msg_txt);
+
+ expect(view.model.messages.length).toBe(1);
+ const msg = view.model.messages.at(0);
+ expect(msg.get('is_ephemeral')).toBe(false)
+ expect(msg.getDisplayName()).toBe('Mercutio');
+ expect(msg.get('is_error')).toBe(false);
+ }));
+
+ it("will create a new device based on a received carbon message",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid});
+ expect(my_devicelist.devices.length).toBe(2);
+
+ const stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+
+ const contact_devicelist = _converse.devicelists.get({'jid': contact_jid});
+ await u.waitUntil(() => contact_devicelist.devices.length === 1);
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ view.model.set('omemo_active', true);
+
+ // Test reception of an encrypted carbon message
+ const obj = await omemo.encryptMessage('This is an encrypted carbon message from another device of mine')
+ const carbon = u.toStanza(`
+ <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat">
+ <sent xmlns="urn:xmpp:carbons:2">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <message xmlns="jabber:client"
+ from="romeo@montague.lit/gajim.HE02SW1L"
+ xml:lang="en"
+ to="${contact_jid}/gajim.0LATM5V2"
+ type="chat" id="87141781-61d6-4eb3-9a31-429935a61b76">
+
+ <archived xmlns="urn:xmpp:mam:tmp" by="romeo@montague.lit" id="1554033877043470"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" by="romeo@montague.lit" id="1554033877043470"/>
+ <request xmlns="urn:xmpp:receipts"/>
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="87141781-61d6-4eb3-9a31-429935a61b76"/>
+ <encrypted xmlns="eu.siacs.conversations.axolotl">
+ <header sid="988349631">
+ <key rid="${_converse.omemo_store.get('device_id')}"
+ prekey="true">${u.arrayBufferToBase64(obj.key_and_tag)}</key>
+ <iv>${obj.iv}</iv>
+ </header>
+ <payload>${obj.payload}</payload>
+ </encrypted>
+ <encryption xmlns="urn:xmpp:eme:0" namespace="eu.siacs.conversations.axolotl" name="OMEMO"/>
+ <store xmlns="urn:xmpp:hints"/>
+ </message>
+ </forwarded>
+ </sent>
+ </message>
+ `);
+ _converse.connection.IQ_stanzas = [];
+ _converse.connection._dataRecv(mock.createRequest(carbon));
+
+ // The message received is a prekey message, so missing prekeys are
+ // generated and a new bundle published.
+ iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
+ const result_iq = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(result_iq));
+
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.model.messages.length).toBe(1);
+
+ expect(view.querySelector('.chat-msg__text').textContent.trim())
+ .toBe('This is an encrypted carbon message from another device of mine');
+
+ expect(contact_devicelist.devices.length).toBe(1);
+
+ // Check that the new device id has been added to my devices
+ expect(my_devicelist.devices.length).toBe(3);
+ expect(my_devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe');
+ expect(my_devicelist.devices.at(1).get('id')).toBe('123456789');
+ expect(my_devicelist.devices.at(2).get('id')).toBe('988349631');
+ expect(my_devicelist.devices.get('988349631').get('active')).toBe(true);
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This is an encrypted message from this device';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '988349631'));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${_converse.bare_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.bundles:988349631"/>`+
+ `</pubsub>`+
+ `</iq>`);
+ }));
+
+ it("can receive a PreKeySignalMessage",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ _converse.NUM_PREKEYS = 5; // Restrict to 5, otherwise the resulting stanza is too large to easily test
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ await u.waitUntil(() => mock.initializedOMEMO(_converse));
+ const obj = await omemo.encryptMessage('This is an encrypted message from the contact');
+ // XXX: Normally the key will be encrypted via libsignal.
+ // However, we're mocking libsignal in the tests, so we include
+ // it as plaintext in the message.
+ let stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': 'qwerty'
+ }).c('body').t('This is a fallback message').up()
+ .c('encrypted', {'xmlns': Strophe.NS.OMEMO})
+ .c('header', {'sid': '555'})
+ .c('key', {
+ 'prekey': 'true',
+ 'rid': _converse.omemo_store.get('device_id')
+ }).t(u.arrayBufferToBase64(obj.key_and_tag)).up()
+ .c('iv').t(obj.iv)
+ .up().up()
+ .c('payload').t(obj.payload);
+
+ const generateMissingPreKeys = _converse.omemo_store.generateMissingPreKeys;
+ spyOn(_converse.omemo_store, 'generateMissingPreKeys').and.callFake(() => {
+ // Since it's difficult to override
+ // decryptPreKeyWhisperMessage, where a prekey will be
+ // removed from the store, we do it here, before the
+ // missing prekeys are generated.
+ _converse.omemo_store.removePreKey(1);
+ return generateMissingPreKeys.apply(_converse.omemo_store, arguments);
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let iq_stanza = await mock.deviceListFetched(_converse, contact_jid);
+ stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+
+ // XXX: the bundle gets published twice, we want to make sure
+ // that we wait for the 2nd, so we clear all the already sent
+ // stanzas.
+ _converse.connection.IQ_stanzas = [];
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000);
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="eu.siacs.conversations.axolotl.bundles:123456789">`+
+ `<item>`+
+ `<bundle xmlns="eu.siacs.conversations.axolotl">`+
+ `<signedPreKeyPublic signedPreKeyId="0">${btoa("1234")}</signedPreKeyPublic>`+
+ `<signedPreKeySignature>${btoa("11112222333344445555")}</signedPreKeySignature>`+
+ `<identityKey>${btoa("1234")}</identityKey>`+
+ `<prekeys>`+
+ `<preKeyPublic preKeyId="0">${btoa("1234")}</preKeyPublic>`+
+ `<preKeyPublic preKeyId="1">${btoa("1234")}</preKeyPublic>`+
+ `<preKeyPublic preKeyId="2">${btoa("1234")}</preKeyPublic>`+
+ `<preKeyPublic preKeyId="3">${btoa("1234")}</preKeyPublic>`+
+ `<preKeyPublic preKeyId="4">${btoa("1234")}</preKeyPublic>`+
+ `</prekeys>`+
+ `</bundle>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>open</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`)
+ const own_device = _converse.devicelists.get(_converse.bare_jid).devices.get(_converse.omemo_store.get('device_id'));
+ expect(own_device.get('bundle').prekeys.length).toBe(5);
+ expect(_converse.omemo_store.generateMissingPreKeys).toHaveBeenCalled();
+ }));
+
+ it("updates device lists based on PEP messages",
+ mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ // Wait until own devices are fetched
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ let stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.chatboxes.length).toBe(1);
+ expect(_converse.devicelists.length).toBe(1);
+ const devicelist = _converse.devicelists.get(_converse.bare_jid);
+ expect(devicelist.devices.length).toBe(2);
+ expect(devicelist.devices.at(0).get('id')).toBe('555');
+ expect(devicelist.devices.at(1).get('id')).toBe('123456789');
+ iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
+
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await _converse.api.waitUntil('OMEMOInitialized');
+
+
+ // A PEP message is received with a device list.
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': contact_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_01',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
+ .c('item')
+ .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('device', {'id': '1234'}).up()
+ .c('device', {'id': '4223'})
+ ));
+
+ // Since we haven't yet fetched any devices for this user, the
+ // devicelist model for them isn't yet initialized.
+ // It will be created and then automatically the devices will
+ // be requested from the server via IQ stanza.
+ //
+ // This is perhaps a bit wasteful since we're already (AFIAK) getting the info we need
+ // from the PEP headline message, but the code is simpler this way.
+ const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_devicelist_get.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '1234'}).up()
+ .c('device', {'id': '4223'})
+ ));
+
+ await u.waitUntil(() => _converse.devicelists.length === 2);
+
+ const list = _converse.devicelists.get(contact_jid);
+ await list.initialized;
+ await u.waitUntil(() => list.devices.length === 2);
+
+ let devices = list.devices;
+ expect(list.devices.length).toBe(2);
+ expect(list.devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223');
+
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_02',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
+ .c('item')
+ .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('device', {'id': '4223'}).up()
+ .c('device', {'id': '4224'})
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ expect(_converse.devicelists.length).toBe(2);
+ await u.waitUntil(() => list.devices.length === 3);
+ expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223,4224');
+ expect(devices.get('1234').get('active')).toBe(false);
+ expect(devices.get('4223').get('active')).toBe(true);
+ expect(devices.get('4224').get('active')).toBe(true);
+
+ // Check that own devicelist gets updated
+ stanza = $msg({
+ 'from': _converse.bare_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_03',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
+ .c('item')
+ .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('device', {'id': '123456789'})
+ .c('device', {'id': '555'})
+ .c('device', {'id': '777'})
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ expect(_converse.devicelists.length).toBe(2);
+ devices = _converse.devicelists.get(_converse.bare_jid).devices;
+ await u.waitUntil(() => devices.length === 3);
+ expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('123456789,555,777');
+ expect(devices.get('123456789').get('active')).toBe(true);
+ expect(devices.get('555').get('active')).toBe(true);
+ expect(devices.get('777').get('active')).toBe(true);
+
+ _converse.connection.IQ_stanzas = [];
+
+ // Check that own device gets re-added
+ stanza = $msg({
+ 'from': _converse.bare_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_04',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'})
+ .c('item')
+ .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('device', {'id': '444'})
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
+ // Check that our own device is added again, but that removed
+ // devices are not added.
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute(`id`)}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="eu.siacs.conversations.axolotl.devicelist">`+
+ `<item id="current">`+
+ `<list xmlns="eu.siacs.conversations.axolotl">`+
+ `<device id="123456789"/>`+
+ `<device id="444"/>`+
+ `</list>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>open</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`);
+ expect(_converse.devicelists.length).toBe(2);
+ devices = _converse.devicelists.get(_converse.bare_jid).devices;
+ // The device id for this device (123456789) was also generated and added to the list,
+ // which is why we have 2 devices now.
+ expect(devices.length).toBe(4);
+ expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('123456789,444,555,777');
+ expect(devices.get('123456789').get('active')).toBe(true);
+ expect(devices.get('444').get('active')).toBe(true);
+ expect(devices.get('555').get('active')).toBe(false);
+ expect(devices.get('777').get('active')).toBe(false);
+ }));
+
+
+ it("updates device bundles based on PEP messages",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'})
+ ));
+
+ await await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(1);
+ const own_device_list = _converse.devicelists.get(_converse.bare_jid);
+ expect(own_device_list.devices.length).toBe(2);
+ expect(own_device_list.devices.at(0).get('id')).toBe('555');
+ expect(own_device_list.devices.at(1).get('id')).toBe('123456789');
+ iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
+ let stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await _converse.api.waitUntil('OMEMOInitialized');
+
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': contact_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_01',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('1111').up()
+ .c('signedPreKeySignature').t('2222').up()
+ .c('identityKey').t('3333').up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1001'}).up()
+ .c('preKeyPublic', {'preKeyId': '1002'}).up()
+ .c('preKeyPublic', {'preKeyId': '1003'})
+ ));
+
+ // Since we haven't yet fetched any devices for this user, the
+ // devicelist model for them isn't yet initialized.
+ // It will be created and then automatically the devices will
+ // be requested from the server via IQ stanza.
+ const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_devicelist_get.getAttribute('id'),
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '1234'})
+ ));
+
+ await u.waitUntil(() => _converse.devicelists.length === 2);
+ const list = _converse.devicelists.get(contact_jid);
+ await list.initialized;
+ await u.waitUntil(() => list.devices.length);
+ let device = list.devices.at(0);
+ expect(device.get('bundle').identity_key).toBe('3333');
+ expect(device.get('bundle').signed_prekey.public_key).toBe('1111');
+ expect(device.get('bundle').signed_prekey.id).toBe(4223);
+ expect(device.get('bundle').signed_prekey.signature).toBe('2222');
+ expect(device.get('bundle').prekeys.length).toBe(3);
+ expect(device.get('bundle').prekeys[0].id).toBe(1001);
+ expect(device.get('bundle').prekeys[1].id).toBe(1002);
+ expect(device.get('bundle').prekeys[2].id).toBe(1003);
+
+ stanza = $msg({
+ 'from': contact_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_02',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('5555').up()
+ .c('signedPreKeySignature').t('6666').up()
+ .c('identityKey').t('7777').up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '2001'}).up()
+ .c('preKeyPublic', {'preKeyId': '2002'}).up()
+ .c('preKeyPublic', {'preKeyId': '2003'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ expect(_converse.devicelists.length).toBe(2);
+ expect(list.devices.length).toBe(1);
+ device = list.devices.at(0);
+
+ await u.waitUntil(() => device.get('bundle').identity_key === '7777');
+ expect(device.get('bundle').signed_prekey.public_key).toBe('5555');
+ expect(device.get('bundle').signed_prekey.id).toBe(4223);
+ expect(device.get('bundle').signed_prekey.signature).toBe('6666');
+ expect(device.get('bundle').prekeys.length).toBe(3);
+ expect(device.get('bundle').prekeys[0].id).toBe(2001);
+ expect(device.get('bundle').prekeys[1].id).toBe(2002);
+ expect(device.get('bundle').prekeys[2].id).toBe(2003);
+
+ _converse.connection._dataRecv(mock.createRequest($msg({
+ 'from': _converse.bare_jid,
+ 'to': _converse.bare_jid,
+ 'type': 'headline',
+ 'id': 'update_03',
+ }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'})
+ .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:555'})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '9999'}).t('8888').up()
+ .c('signedPreKeySignature').t('3333').up()
+ .c('identityKey').t('1111').up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '3001'}).up()
+ .c('preKeyPublic', {'preKeyId': '3002'}).up()
+ .c('preKeyPublic', {'preKeyId': '3003'})
+ ));
+
+ expect(_converse.devicelists.length).toBe(2);
+ expect(own_device_list.devices.length).toBe(2);
+ expect(own_device_list.devices.at(0).get('id')).toBe('555');
+ expect(own_device_list.devices.at(1).get('id')).toBe('123456789');
+ device = own_device_list.devices.at(0);
+ await u.waitUntil(() => device.get('bundle')?.identity_key === '1111');
+ expect(device.get('bundle').signed_prekey.public_key).toBe('8888');
+ expect(device.get('bundle').signed_prekey.id).toBe(9999);
+ expect(device.get('bundle').signed_prekey.signature).toBe('3333');
+ expect(device.get('bundle').prekeys.length).toBe(3);
+ expect(device.get('bundle').prekeys[0].id).toBe(3001);
+ expect(device.get('bundle').prekeys[1].id).toBe(3002);
+ expect(device.get('bundle').prekeys[2].id).toBe(3003);
+ }));
+
+ it("publishes a bundle with which an encrypted session can be created",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ _converse.NUM_PREKEYS = 2; // Restrict to 2, otherwise the resulting stanza is too large to easily test
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
+ let stanza = $iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '482886413b977930064a5888b92134fe'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_converse.devicelists.length).toBe(1);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ iq_stanza = await mock.ownDeviceHasBeenPublished(_converse);
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="eu.siacs.conversations.axolotl.bundles:123456789">`+
+ `<item>`+
+ `<bundle xmlns="eu.siacs.conversations.axolotl">`+
+ `<signedPreKeyPublic signedPreKeyId="0">${btoa("1234")}</signedPreKeyPublic>`+
+ `<signedPreKeySignature>${btoa("11112222333344445555")}</signedPreKeySignature>`+
+ `<identityKey>${btoa("1234")}</identityKey>`+
+ `<prekeys>`+
+ `<preKeyPublic preKeyId="0">${btoa("1234")}</preKeyPublic>`+
+ `<preKeyPublic preKeyId="1">${btoa("1234")}</preKeyPublic>`+
+ `</prekeys>`+
+ `</bundle>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>open</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`)
+
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await _converse.api.waitUntil('OMEMOInitialized');
+ }));
+
+
+ it("adds a toolbar button for starting an encrypted chat session",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ let stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '482886413b977930064a5888b92134fe'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.omemo_store);
+ expect(_converse.devicelists.length).toBe(1);
+ let devicelist = _converse.devicelists.get(_converse.bare_jid);
+ expect(devicelist.devices.length).toBe(2);
+ expect(devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe');
+ expect(devicelist.devices.at(1).get('id')).toBe('123456789');
+ // Check that own device was published
+ iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute(`id`)}" type="set" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<publish node="eu.siacs.conversations.axolotl.devicelist">`+
+ `<item id="current">`+
+ `<list xmlns="eu.siacs.conversations.axolotl">`+
+ `<device id="482886413b977930064a5888b92134fe"/>`+
+ `<device id="123456789"/>`+
+ `</list>`+
+ `</item>`+
+ `</publish>`+
+ `<publish-options>`+
+ `<x type="submit" xmlns="jabber:x:data">`+
+ `<field type="hidden" var="FORM_TYPE">`+
+ `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+
+ `</field>`+
+ `<field var="pubsub#access_model">`+
+ `<value>open</value>`+
+ `</field>`+
+ `</x>`+
+ `</publish-options>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ const iq_el = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse));
+ expect(iq_el.getAttributeNames().sort().join()).toBe(["from", "type", "xmlns", "id"].sort().join());
+ expect(iq_el.querySelector('prekeys').childNodes.length).toBe(100);
+
+ const signed_prekeys = iq_el.querySelectorAll('signedPreKeyPublic');
+ expect(signed_prekeys.length).toBe(1);
+ const signed_prekey = signed_prekeys[0];
+ expect(signed_prekey.getAttribute('signedPreKeyId')).toBe('0')
+ expect(iq_el.querySelectorAll('signedPreKeySignature').length).toBe(1);
+ expect(iq_el.querySelectorAll('identityKey').length).toBe(1);
+
+ stanza = $iq({
+ 'from': _converse.bare_jid,
+ 'id': iq_el.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await _converse.api.waitUntil('OMEMOInitialized', 1000);
+ await mock.openChatBoxFor(_converse, contact_jid);
+
+ iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '368866411b877c30064a5f62b917cffe'}).up()
+ .c('device', {'id': '3300659945416e274474e469a1f0154c'}).up()
+ .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+ .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'})
+ ));
+
+ devicelist = _converse.devicelists.get(contact_jid);
+ await u.waitUntil(() => devicelist.devices.length);
+ expect(_converse.devicelists.length).toBe(2);
+ devicelist = _converse.devicelists.get(contact_jid);
+ expect(devicelist.devices.length).toBe(4);
+ expect(devicelist.devices.at(0).get('id')).toBe('368866411b877c30064a5f62b917cffe');
+ expect(devicelist.devices.at(1).get('id')).toBe('3300659945416e274474e469a1f0154c');
+ expect(devicelist.devices.at(2).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+ expect(devicelist.devices.at(3).get('id')).toBe('ae890ac52d0df67ed7cfdf51b644e901');
+ await u.waitUntil(() => _converse.chatboxviews.get(contact_jid).querySelector('.chat-toolbar'));
+ const view = _converse.chatboxviews.get(contact_jid);
+ const toolbar = view.querySelector('.chat-toolbar');
+ expect(view.model.get('omemo_active')).toBe(undefined);
+ const toggle = toolbar.querySelector('.toggle-omemo');
+ expect(toggle === null).toBe(false);
+ expect(u.hasClass('fa-unlock', toggle.querySelector('converse-icon'))).toBe(true);
+ expect(u.hasClass('fa-lock', toggle.querySelector('.converse-icon'))).toBe(false);
+ toolbar.querySelector('.toggle-omemo').click();
+ expect(view.model.get('omemo_active')).toBe(true);
+
+ await u.waitUntil(() => u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon')));
+ let icon = toolbar.querySelector('.toggle-omemo converse-icon');
+ expect(u.hasClass('fa-unlock', icon)).toBe(false);
+ expect(u.hasClass('fa-lock', icon)).toBe(true);
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This message will be sent encrypted';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+
+ view.model.save({'omemo_supported': false});
+ await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "true");
+ icon = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo converse-icon'));
+ expect(u.hasClass('fa-lock', icon)).toBe(false);
+ expect(u.hasClass('fa-unlock', icon)).toBe(true);
+
+ view.model.save({'omemo_supported': true});
+ await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "false");
+ icon = toolbar.querySelector('.toggle-omemo converse-icon');
+ expect(u.hasClass('fa-lock', icon)).toBe(false);
+ expect(u.hasClass('fa-unlock', icon)).toBe(true);
+ }));
+
+ it("shows OMEMO device fingerprints in the user details modal",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type': 'pep'}],
+ ['http://jabber.org/protocol/pubsub#publish-options']
+ );
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ // We simply emit, to avoid doing all the setup work
+ _converse.api.trigger('OMEMOInitialized');
+
+ const view = _converse.chatboxviews.get(contact_jid);
+ const show_modal_button = view.querySelector('.show-user-details-modal');
+ show_modal_button.click();
+ const modal = _converse.api.modal.get('converse-user-details-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub>`+
+ `</iq>`);
+
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+ .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+ .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+ .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+ .c('device', {'id': '555'})
+ ));
+
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+ expect(Strophe.serialize(iq_stanza)).toBe(
+ `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+
+ `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+ `<items node="eu.siacs.conversations.axolotl.bundles:555"/>`+
+ `</pubsub>`+
+ `</iq>`);
+
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'from': contact_jid,
+ 'id': iq_stanza.getAttribute('id'),
+ 'to': _converse.bare_jid,
+ 'type': 'result',
+ }).c('pubsub', {
+ 'xmlns': 'http://jabber.org/protocol/pubsub'
+ }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+ .c('item')
+ .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+ .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+ .c('signedPreKeySignature').t(btoa('2222')).up()
+ .c('identityKey').t('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI').up()
+ .c('prekeys')
+ .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+ .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+ .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'))
+ ));
+
+ await u.waitUntil(() => modal.querySelectorAll('.fingerprints .fingerprint').length);
+ expect(modal.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);
+ const el = modal.querySelector('.fingerprints .fingerprint');
+ expect(el.textContent.trim()).toBe(
+ omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
+ );
+ expect(modal.querySelectorAll('input[type="radio"]').length).toBe(2);
+
+ const devicelist = _converse.devicelists.get(contact_jid);
+ expect(devicelist.devices.get('555').get('trusted')).toBe(0);
+
+ let trusted_radio = modal.querySelector('input[type="radio"][name="555"][value="1"]');
+ expect(trusted_radio.checked).toBe(true);
+
+ let untrusted_radio = modal.querySelector('input[type="radio"][name="555"][value="-1"]');
+ expect(untrusted_radio.checked).toBe(false);
+
+ // Test that the device can be set to untrusted
+ untrusted_radio.click();
+ trusted_radio = document.querySelector('input[type="radio"][name="555"][value="1"]');
+
+ await u.waitUntil(() => !trusted_radio.hasAttribute('checked'));
+ expect(devicelist.devices.get('555').get('trusted')).toBe(-1);
+
+ untrusted_radio = document.querySelector('input[type="radio"][name="555"][value="-1"]');
+ expect(untrusted_radio.hasAttribute('checked')).toBe(true);
+
+ trusted_radio.click();
+ expect(devicelist.devices.get('555').get('trusted')).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js
new file mode 100644
index 0000000..0605758
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js
@@ -0,0 +1,866 @@
+/* global libsignal */
+import concat from 'lodash-es/concat';
+import difference from 'lodash-es/difference';
+import log from '@converse/headless/log';
+import tplAudio from 'templates/audio.js';
+import tplFile from 'templates/file.js';
+import tplImage from 'templates/image.js';
+import tplVideo from 'templates/video.js';
+import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js';
+import { MIMETYPES_MAP } from 'utils/file.js';
+import { __ } from 'i18n';
+import { _converse, converse, api } from '@converse/headless/core';
+import { html } from 'lit';
+import { initStorage } from '@converse/headless/utils/storage.js';
+import { isError } from '@converse/headless/utils/core.js';
+import { isAudioURL, isImageURL, isVideoURL, getURI } from '@converse/headless/utils/url.js';
+import { until } from 'lit/directives/until.js';
+import {
+ appendArrayBuffer,
+ arrayBufferToBase64,
+ arrayBufferToHex,
+ arrayBufferToString,
+ base64ToArrayBuffer,
+ hexToArrayBuffer,
+ stringToArrayBuffer
+} from '@converse/headless/utils/arraybuffer.js';
+
+const { Strophe, URI, sizzle, u } = converse.env;
+
+export function formatFingerprint (fp) {
+ fp = fp.replace(/^05/, '');
+ for (let i=1; i<8; i++) {
+ const idx = i*8+i-1;
+ fp = fp.slice(0, idx) + ' ' + fp.slice(idx);
+ }
+ return fp;
+}
+
+export function handleMessageSendError (e, chat) {
+ if (e.name === 'IQError') {
+ chat.save('omemo_supported', false);
+
+ const err_msgs = [];
+ if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
+ err_msgs.push(
+ __(
+ "Sorry, we're unable to send an encrypted message because %1$s " +
+ 'requires you to be subscribed to their presence in order to see their OMEMO information',
+ e.iq.getAttribute('from')
+ )
+ );
+ } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
+ err_msgs.push(
+ __(
+ "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
+ e.iq.getAttribute('from')
+ )
+ );
+ } else {
+ err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
+ err_msgs.push(e.iq.outerHTML);
+ }
+ api.alert('error', __('Error'), err_msgs);
+ } else if (e.user_facing) {
+ api.alert('error', __('Error'), [e.message]);
+ }
+ throw e;
+}
+
+export function getOutgoingMessageAttributes (chat, attrs) {
+ if (chat.get('omemo_active') && attrs.body) {
+ attrs['is_encrypted'] = true;
+ attrs['plaintext'] = attrs.body;
+ attrs['body'] = __(
+ 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
+ 'Find more information on https://conversations.im/omemo'
+ );
+ }
+ return attrs;
+}
+
+async function encryptMessage (plaintext) {
+ // The client MUST use fresh, randomly generated key/IV pairs
+ // with AES-128 in Galois/Counter Mode (GCM).
+
+ // For GCM a 12 byte IV is strongly suggested as other IV lengths
+ // will require additional calculations. In principle any IV size
+ // can be used as long as the IV doesn't ever repeat. NIST however
+ // suggests that only an IV size of 12 bytes needs to be supported
+ // by implementations.
+ //
+ // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode
+ const iv = crypto.getRandomValues(new window.Uint8Array(12));
+ const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']);
+ const algo = {
+ 'name': 'AES-GCM',
+ 'iv': iv,
+ 'tagLength': TAG_LENGTH
+ };
+ const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext));
+ const length = encrypted.byteLength - ((128 + 7) >> 3);
+ const ciphertext = encrypted.slice(0, length);
+ const tag = encrypted.slice(length);
+ const exported_key = await crypto.subtle.exportKey('raw', key);
+ return {
+ 'key': exported_key,
+ 'tag': tag,
+ 'key_and_tag': appendArrayBuffer(exported_key, tag),
+ 'payload': arrayBufferToBase64(ciphertext),
+ 'iv': arrayBufferToBase64(iv)
+ };
+}
+
+async function decryptMessage (obj) {
+ const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']);
+ const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag);
+ const algo = {
+ 'name': 'AES-GCM',
+ 'iv': base64ToArrayBuffer(obj.iv),
+ 'tagLength': TAG_LENGTH
+ };
+ return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher));
+}
+
+export async function encryptFile (file) {
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']);
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, }, key, await file.arrayBuffer());
+ const exported_key = await window.crypto.subtle.exportKey('raw', key);
+ const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified });
+ encrypted_file.xep454_ivkey = arrayBufferToHex(iv) + arrayBufferToHex(exported_key);
+ return encrypted_file;
+}
+
+export function setEncryptedFileURL (message, attrs) {
+ const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey;
+ return Object.assign(attrs, {
+ 'oob_url': null, // Since only the body gets encrypted, we don't set the oob_url
+ 'message': url,
+ 'body': url
+ });
+}
+
+async function decryptFile (iv, key, cipher) {
+ const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']);
+ const algo = {
+ 'name': 'AES-GCM',
+ 'iv': hexToArrayBuffer(iv),
+ };
+ return crypto.subtle.decrypt(algo, key_obj, cipher);
+}
+
+async function downloadFile(url) {
+ let response;
+ try {
+ response = await fetch(url)
+ } catch(e) {
+ log.error(`${e.name}: Failed to download encrypted media: ${url}`);
+ log.error(e);
+ return null;
+ }
+
+ if (response.status >= 200 && response.status < 400) {
+ return response.arrayBuffer();
+ }
+}
+
+async function getAndDecryptFile (uri) {
+ const protocol = (window.location.hostname === 'localhost' && uri.domain() === 'localhost') ? 'http' : 'https';
+ const http_url = uri.toString().replace(/^aesgcm/, protocol);
+ const cipher = await downloadFile(http_url);
+ if (cipher === null) {
+ log.error(`Could not decrypt a received encrypted file ${uri.toString()} since it could not be downloaded`);
+ return new Error(__('Error: could not decrypt a received encrypted file, because it could not be downloaded'));
+ }
+
+ const hash = uri.hash().slice(1);
+ const key = hash.substring(hash.length-64);
+ const iv = hash.replace(key, '');
+ let content;
+ try {
+ content = await decryptFile(iv, key, cipher);
+ } catch (e) {
+ log.error(`Could not decrypt file ${uri.toString()}`);
+ log.error(e);
+ return null;
+ }
+ const [filename, extension] = uri.filename().split('.');
+ const mimetype = MIMETYPES_MAP[extension];
+ try {
+ const file = new File([content], filename, { 'type': mimetype });
+ return URL.createObjectURL(file);
+ } catch (e) {
+ log.error(`Could not decrypt file ${uri.toString()}`);
+ log.error(e);
+ return null;
+ }
+}
+
+function getTemplateForObjectURL (uri, obj_url, richtext) {
+ if (isError(obj_url)) {
+ return html`<p class="error">${obj_url.message}</p>`;
+ }
+
+ const file_url = uri.toString();
+ if (isImageURL(file_url)) {
+ return tplImage({
+ 'src': obj_url,
+ 'onClick': richtext.onImgClick,
+ 'onLoad': richtext.onImgLoad
+ });
+ } else if (isAudioURL(file_url)) {
+ return tplAudio(obj_url);
+ } else if (isVideoURL(file_url)) {
+ return tplVideo(obj_url);
+ } else {
+ return tplFile(obj_url, uri.filename());
+ }
+
+}
+
+function addEncryptedFiles(text, offset, richtext) {
+ const objs = [];
+ try {
+ const parse_options = { 'start': /\b(aesgcm:\/\/)/gi };
+ URI.withinString(
+ text,
+ (url, start, end) => {
+ objs.push({ url, start, end });
+ return url;
+ },
+ parse_options
+ );
+ } catch (error) {
+ log.debug(error);
+ return;
+ }
+ objs.forEach(o => {
+ const uri = getURI(text.slice(o.start, o.end));
+ const promise = getAndDecryptFile(uri)
+ .then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext));
+
+ const template = html`${until(promise, '')}`;
+ richtext.addTemplateResult(o.start + offset, o.end + offset, template);
+ });
+}
+
+export function handleEncryptedFiles (richtext) {
+ if (!_converse.config.get('trusted')) {
+ return;
+ }
+ richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext));
+}
+
+/**
+ * Hook handler for { @link parseMessage } and { @link parseMUCMessage }, which
+ * parses the passed in `message` stanza for OMEMO attributes and then sets
+ * them on the attrs object.
+ * @param { Element } stanza - The message stanza
+ * @param { (MUCMessageAttributes|MessageAttributes) } attrs
+ * @returns (MUCMessageAttributes|MessageAttributes)
+ */
+export async function parseEncryptedMessage (stanza, attrs) {
+ if (api.settings.get('clear_cache_on_logout') ||
+ !attrs.is_encrypted ||
+ attrs.encryption_namespace !== Strophe.NS.OMEMO) {
+ return attrs;
+ }
+ const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
+ const header = encrypted_el.querySelector('header');
+ attrs.encrypted = { 'device_id': header.getAttribute('sid') };
+
+ const device_id = await api.omemo?.getDeviceID();
+ const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop();
+ if (key) {
+ Object.assign(attrs.encrypted, {
+ 'iv': header.querySelector('iv').textContent,
+ 'key': key.textContent,
+ 'payload': encrypted_el.querySelector('payload')?.textContent || null,
+ 'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
+ });
+ } else {
+ return Object.assign(attrs, {
+ 'error_condition': 'not-encrypted-for-this-device',
+ 'error_type': 'Decryption',
+ 'is_ephemeral': true,
+ 'is_error': true,
+ 'type': 'error'
+ });
+ }
+ // https://xmpp.org/extensions/xep-0384.html#usecases-receiving
+ if (attrs.encrypted.prekey === true) {
+ return decryptPrekeyWhisperMessage(attrs);
+ } else {
+ return decryptWhisperMessage(attrs);
+ }
+}
+
+export function onChatBoxesInitialized () {
+ _converse.chatboxes.on('add', chatbox => {
+ checkOMEMOSupported(chatbox);
+ if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+ chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o));
+ chatbox.features.on('change', () => checkOMEMOSupported(chatbox));
+ }
+ });
+}
+
+export function onChatInitialized (el) {
+ el.listenTo(el.model.messages, 'add', message => {
+ if (message.get('is_encrypted') && !message.get('is_error')) {
+ el.model.save('omemo_supported', true);
+ }
+ });
+ el.listenTo(el.model, 'change:omemo_supported', () => {
+ if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) {
+ el.model.set('omemo_active', false);
+ } else {
+ // Manually trigger an update, setting omemo_active to
+ // false above will automatically trigger one.
+ el.querySelector('converse-chat-toolbar')?.requestUpdate();
+ }
+ });
+ el.listenTo(el.model, 'change:omemo_active', () => {
+ el.querySelector('converse-chat-toolbar').requestUpdate();
+ });
+}
+
+export function getSessionCipher (jid, id) {
+ const address = new libsignal.SignalProtocolAddress(jid, id);
+ return new window.libsignal.SessionCipher(_converse.omemo_store, address);
+}
+
+function getJIDForDecryption (attrs) {
+ const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
+ if (!from_jid) {
+ Object.assign(attrs, {
+ 'error_text': __("Sorry, could not decrypt a received OMEMO "+
+ "message because we don't have the XMPP address for that user."),
+ 'error_type': 'Decryption',
+ 'is_ephemeral': true,
+ 'is_error': true,
+ 'type': 'error'
+ });
+ throw new Error("Could not find JID to decrypt OMEMO message for");
+ }
+ return from_jid;
+}
+
+async function handleDecryptedWhisperMessage (attrs, key_and_tag) {
+ const from_jid = getJIDForDecryption(attrs);
+ const devicelist = await api.omemo.devicelists.get(from_jid, true);
+ const encrypted = attrs.encrypted;
+ let device = devicelist.devices.get(encrypted.device_id);
+ if (!device) {
+ device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true });
+ }
+ if (encrypted.payload) {
+ const key = key_and_tag.slice(0, 16);
+ const tag = key_and_tag.slice(16);
+ const result = await omemo.decryptMessage(Object.assign(encrypted, { 'key': key, 'tag': tag }));
+ device.save('active', true);
+ return result;
+ }
+}
+
+function getDecryptionErrorAttributes (e) {
+ return {
+ 'error_text':
+ __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`,
+ 'error_condition': e.name,
+ 'error_message': e.message,
+ 'error_type': 'Decryption',
+ 'is_ephemeral': true,
+ 'is_error': true,
+ 'type': 'error'
+ };
+}
+
+async function decryptPrekeyWhisperMessage (attrs) {
+ const from_jid = getJIDForDecryption(attrs);
+ const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
+ const key = base64ToArrayBuffer(attrs.encrypted.key);
+ let key_and_tag;
+ try {
+ key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary');
+ } catch (e) {
+ // TODO from the XEP:
+ // There are various reasons why decryption of an
+ // OMEMOKeyExchange or an OMEMOAuthenticatedMessage
+ // could fail. One reason is if the message was
+ // received twice and already decrypted once, in this
+ // case the client MUST ignore the decryption failure
+ // and not show any warnings/errors. In all other cases
+ // of decryption failure, clients SHOULD respond by
+ // forcibly doing a new key exchange and sending a new
+ // OMEMOKeyExchange with a potentially empty SCE
+ // payload. By building a new session with the original
+ // sender this way, the invalid session of the original
+ // sender will get overwritten with this newly created,
+ // valid session.
+ log.error(`${e.name} ${e.message}`);
+ return Object.assign(attrs, getDecryptionErrorAttributes(e));
+ }
+ // TODO from the XEP:
+ // When a client receives the first message for a given
+ // ratchet key with a counter of 53 or higher, it MUST send
+ // a heartbeat message. Heartbeat messages are normal OMEMO
+ // encrypted messages where the SCE payload does not include
+ // any elements. These heartbeat messages cause the ratchet
+ // to forward, thus consequent messages will have the
+ // counter restarted from 0.
+ try {
+ const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
+ await _converse.omemo_store.generateMissingPreKeys();
+ await _converse.omemo_store.publishBundle();
+ if (plaintext) {
+ return Object.assign(attrs, { 'plaintext': plaintext });
+ } else {
+ return Object.assign(attrs, { 'is_only_key': true });
+ }
+ } catch (e) {
+ log.error(`${e.name} ${e.message}`);
+ return Object.assign(attrs, getDecryptionErrorAttributes(e));
+ }
+}
+
+async function decryptWhisperMessage (attrs) {
+ const from_jid = getJIDForDecryption(attrs);
+ const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10));
+ const key = base64ToArrayBuffer(attrs.encrypted.key);
+ try {
+ const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary');
+ const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag);
+ return Object.assign(attrs, { 'plaintext': plaintext });
+ } catch (e) {
+ log.error(`${e.name} ${e.message}`);
+ return Object.assign(attrs, getDecryptionErrorAttributes(e));
+ }
+}
+
+export function addKeysToMessageStanza (stanza, dicts, iv) {
+ for (const i in dicts) {
+ if (Object.prototype.hasOwnProperty.call(dicts, i)) {
+ const payload = dicts[i].payload;
+ const device = dicts[i].device;
+ const prekey = 3 == parseInt(payload.type, 10);
+
+ stanza.c('key', { 'rid': device.get('id') }).t(btoa(payload.body));
+ if (prekey) {
+ stanza.attrs({ 'prekey': prekey });
+ }
+ stanza.up();
+ if (i == dicts.length - 1) {
+ stanza.c('iv').t(iv).up().up();
+ }
+ }
+ }
+ return Promise.resolve(stanza);
+}
+
+/**
+ * Given an XML element representing a user's OMEMO bundle, parse it
+ * and return a map.
+ */
+export function parseBundle (bundle_el) {
+ const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic');
+ const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature');
+ const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({
+ 'id': parseInt(el.getAttribute('preKeyId'), 10),
+ 'key': el.textContent
+ }));
+ return {
+ 'identity_key': bundle_el.querySelector('identityKey').textContent.trim(),
+ 'signed_prekey': {
+ 'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10),
+ 'public_key': signed_prekey_public_el.textContent,
+ 'signature': signed_prekey_signature_el.textContent
+ },
+ 'prekeys': prekeys
+ };
+}
+
+export async function generateFingerprint (device) {
+ if (device.get('bundle')?.fingerprint) {
+ return;
+ }
+ const bundle = await device.getBundle();
+ bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key']));
+ device.save('bundle', bundle);
+ device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference
+}
+
+export async function getDevicesForContact (jid) {
+ await api.waitUntil('OMEMOInitialized');
+ const devicelist = await api.omemo.devicelists.get(jid, true);
+ await devicelist.fetchDevices();
+ return devicelist.devices;
+}
+
+export async function generateDeviceID () {
+ /* Generates a device ID, making sure that it's unique */
+ const devicelist = await api.omemo.devicelists.get(_converse.bare_jid, true);
+ const existing_ids = devicelist.devices.pluck('id');
+ let device_id = libsignal.KeyHelper.generateRegistrationId();
+
+ // Before publishing a freshly generated device id for the first time,
+ // a device MUST check whether that device id already exists, and if so, generate a new one.
+ let i = 0;
+ while (existing_ids.includes(device_id)) {
+ device_id = libsignal.KeyHelper.generateRegistrationId();
+ i++;
+ if (i === 10) {
+ throw new Error('Unable to generate a unique device ID');
+ }
+ }
+ return device_id.toString();
+}
+
+async function buildSession (device) {
+ const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+ const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address);
+ const prekey = device.getRandomPreKey();
+ const bundle = await device.getBundle();
+
+ return sessionBuilder.processPreKey({
+ 'registrationId': parseInt(device.get('id'), 10),
+ 'identityKey': base64ToArrayBuffer(bundle.identity_key),
+ 'signedPreKey': {
+ 'keyId': bundle.signed_prekey.id, // <Number>
+ 'publicKey': base64ToArrayBuffer(bundle.signed_prekey.public_key),
+ 'signature': base64ToArrayBuffer(bundle.signed_prekey.signature)
+ },
+ 'preKey': {
+ 'keyId': prekey.id, // <Number>
+ 'publicKey': base64ToArrayBuffer(prekey.key)
+ }
+ });
+}
+
+export async function getSession (device) {
+ if (!device.get('bundle')) {
+ log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`);
+ return null;
+ }
+ const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id'));
+ const session = await _converse.omemo_store.loadSession(address.toString());
+ if (session) {
+ return session;
+ } else {
+ try {
+ const session = await buildSession(device);
+ return session;
+ } catch (e) {
+ log.error(`Could not build an OMEMO session for device ${device.get('id')}`);
+ log.error(e);
+ return null;
+ }
+ }
+}
+
+async function updateBundleFromStanza (stanza) {
+ const items_el = sizzle(`items`, stanza).pop();
+ if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) {
+ return;
+ }
+ const device_id = items_el.getAttribute('node').split(':')[1];
+ const jid = stanza.getAttribute('from');
+ const bundle_el = sizzle(`item > bundle`, items_el).pop();
+ const devicelist = await api.omemo.devicelists.get(jid, true);
+ const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid });
+ device.save({ 'bundle': parseBundle(bundle_el) });
+}
+
+async function updateDevicesFromStanza (stanza) {
+ const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop();
+ if (!items_el) {
+ return;
+ }
+ const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`;
+ const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id'));
+ const jid = stanza.getAttribute('from');
+ const devicelist = await api.omemo.devicelists.get(jid, true);
+ const devices = devicelist.devices;
+ const removed_ids = difference(devices.pluck('id'), device_ids);
+
+ removed_ids.forEach(id => {
+ if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) {
+ return; // We don't set the current device as inactive
+ }
+ devices.get(id).save('active', false);
+ });
+ device_ids.forEach(device_id => {
+ const device = devices.get(device_id);
+ if (device) {
+ device.save('active', true);
+ } else {
+ devices.create({ 'id': device_id, 'jid': jid });
+ }
+ });
+ if (u.isSameBareJID(jid, _converse.bare_jid)) {
+ // Make sure our own device is on the list
+ // (i.e. if it was removed, add it again).
+ devicelist.publishCurrentDevice(device_ids);
+ }
+}
+
+export function registerPEPPushHandler () {
+ // Add a handler for devices pushed from other connected clients
+ _converse.connection.addHandler(
+ async (message) => {
+ try {
+ if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
+ await api.waitUntil('OMEMOInitialized');
+ await updateDevicesFromStanza(message);
+ await updateBundleFromStanza(message);
+ }
+ } catch (e) {
+ log.error(e.message);
+ }
+ return true;
+ },
+ null,
+ 'message',
+ 'headline'
+ );
+}
+
+export async function restoreOMEMOSession () {
+ if (_converse.omemo_store === undefined) {
+ const id = `converse.omemosession-${_converse.bare_jid}`;
+ _converse.omemo_store = new _converse.OMEMOStore({ id });
+ initStorage(_converse.omemo_store, id);
+ }
+ await _converse.omemo_store.fetchSession();
+}
+
+async function fetchDeviceLists () {
+ _converse.devicelists = new _converse.DeviceLists();
+ const id = `converse.devicelists-${_converse.bare_jid}`;
+ initStorage(_converse.devicelists, id);
+ await new Promise(resolve => {
+ _converse.devicelists.fetch({
+ 'success': resolve,
+ 'error': (_m, e) => { log.error(e); resolve(); }
+ })
+ });
+ // Call API method to wait for our own device list to be fetched from the
+ // server or to be created. If we have no pre-existing OMEMO session, this
+ // will cause a new device and bundle to be generated and published.
+ await api.omemo.devicelists.get(_converse.bare_jid, true);
+}
+
+export async function initOMEMO (reconnecting) {
+ if (reconnecting) {
+ return;
+ }
+ if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
+ log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true');
+ return;
+ }
+ try {
+ await fetchDeviceLists();
+ await restoreOMEMOSession();
+ await _converse.omemo_store.publishBundle();
+ } catch (e) {
+ log.error('Could not initialize OMEMO support');
+ log.error(e);
+ return;
+ }
+ /**
+ * Triggered once OMEMO support has been initialized
+ * @event _converse#OMEMOInitialized
+ * @example _converse.api.listen.on('OMEMOInitialized', () => { ... });
+ */
+ api.trigger('OMEMOInitialized');
+}
+
+async function onOccupantAdded (chatroom, occupant) {
+ if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) {
+ return;
+ }
+ if (chatroom.get('omemo_active')) {
+ const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid'));
+ if (!supported) {
+ chatroom.createMessage({
+ 'message': __(
+ "%1$s doesn't appear to have a client that supports OMEMO. " +
+ 'Encrypted chat will no longer be possible in this grouchat.',
+ occupant.get('nick')
+ ),
+ 'type': 'error'
+ });
+ chatroom.save({ 'omemo_active': false, 'omemo_supported': false });
+ }
+ }
+}
+
+async function checkOMEMOSupported (chatbox) {
+ let supported;
+ if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+ await api.waitUntil('OMEMOInitialized');
+ supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly');
+ } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+ supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid'));
+ }
+ chatbox.set('omemo_supported', supported);
+ if (supported && api.settings.get('omemo_default')) {
+ chatbox.set('omemo_active', true);
+ }
+}
+
+function toggleOMEMO (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar');
+ if (!toolbar_el.model.get('omemo_supported')) {
+ let messages;
+ if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) {
+ messages = [
+ __(
+ 'Cannot use end-to-end encryption in this groupchat, ' +
+ 'either the groupchat has some anonymity or not all participants support OMEMO.'
+ )
+ ];
+ } else {
+ messages = [
+ __(
+ "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.",
+ toolbar_el.model.contact.getDisplayName()
+ )
+ ];
+ }
+ return api.alert('error', __('Error'), messages);
+ }
+ toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') });
+}
+
+export function getOMEMOToolbarButton (toolbar_el, buttons) {
+ const model = toolbar_el.model;
+ const is_muc = model.get('type') === _converse.CHATROOMS_TYPE;
+ let title;
+ if (model.get('omemo_supported')) {
+ const i18n_plaintext = __('Messages are being sent in plaintext');
+ const i18n_encrypted = __('Messages are sent encrypted');
+ title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext;
+ } else if (is_muc) {
+ title = __(
+ 'This groupchat needs to be members-only and non-anonymous in ' +
+ 'order to support OMEMO encrypted messages'
+ );
+ } else {
+ title = __('OMEMO encryption is not supported');
+ }
+
+ let color;
+ if (model.get('omemo_supported')) {
+ if (model.get('omemo_active')) {
+ color = is_muc ? `var(--muc-color)` : `var(--chat-toolbar-btn-color)`;
+ } else {
+ color = `var(--error-color)`;
+ }
+ } else {
+ color = `var(--muc-toolbar-btn-disabled-color)`;
+ }
+ buttons.push(html`
+ <button class="toggle-omemo" title="${title}" data-disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}>
+ <converse-icon
+ class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}"
+ path-prefix="${api.settings.get('assets_path')}"
+ size="1em"
+ color="${color}"
+ ></converse-icon>
+ </button>
+ `);
+ return buttons;
+}
+
+
+async function getBundlesAndBuildSessions (chatbox) {
+ const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
+ let devices;
+ if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
+ const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid'))));
+ devices = collections.reduce((a, b) => concat(a, b.models), []);
+ } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+ const their_devices = await getDevicesForContact(chatbox.get('jid'));
+ if (their_devices.length === 0) {
+ const err = new Error(no_devices_err);
+ err.user_facing = true;
+ throw err;
+ }
+ const own_list = await api.omemo.devicelists.get(_converse.bare_jid)
+ const own_devices = own_list.devices;
+ devices = [...own_devices.models, ...their_devices.models];
+ }
+ // Filter out our own device
+ const id = _converse.omemo_store.get('device_id');
+ devices = devices.filter(d => d.get('id') !== id);
+ // Fetch bundles if necessary
+ await Promise.all(devices.map(d => d.getBundle()));
+
+ const sessions = devices.filter(d => d).map(d => getSession(d));
+ await Promise.all(sessions);
+ if (sessions.includes(null)) {
+ // We couldn't build a session for certain devices.
+ devices = devices.filter(d => sessions[devices.indexOf(d)]);
+ if (devices.length === 0) {
+ const err = new Error(no_devices_err);
+ err.user_facing = true;
+ throw err;
+ }
+ }
+ return devices;
+}
+
+function encryptKey (key_and_tag, device) {
+ return getSessionCipher(device.get('jid'), device.get('id'))
+ .encrypt(key_and_tag)
+ .then(payload => ({ 'payload': payload, 'device': device }));
+}
+
+export async function createOMEMOMessageStanza (chat, data) {
+ let { stanza } = data;
+ const { message } = data;
+ if (!message.get('is_encrypted')) {
+ return data;
+ }
+ if (!message.get('body')) {
+ throw new Error('No message body to encrypt!');
+ }
+ const devices = await getBundlesAndBuildSessions(chat);
+
+ // An encrypted header is added to the message for
+ // each device that is supposed to receive it.
+ // These headers simply contain the key that the
+ // payload message is encrypted with,
+ // and they are separately encrypted using the
+ // session corresponding to the counterpart device.
+ stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
+ .c('header', { 'sid': _converse.omemo_store.get('device_id') });
+
+ const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext'));
+
+ // The 16 bytes key and the GCM authentication tag (The tag
+ // SHOULD have at least 128 bit) are concatenated and for each
+ // intended recipient device, i.e. both own devices as well as
+ // devices associated with the contact, the result of this
+ // concatenation is encrypted using the corresponding
+ // long-standing SignalProtocol session.
+ const dicts = await Promise.all(devices
+ .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
+ .map(device => encryptKey(key_and_tag, device)));
+
+ stanza = await addKeysToMessageStanza(stanza, dicts, iv);
+ stanza.c('payload').t(payload).up().up();
+ stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up();
+ stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO });
+ return { message, stanza };
+}
+
+export const omemo = {
+ decryptMessage,
+ encryptMessage,
+ formatFingerprint
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js
new file mode 100644
index 0000000..3b75578
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js
@@ -0,0 +1,26 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../modal/index.js';
+import './modals/chat-status.js';
+import './modals/profile.js';
+import './modals/user-settings.js';
+import './statusview.js';
+import '@converse/headless/plugins/status';
+import '@converse/headless/plugins/vcard';
+import { api, converse } from '@converse/headless/core';
+
+converse.plugins.add('converse-profile', {
+ dependencies: [
+ 'converse-status',
+ 'converse-modal',
+ 'converse-vcard',
+ 'converse-chatboxviews',
+ 'converse-adhoc-views',
+ ],
+
+ initialize () {
+ api.settings.extend({ 'show_client_info': true });
+ },
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js
new file mode 100644
index 0000000..16cf74c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js
@@ -0,0 +1,49 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplChatStatusModal from "../templates/chat-status-modal.js";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const u = converse.env.utils;
+
+
+export default class ChatStatusModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.render();
+ this.addEventListener('shown.bs.modal', () => {
+ this.querySelector('input[name="status_message"]').focus();
+ }, false);
+ }
+
+ renderModal () {
+ return tplChatStatusModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Change chat status');
+ }
+
+ clearStatusMessage (ev) {
+ if (ev && ev.preventDefault) {
+ ev.preventDefault();
+ u.hideElement(this.querySelector('.clear-input'));
+ }
+ const roster_filter = this.querySelector('input[name="status_message"]');
+ roster_filter.value = '';
+ }
+
+ onFormSubmitted (ev) {
+ ev.preventDefault();
+ const data = new FormData(ev.target);
+ this.model.save({
+ 'status_message': data.get('status_message'),
+ 'status': data.get('chat_status')
+ });
+ this.modal.hide();
+ }
+}
+
+_converse.ChatStatusModal = ChatStatusModal;
+
+api.elements.define('converse-chat-status-modal', ChatStatusModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js
new file mode 100644
index 0000000..51da14b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js
@@ -0,0 +1,92 @@
+import BaseModal from "plugins/modal/modal.js";
+import log from "@converse/headless/log";
+import tplProfileModal from "../templates/profile_modal.js";
+import Compress from 'client-compress';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+import '../password-reset.js';
+
+const compress = new Compress({
+ targetSize: 0.1,
+ quality: 0.75,
+ maxWidth: 256,
+ maxHeight: 256
+});
+
+export default class ProfileModal extends BaseModal {
+
+ constructor (options) {
+ super(options);
+ this.tab = 'profile';
+ }
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', this.render);
+ /**
+ * Triggered when the _converse.ProfileModal has been created and initialized.
+ * @event _converse#profileModalInitialized
+ * @type { _converse.XMPPStatus }
+ * @example _converse.api.listen.on('profileModalInitialized', status => { ... });
+ */
+ api.trigger('profileModalInitialized', this.model);
+ }
+
+ renderModal () {
+ return tplProfileModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Your Profile');
+ }
+
+ async setVCard (data) {
+ try {
+ await api.vcard.set(_converse.bare_jid, data);
+ } catch (err) {
+ log.fatal(err);
+ this.alert([
+ __("Sorry, an error happened while trying to save your profile data."),
+ __("You can check your browser's developer console for any error output.")
+ ].join(" "));
+ return;
+ }
+ this.modal.hide();
+ }
+
+ onFormSubmitted (ev) {
+ ev.preventDefault();
+ const reader = new FileReader();
+ const form_data = new FormData(ev.target);
+ const image_file = form_data.get('image');
+ const data = {
+ 'fn': form_data.get('fn'),
+ 'nickname': form_data.get('nickname'),
+ 'role': form_data.get('role'),
+ 'email': form_data.get('email'),
+ 'url': form_data.get('url'),
+ };
+ if (!image_file.size) {
+ Object.assign(data, {
+ 'image': this.model.vcard.get('image'),
+ 'image_type': this.model.vcard.get('image_type')
+ });
+ this.setVCard(data);
+ } else {
+ const files = [image_file];
+ compress.compress(files).then((conversions) => {
+ const { photo, } = conversions[0];
+ reader.onloadend = () => {
+ Object.assign(data, {
+ 'image': btoa(reader.result),
+ 'image_type': image_file.type
+ });
+ this.setVCard(data);
+ };
+ reader.readAsBinaryString(photo.data);
+ });
+ }
+ }
+}
+
+api.elements.define('converse-profile-modal', ProfileModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss
new file mode 100644
index 0000000..813671e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss
@@ -0,0 +1,38 @@
+converse-profile-modal {
+ .profile-form {
+ label {
+ font-weight: bold;
+ }
+ }
+
+ .fingerprint-removal {
+ label {
+ display: flex;
+ padding: 0.75rem 1.25rem;
+ }
+ }
+
+ .list-group-item {
+ display: flex;
+ justify-content: left;
+ font-size: 95%;
+
+ input[type="checkbox"] {
+ margin-right: 1em;
+ }
+ }
+
+ .fingerprints {
+ width: 100%;
+ margin-bottom: 1em;
+ }
+
+ .fingerprint-trust {
+ display: flex;
+ justify-content: space-between;
+ font-size: 95%;
+ .fingerprint {
+ margin-left: 1em;
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js
new file mode 100644
index 0000000..8dd6962
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js
@@ -0,0 +1,81 @@
+import DOMPurify from 'dompurify';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core.js";
+import { html } from "lit";
+import { unsafeHTML } from 'lit/directives/unsafe-html.js';
+
+
+const tplNavigation = (el) => {
+ const i18n_about = __('About');
+ const i18n_commands = __('Commands');
+ return html`
+ <ul class="nav nav-pills justify-content-center">
+ <li role="presentation" class="nav-item">
+ <a class="nav-link ${el.tab === "about" ? "active" : ""}"
+ id="about-tab"
+ href="#about-tabpanel"
+ aria-controls="about-tabpanel"
+ role="tab"
+ data-toggle="tab"
+ data-name="about"
+ @click=${ev => el.switchTab(ev)}>${i18n_about}</a>
+ </li>
+ <li role="presentation" class="nav-item">
+ <a class="nav-link ${el.tab === "commands" ? "active" : ""}"
+ id="commands-tab"
+ href="#commands-tabpanel"
+ aria-controls="commands-tabpanel"
+ role="tab"
+ data-toggle="tab"
+ data-name="commands"
+ @click=${ev => el.switchTab(ev)}>${i18n_commands}</a>
+ </li>
+ </ul>
+ `;
+}
+
+
+export default (el) => {
+ const first_subtitle = __(
+ '%1$s Open Source %2$s XMPP chat client brought to you by %3$s Opkode %2$s',
+ '<a target="_blank" rel="nofollow" href="https://conversejs.org">',
+ '</a>',
+ '<a target="_blank" rel="nofollow" href="https://opkode.com">'
+ );
+
+ const second_subtitle = __(
+ '%1$s Translate %2$s it into your own language',
+ '<a target="_blank" rel="nofollow" href="https://hosted.weblate.org/projects/conversejs/#languages">',
+ '</a>'
+ );
+ const show_client_info = api.settings.get('show_client_info');
+ const allow_adhoc_commands = api.settings.get('allow_adhoc_commands');
+ const show_both_tabs = show_client_info && allow_adhoc_commands;
+
+ return html`
+ ${ show_both_tabs ? tplNavigation(el) : '' }
+
+ <div class="tab-content">
+ ${ show_client_info ? html`
+ <div class="tab-pane tab-pane--columns ${ el.tab === 'about' ? 'active' : ''}"
+ id="about-tabpanel" role="tabpanel" aria-labelledby="about-tab">
+
+ <span class="modal-alert"></span>
+ <br/>
+ <div class="container">
+ <h6 class="brand-heading">Converse</h6>
+ <p class="brand-subtitle">${_converse.VERSION_NAME}</p>
+ <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(first_subtitle))}</p>
+ <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(second_subtitle))}</p>
+ </div>
+ </div>` : '' }
+
+ ${ allow_adhoc_commands ? html`
+ <div class="tab-pane tab-pane--columns ${ el.tab === 'commands' ? 'active' : ''}"
+ id="commands-tabpanel"
+ role="tabpanel"
+ aria-labelledby="commands-tab">
+ <converse-adhoc-commands/>
+ </div> ` : '' }
+ </div>
+`};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js
new file mode 100644
index 0000000..1e4df13
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js
@@ -0,0 +1,31 @@
+import BaseModal from "plugins/modal/modal.js";
+import tplUserSettingsModal from "./templates/user-settings.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+
+export default class UserSettingsModal extends BaseModal {
+
+ constructor (options) {
+ super(options);
+
+ const show_client_info = api.settings.get('show_client_info');
+ const allow_adhoc_commands = api.settings.get('allow_adhoc_commands');
+ const show_both_tabs = show_client_info && allow_adhoc_commands;
+
+ if (show_both_tabs || show_client_info) {
+ this.tab = 'about';
+ } else if (allow_adhoc_commands) {
+ this.tab = 'commands';
+ }
+ }
+
+ renderModal () {
+ return tplUserSettingsModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Settings');
+ }
+}
+
+api.elements.define('converse-user-settings-modal', UserSettingsModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js
new file mode 100644
index 0000000..58c9ffa
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js
@@ -0,0 +1,83 @@
+import log from '@converse/headless/log';
+import tplPasswordReset from './templates/password-reset.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $iq, sizzle, u } = converse.env;
+
+
+class PasswordReset extends CustomElement {
+
+ static get properties () {
+ return {
+ passwords_mismatched: { type: Boolean },
+ alert_message: { type: String }
+ }
+ }
+
+ initialize () {
+ this.passwords_mismatched = false;
+ this.alert_message = '';
+ }
+
+ render () {
+ return tplPasswordReset(this);
+ }
+
+ checkPasswordsMatch (ev) {
+ const form_data = new FormData(ev.target.form ?? ev.target);
+ const password = form_data.get('password');
+ const password_check = form_data.get('password_check');
+
+ this.passwords_mismatched = password && password !== password_check;
+ return this.passwords_mismatched
+ }
+
+ async onSubmit (ev) {
+ ev.preventDefault();
+
+ if (this.checkPasswordsMatch(ev)) return;
+
+ const iq = $iq({ 'type': 'get', 'to': _converse.domain }).c('query', { 'xmlns': Strophe.NS.REGISTER });
+ const iq_response = await api.sendIQ(iq);
+
+ if (iq_response === null) {
+ this.alert_message = __('Timeout error');
+ return;
+ } else if (sizzle(`error service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, iq_response).length) {
+ this.alert_message = __('Your server does not support in-band password reset');
+ return;
+ } else if (u.isErrorStanza(iq_response)) {
+ this.alert_message = __('Your server responded with an unknown error, check the console for details');
+ log.error("Could not set password");
+ log.error(iq_response);
+ return;
+ }
+
+ const username = iq_response.querySelector('username').textContent;
+
+ const data = new FormData(ev.target);
+ const password = data.get('password');
+
+ const reset_iq = $iq({ 'type': 'set', 'to': _converse.domain })
+ .c('query', { 'xmlns': Strophe.NS.REGISTER })
+ .c('username', {}, username)
+ .c('password', {}, password);
+
+ const iq_result = await api.sendIQ(reset_iq);
+ if (iq_result === null) {
+ this.alert_message = __('Timeout error while trying to set your password');
+ } else if (sizzle(`error not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) {
+ this.alert_message = __('Your server does not allow in-band password reset');
+ } else if (sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) {
+ this.alert_message = __('You are not allowed to change your password');
+ } else if (u.isErrorStanza(iq_result)) {
+ this.alert_message = __('You are not allowed to change your password');
+ } else {
+ api.alert('info', __('Success'), [__('Your new password has been set')]);
+ }
+ }
+}
+
+api.elements.define('converse-change-password-form', PasswordReset);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js
new file mode 100644
index 0000000..ab3c3b3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js
@@ -0,0 +1,33 @@
+import tplProfile from './templates/profile.js';
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api } from '@converse/headless/core';
+
+class Profile extends CustomElement {
+ initialize () {
+ this.model = _converse.xmppstatus;
+ this.listenTo(this.model, "change", () => this.requestUpdate());
+ this.listenTo(this.model, "vcard:add", () => this.requestUpdate());
+ this.listenTo(this.model, "vcard:change", () => this.requestUpdate());
+ }
+
+ render () {
+ return tplProfile(this);
+ }
+
+ showProfileModal (ev) {
+ ev?.preventDefault();
+ api.modal.show('converse-profile-modal', { model: this.model }, ev);
+ }
+
+ showStatusChangeModal (ev) {
+ ev?.preventDefault();
+ api.modal.show('converse-chat-status-modal', { model: this.model }, ev);
+ }
+
+ showUserSettingsModal (ev) {
+ ev?.preventDefault();
+ api.modal.show('converse-user-settings-modal', { model: this.model, _converse }, ev);
+ }
+}
+
+api.elements.define('converse-user-profile', Profile);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js
new file mode 100644
index 0000000..0afa4ef
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js
@@ -0,0 +1,52 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (el) => {
+ const label_away = __('Away');
+ const label_busy = __('Busy');
+ const label_online = __('Online');
+ const label_save = __('Save');
+ const label_xa = __('Away for long');
+ const placeholder_status_message = __('Personal status message');
+ const status = el.model.get('status');
+ const status_message = el.model.get('status_message');
+
+ return html`
+ <form class="converse-form set-xmpp-status" id="set-xmpp-status" @submit=${ev => el.onFormSubmitted(ev)}>
+ <div class="form-group">
+ <div class="custom-control custom-radio">
+ <input ?checked=${status === 'online'}
+ type="radio" id="radio-online" value="online" name="chat_status" class="custom-control-input"/>
+ <label class="custom-control-label" for="radio-online">
+ <converse-icon size="1em" class="fa fa-circle chat-status chat-status--online"></converse-icon>${label_online}</label>
+ </div>
+ <div class="custom-control custom-radio">
+ <input ?checked=${status === 'busy'}
+ type="radio" id="radio-busy" value="dnd" name="chat_status" class="custom-control-input"/>
+ <label class="custom-control-label" for="radio-busy">
+ <converse-icon size="1em" class="fa fa-minus-circle chat-status chat-status--busy"></converse-icon>${label_busy}</label>
+ </div>
+ <div class="custom-control custom-radio">
+ <input ?checked=${status === 'away'}
+ type="radio" id="radio-away" value="away" name="chat_status" class="custom-control-input"/>
+ <label class="custom-control-label" for="radio-away">
+ <converse-icon size="1em" class="fa fa-circle chat-status chat-status--away"></converse-icon>${label_away}</label>
+ </div>
+ <div class="custom-control custom-radio">
+ <input ?checked=${status === 'xa'}
+ type="radio" id="radio-xa" value="xa" name="chat_status" class="custom-control-input"/>
+ <label class="custom-control-label" for="radio-xa">
+ <converse-icon size="1em" class="far fa-circle chat-status chat-status--xa"></converse-icon>${label_xa}</label>
+ </div>
+ </div>
+ <div class="form-group">
+ <div class="btn-group w-100">
+ <input name="status_message" type="text" class="form-control" autofocus
+ value="${status_message || ''}" placeholder="${placeholder_status_message}"/>
+ <converse-icon size="1em" class="fa fa-times clear-input ${status_message ? '' : 'hidden'}" @click=${ev => el.clearStatusMessage(ev)}></converse-icon>
+ </div>
+ </div>
+ <button type="submit" class="btn btn-primary">${label_save}</button>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js
new file mode 100644
index 0000000..550493b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js
@@ -0,0 +1,49 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+
+export default el => {
+ const i18n_submit = __('Submit');
+ const i18n_passwords_must_match = __('The new passwords must match');
+ const i18n_new_password = __('New password');
+ const i18n_confirm_password = __('Confirm new password');
+
+ return html`<form class="converse-form passwordreset-form" method="POST" @submit=${ev => el.onSubmit(ev)}>
+ ${el.alert_message ? html`<div class="alert alert-danger" role="alert">${el.alert_message}</div>` : ''}
+
+ <div class="form-group">
+ <label for="converse_password_reset_new">${i18n_new_password}</label>
+ <input
+ class="form-control ${el.passwords_mismatched ? 'error' : ''}"
+ type="password"
+ value=""
+ name="password"
+ required="required"
+ id="converse_password_reset_new"
+ autocomplete="new-password"
+ minlength="8"
+ ?disabled="${el.alert_message}"
+ />
+ </div>
+ <div class="form-group">
+ <label for="converse_password_reset_check">${i18n_confirm_password}</label>
+ <input
+ class="form-control ${el.passwords_mismatched ? 'error' : ''}"
+ type="password"
+ value=""
+ name="password_check"
+ required="required"
+ id="converse_password_reset_check"
+ autocomplete="new-password"
+ minlength="8"
+ ?disabled="${el.alert_message}"
+ @input=${ev => el.checkPasswordsMatch(ev)}
+ />
+ ${el.passwords_mismatched ? html`<span class="error">${i18n_passwords_must_match}</span>` : ''}
+ </div>
+
+ <input class="save-form btn btn-primary"
+ type="submit"
+ value=${i18n_submit}
+ ?disabled="${el.alert_message}" />
+ </form>`;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js
new file mode 100644
index 0000000..a0ab723
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js
@@ -0,0 +1,57 @@
+import 'shared/avatar/avatar.js';
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { getPrettyStatus, logOut } from '../utils.js';
+import { html } from "lit";
+
+
+function tplSignout () {
+ const i18n_logout = __('Log out');
+ return html`<a class="controlbox-heading__btn logout align-self-center" title="${i18n_logout}" @click=${logOut}>
+ <converse-icon class="fa fa-sign-out-alt" size="1em"></converse-icon>
+ </a>`
+}
+
+function tplUserSettingsButton (o) {
+ const i18n_details = __('Show details about this chat client');
+ return html`<a class="controlbox-heading__btn show-client-info align-self-center" title="${i18n_details}" @click=${o.showUserSettingsModal}>
+ <converse-icon class="fa fa-cog" size="1em"></converse-icon>
+ </a>`;
+}
+
+export default (el) => {
+ const chat_status = el.model.get('status') || 'offline';
+ const status_message = el.model.get('status_message') || __("I am %1$s", getPrettyStatus(chat_status));
+ const i18n_change_status = __('Click to change your chat status');
+ const show_settings_button = api.settings.get('show_client_info') || api.settings.get('allow_adhoc_commands');
+ let classes, color;
+ if (chat_status === 'online') {
+ [classes, color] = ['fa fa-circle chat-status', 'chat-status-online'];
+ } else if (chat_status === 'dnd') {
+ [classes, color] = ['fa fa-minus-circle chat-status', 'chat-status-busy'];
+ } else if (chat_status === 'away') {
+ [classes, color] = ['fa fa-circle chat-status', 'chat-status-away'];
+ } else {
+ [classes, color] = ['fa fa-circle chat-status', 'subdued-color'];
+ }
+ return html`
+ <div class="userinfo controlbox-padded">
+ <div class="controlbox-section profile d-flex">
+ <a class="show-profile" href="#" @click=${el.showProfileModal}>
+ <converse-avatar class="avatar align-self-center"
+ .data=${el.model.vcard?.attributes}
+ nonce=${el.model.vcard?.get('vcard_updated')}
+ height="40" width="40"></converse-avatar>
+ </a>
+ <span class="username w-100 align-self-center">${el.model.getDisplayName()}</span>
+ ${show_settings_button ? tplUserSettingsButton(el) : ''}
+ ${api.settings.get('allow_logout') ? tplSignout() : ''}
+ </div>
+ <div class="d-flex xmpp-status">
+ <a class="change-status" title="${i18n_change_status}" data-toggle="modal" data-target="#changeStatusModal" @click=${el.showStatusChangeModal}>
+ <span class="${chat_status} w-100 align-self-center" data-value="${chat_status}">
+ <converse-icon color="var(--${color})" style="margin-top: -0.1em" size="0.82em" class="${classes}"></converse-icon> ${status_message}</span>
+ </a>
+ </div>
+ </div>`
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js
new file mode 100644
index 0000000..e67eb01
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js
@@ -0,0 +1,120 @@
+import "shared/components/image-picker.js";
+import { __ } from 'i18n';
+import { _converse } from "@converse/headless/core";
+import { html } from "lit";
+
+
+const tplOmemoPage = (el) => html`
+ <div class="tab-pane ${ el.tab === 'omemo' ? 'active' : ''}" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab">
+ ${ el.tab === 'omemo' ? html`<converse-omemo-profile></converse-omemo-profile>` : '' }
+ </div>`;
+
+
+export default (el) => {
+ const o = { ...el.model.toJSON(), ...el.model.vcard.toJSON() };
+ const i18n_email = __('Email');
+ const i18n_fullname = __('Full Name');
+ const i18n_jid = __('XMPP Address');
+ const i18n_nickname = __('Nickname');
+ const i18n_role = __('Role');
+ const i18n_save = __('Save and close');
+ const i18n_role_help = __('Use commas to separate multiple roles. Your roles are shown next to your name on your chat messages.');
+ const i18n_url = __('URL');
+
+ const i18n_omemo = __('OMEMO');
+ const i18n_profile = __('Profile');
+ const ii18n_reset_password = __('Reset Password');
+
+ const navigation_tabs = [
+ html`<li role="presentation" class="nav-item">
+ <a class="nav-link ${el.tab === "profile" ? "active" : ""}"
+ id="profile-tab"
+ href="#profile-tabpanel"
+ aria-controls="profile-tabpanel"
+ role="tab"
+ @click=${ev => el.switchTab(ev)}
+ data-name="profile"
+ data-toggle="tab">${ i18n_profile }</a>
+ </li>`
+ ];
+
+ navigation_tabs.push(
+ html`<li role="presentation" class="nav-item">
+ <a class="nav-link ${el.tab === "passwordreset" ? "active" : ""}"
+ id="passwordreset-tab"
+ href="#passwordreset-tabpanel"
+ aria-controls="passwordreset-tabpanel"
+ role="tab"
+ @click=${ev => el.switchTab(ev)}
+ data-name="passwordreset"
+ data-toggle="tab">${ ii18n_reset_password }</a>
+ </li>`
+ );
+
+ if (_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)) {
+ navigation_tabs.push(
+ html`<li role="presentation" class="nav-item">
+ <a class="nav-link ${el.tab === "omemo" ? "active" : ""}"
+ id="omemo-tab"
+ href="#omemo-tabpanel"
+ aria-controls="omemo-tabpanel"
+ role="tab"
+ @click=${ev => el.switchTab(ev)}
+ data-name="omemo"
+ data-toggle="tab">${ i18n_omemo }</a>
+ </li>`
+ );
+ }
+
+ return html`
+ <ul class="nav nav-pills justify-content-center">${navigation_tabs}</ul>
+ <div class="tab-content">
+ <div class="tab-pane ${ el.tab === 'profile' ? 'active' : ''}" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab">
+ <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
+ <div class="row">
+ <div class="col-auto">
+ <converse-image-picker .data="${{image: o.image, image_type: o.image_type}}" width="128" height="128"></converse-image-picker>
+ </div>
+ <div class="col">
+ <div class="form-group">
+ <label class="col-form-label">${i18n_jid}:</label>
+ <div>${o.jid}</div>
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="vcard-fullname" class="col-form-label">${i18n_fullname}:</label>
+ <input id="vcard-fullname" type="text" class="form-control" name="fn" value="${o.fullname || ''}"/>
+ </div>
+ <div class="form-group">
+ <label for="vcard-nickname" class="col-form-label">${i18n_nickname}:</label>
+ <input id="vcard-nickname" type="text" class="form-control" name="nickname" value="${o.nickname || ''}"/>
+ </div>
+ <div class="form-group">
+ <label for="vcard-url" class="col-form-label">${i18n_url}:</label>
+ <input id="vcard-url" type="url" class="form-control" name="url" value="${o.url || ''}"/>
+ </div>
+ <div class="form-group">
+ <label for="vcard-email" class="col-form-label">${i18n_email}:</label>
+ <input id="vcard-email" type="email" class="form-control" name="email" value="${o.email || ''}"/>
+ </div>
+ <div class="form-group">
+ <label for="vcard-role" class="col-form-label">${i18n_role}:</label>
+ <input id="vcard-role" type="text" class="form-control" name="role" value="${o.role || ''}" aria-describedby="vcard-role-help"/>
+ <small id="vcard-role-help" class="form-text text-muted">${i18n_role_help}</small>
+ </div>
+ <hr/>
+ <div class="form-group">
+ <button type="submit" class="save-form btn btn-primary">${i18n_save}</button>
+ </div>
+ </form>
+ </div>
+
+ <div class="tab-pane ${ el.tab === 'passwordreset' ? 'active' : ''}" id="passwordreset-tabpanel" role="tabpanel" aria-labelledby="passwordreset-tab">
+ ${ el.tab === 'passwordreset' ? html`<converse-change-password-form></converse-change-password-form>` : '' }
+ </div>
+
+ ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? tplOmemoPage(el) : '' }
+ </div>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js
new file mode 100644
index 0000000..421f14d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js
@@ -0,0 +1,158 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+async function submitPasswordResetForm (_converse) {
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('a.show-profile')?.click();
+ const modal = _converse.api.modal.get('converse-profile-modal');
+ await u.waitUntil(() => u.isVisible(modal));
+
+ modal.querySelector('#passwordreset-tab').click();
+ const form = await u.waitUntil(() => modal.querySelector('.passwordreset-form'));
+
+ const pw_input = form.querySelector('input[name="password"]');
+ pw_input.value = 'secret-password';
+ const pw_check_input = form.querySelector('input[name="password_check"]');
+ pw_check_input.value = 'secret-password';
+ form.querySelector('input[type="submit"]').click();
+
+ return modal;
+}
+
+
+describe('The profile modal', function () {
+ it(
+ 'allows you to reset your password',
+ mock.initConverse([], {}, async function (_converse) {
+ await submitPasswordResetForm(_converse);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const query_iq = await u.waitUntil(() =>
+ sent_IQs.filter(iq => iq.querySelector('iq[type="get"] query[xmlns="jabber:iq:register"]')).pop()
+ );
+ expect(Strophe.serialize(query_iq)).toBe(
+ `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+ `<query xmlns="jabber:iq:register"/>` +
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(
+ mock.createRequest(
+ u.toStanza(`
+ <iq type='result' id='${query_iq.getAttribute('id')}'>
+ <query xmlns='jabber:iq:register'>
+ <username>romeo@montague.lit</username>
+ <password/>
+ </query>
+ </iq>`)
+ )
+ );
+
+ const set_iq = await u.waitUntil(() =>
+ sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop()
+ );
+ expect(Strophe.serialize(set_iq)).toBe(
+ `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` +
+ `<query xmlns="jabber:iq:register">` +
+ `<username>romeo@montague.lit</username>` +
+ `<password>secret-password</password>` +
+ `</query>` +
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(
+ mock.createRequest(u.toStanza(`<iq type='result' id='${set_iq.getAttribute('id')}'></iq>`))
+ );
+
+ const alert = await u.waitUntil(() => document.querySelector('converse-alert-modal'));
+ await u.waitUntil(() => u.isVisible(alert));
+ expect(alert.querySelector('.modal-title').textContent).toBe('Success');
+ })
+ );
+
+ it(
+ 'informs you if you cannot reset your password due to in-band registration not being supported',
+ mock.initConverse([], {}, async function (_converse) {
+ const modal = await submitPasswordResetForm(_converse);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const query_iq = await u.waitUntil(() =>
+ sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop()
+ );
+
+ expect(Strophe.serialize(query_iq)).toBe(
+ `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+ `<query xmlns="jabber:iq:register"/>` +
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(
+ mock.createRequest(
+ u.toStanza(`
+ <iq type='result' id="${query_iq.getAttribute('id')}">
+ <error type="cancel"><service-unavailable xmlns="${Strophe.NS.STANZAS}"/></error>
+ </iq>`)
+ )
+ );
+
+ const alert = await u.waitUntil(() => modal.querySelector('.alert-danger'));
+ expect(alert.textContent).toBe('Your server does not support in-band password reset');
+ })
+ );
+
+ it(
+ 'informs you if you\'re not allowed to reset your password',
+ mock.initConverse([], {}, async function (_converse) {
+ const modal = await submitPasswordResetForm(_converse);
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const query_iq = await u.waitUntil(() =>
+ sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop()
+ );
+
+ expect(Strophe.serialize(query_iq)).toBe(
+ `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+ `<query xmlns="jabber:iq:register"/>` +
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(
+ mock.createRequest(
+ u.toStanza(`
+ <iq type='result' id='${query_iq.getAttribute('id')}'>
+ <query xmlns='jabber:iq:register'>
+ <username>romeo@montague.lit</username>
+ <password/>
+ </query>
+ </iq>`)
+ )
+ );
+
+ const set_iq = await u.waitUntil(() =>
+ sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop()
+ );
+ expect(Strophe.serialize(set_iq)).toBe(
+ `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` +
+ `<query xmlns="jabber:iq:register">` +
+ `<username>romeo@montague.lit</username>` +
+ `<password>secret-password</password>` +
+ `</query>` +
+ `</iq>`
+ );
+
+ _converse.connection._dataRecv(
+ mock.createRequest(
+ u.toStanza(`
+ <iq type='result' id="${set_iq.getAttribute('id')}">
+ <error type="modify"><forbidden xmlns="${Strophe.NS.STANZAS}"/></error>
+ </iq>`)
+ )
+ );
+
+ const alert = await u.waitUntil(() => modal.querySelector('.alert-danger'));
+ expect(alert.textContent).toBe('You are not allowed to change your password');
+ })
+ );
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js
new file mode 100644
index 0000000..38e2a78
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js
@@ -0,0 +1,17 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+
+describe("The Controlbox", function () {
+ describe("The user profile", function () {
+
+ it("shows the user's configured nickname",
+ mock.initConverse([], { blacklisted_plugins: ['converse-vcard'], nickname: 'nicky'},
+ async function (_converse) {
+
+ mock.openControlBox(_converse);
+ const el = await u.waitUntil(() => document.querySelector('converse-user-profile .username'));
+ expect(el.textContent).toBe('nicky');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js
new file mode 100644
index 0000000..18a2069
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js
@@ -0,0 +1,69 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+const Strophe = converse.env.Strophe;
+
+describe("The Controlbox", function () {
+ describe("The Status Widget", function () {
+
+ it("shows the user's chat status, which is online by default",
+ mock.initConverse([], {}, async function (_converse) {
+ mock.openControlBox(_converse);
+ const view = await u.waitUntil(() => document.querySelector('converse-user-profile'));
+ expect(u.hasClass('online', view.querySelector('.xmpp-status span:first-child'))).toBe(true);
+ expect(view.querySelector('.xmpp-status span.online').textContent.trim()).toBe('I am online');
+ }));
+
+ it("can be used to set the current user's chat status",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.change-status').click()
+ const modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+ modal.querySelector('[type="submit"]').click();
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+ expect(Strophe.serialize(sent_presence)).toBe(
+ `<presence xmlns="jabber:client">`+
+ `<show>dnd</show>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`);
+ const view = await u.waitUntil(() => document.querySelector('converse-user-profile'));
+ const first_child = view.querySelector('.xmpp-status span:first-child');
+ expect(u.hasClass('online', first_child)).toBe(false);
+ expect(u.hasClass('dnd', first_child)).toBe(true);
+ expect(view.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe('I am busy');
+ }));
+
+ it("can be used to set a custom status message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.change-status').click()
+ const modal = _converse.api.modal.get('converse-chat-status-modal');
+
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const msg = 'I am happy';
+ modal.querySelector('input[name="status_message"]').value = msg;
+ modal.querySelector('[type="submit"]').click();
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+ expect(Strophe.serialize(sent_presence)).toBe(
+ `<presence xmlns="jabber:client">`+
+ `<status>I am happy</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`);
+
+ const view = await u.waitUntil(() => document.querySelector('converse-user-profile'));
+ const first_child = view.querySelector('.xmpp-status span:first-child');
+ expect(u.hasClass('online', first_child)).toBe(true);
+ expect(view.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe(msg);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js
new file mode 100644
index 0000000..96b1d63
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js
@@ -0,0 +1,28 @@
+import { __ } from 'i18n';
+import { api, converse, _converse } from '@converse/headless/core';
+
+const { Strophe, $iq, sizzle, u } = converse.env;
+
+export function getPrettyStatus (stat) {
+ if (stat === 'chat') {
+ return __('online');
+ } else if (stat === 'dnd') {
+ return __('busy');
+ } else if (stat === 'xa') {
+ return __('away for long');
+ } else if (stat === 'away') {
+ return __('away');
+ } else if (stat === 'offline') {
+ return __('offline');
+ } else {
+ return __(stat) || __('online');
+ }
+}
+
+export async function logOut (ev) {
+ ev?.preventDefault();
+ const result = await api.confirm(__("Are you sure you want to log out?"));
+ if (result) {
+ api.user.logout();
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/index.js b/roles/reverseproxy/files/conversejs/src/plugins/push/index.js
new file mode 100644
index 0000000..f74207c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/push/index.js
@@ -0,0 +1,31 @@
+/**
+ * @description
+ * Converse.js plugin which add support for registering
+ * an "App Server" as defined in XEP-0357
+ * @copyright 2021, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import { _converse, api, converse } from '@converse/headless/core';
+import { enablePush, onChatBoxAdded } from './utils.js';
+
+const { Strophe } = converse.env;
+
+Strophe.addNamespace('PUSH', 'urn:xmpp:push:0');
+
+converse.plugins.add('converse-push', {
+ initialize () {
+ /* The initialize function gets called as soon as the plugin is
+ * loaded by converse.js's plugin machinery.
+ */
+ api.settings.extend({
+ 'push_app_servers': [],
+ 'enable_muc_push': false,
+ });
+
+ api.listen.on('statusInitialized', () => enablePush());
+
+ if (api.settings.get('enable_muc_push')) {
+ api.listen.on('chatBoxesInitialized', () => _converse.chatboxes.on('add', onChatBoxAdded));
+ }
+ },
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js b/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js
new file mode 100644
index 0000000..db79324
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js
@@ -0,0 +1,181 @@
+/*global mock, converse */
+
+const $iq = converse.env.$iq;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("XEP-0357 Push Notifications", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("can be enabled",
+ mock.initConverse(
+ [], {
+ 'push_app_servers': [{
+ 'jid': 'push-5@client.example',
+ 'node': 'yxs32uqsflafdk3iuqo'
+ }]
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, api.settings.get('push_app_servers')[0].jid,
+ [{'category': 'pubsub', 'type':'push'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ await mock.waitUntilDiscoConfirmed(
+ _converse,
+ _converse.bare_jid,
+ [{'category': 'account', 'type':'registered'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ const stanza = await u.waitUntil(() =>
+ IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
+ );
+ expect(Strophe.serialize(stanza)).toEqual(
+ `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+ '</iq>'
+ )
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ })));
+ await u.waitUntil(() => _converse.session.get('push_enabled'));
+ }));
+
+ it("can be enabled for a MUC domain",
+ mock.initConverse(
+ [], {
+ 'enable_muc_push': true,
+ 'push_app_servers': [{
+ 'jid': 'push-5@client.example',
+ 'node': 'yxs32uqsflafdk3iuqo'
+ }]
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ await mock.waitUntilDiscoConfirmed(
+ _converse, api.settings.get('push_app_servers')[0].jid,
+ [{'category': 'pubsub', 'type':'push'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid, [],
+ ['urn:xmpp:push:0']);
+
+ let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(`iq[type="set"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length).pop());
+
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>`+
+ `</iq>`
+ );
+ const result = u.toStanza(`<iq type="result" id="${iq.getAttribute('id')}" to="romeo@montague.lit" />`);
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ await u.waitUntil(() => _converse.session.get('push_enabled'));
+ expect(_converse.session.get('push_enabled').length).toBe(1);
+ expect(_converse.session.get('push_enabled').includes('romeo@montague.lit')).toBe(true);
+
+ mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'oldhag');
+ await mock.waitUntilDiscoConfirmed(
+ _converse, 'chat.shakespeare.lit',
+ [{'category': 'account', 'type':'registered'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(`iq[type="set"][to="chat.shakespeare.lit"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
+ ).pop());
+
+ expect(Strophe.serialize(iq)).toEqual(
+ `<iq id="${iq.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+ '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+ '</iq>'
+ );
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': iq.getAttribute('id')
+ })));
+ await u.waitUntil(() => _converse.session.get('push_enabled').includes('chat.shakespeare.lit'));
+ }));
+
+ it("can be disabled",
+ mock.initConverse(
+ ['chatBoxesFetched'], {
+ 'push_app_servers': [{
+ 'jid': 'push-5@client.example',
+ 'node': 'yxs32uqsflafdk3iuqo',
+ 'disable': true
+ }]
+ }, async function (_converse) {
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse,
+ _converse.bare_jid,
+ [{'category': 'account', 'type':'registered'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] disable[xmlns="urn:xmpp:push:0"]')).pop());
+ expect(Strophe.serialize(stanza)).toEqual(
+ `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ '<disable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
+ '</iq>'
+ );
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ })));
+ await u.waitUntil(() => _converse.session.get('push_enabled'))
+ }));
+
+
+ it("can require a secret token to be included",
+ mock.initConverse([], {
+ 'push_app_servers': [{
+ 'jid': 'push-5@client.example',
+ 'node': 'yxs32uqsflafdk3iuqo',
+ 'secret': 'eruio234vzxc2kla-91'
+ }]
+ }, async function (_converse) {
+
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ expect(_converse.session.get('push_enabled')).toBeFalsy();
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, api.settings.get('push_app_servers')[0].jid,
+ [{'category': 'pubsub', 'type':'push'}],
+ ['urn:xmpp:push:0'], [], 'info');
+ await mock.waitUntilDiscoConfirmed(
+ _converse,
+ _converse.bare_jid,
+ [{'category': 'account', 'type':'registered'}],
+ ['urn:xmpp:push:0'], [], 'info');
+
+ const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop());
+ expect(Strophe.serialize(stanza)).toEqual(
+ `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0">'+
+ '<x type="submit" xmlns="jabber:x:data">'+
+ '<field var="FORM_TYPE"><value>http://jabber.org/protocol/pubsub#publish-options</value></field>'+
+ '<field var="secret"><value>eruio234vzxc2kla-91</value></field>'+
+ '</x>'+
+ '</enable>'+
+ '</iq>'
+ )
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ })));
+ await u.waitUntil(() => _converse.session.get('push_enabled'))
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js
new file mode 100644
index 0000000..22995b1
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js
@@ -0,0 +1,94 @@
+import log from "@converse/headless/log";
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { Strophe, $iq } = converse.env;
+
+async function disablePushAppServer (domain, push_app_server) {
+ if (!push_app_server.jid) {
+ return;
+ }
+ if (!(await api.disco.supports(Strophe.NS.PUSH, domain || _converse.bare_jid))) {
+ log.warn(`Not disabling push app server "${push_app_server.jid}", no disco support from your server.`);
+ return;
+ }
+ const stanza = $iq({'type': 'set'});
+ if (domain !== _converse.bare_jid) {
+ stanza.attrs({'to': domain});
+ }
+ stanza.c('disable', {
+ 'xmlns': Strophe.NS.PUSH,
+ 'jid': push_app_server.jid,
+ });
+ if (push_app_server.node) {
+ stanza.attrs({'node': push_app_server.node});
+ }
+ api.sendIQ(stanza)
+ .catch(e => {
+ log.error(`Could not disable push app server for ${push_app_server.jid}`);
+ log.error(e);
+ });
+}
+
+async function enablePushAppServer (domain, push_app_server) {
+ if (!push_app_server.jid || !push_app_server.node) {
+ return;
+ }
+ const identity = await api.disco.getIdentity('pubsub', 'push', push_app_server.jid);
+ if (!identity) {
+ return log.warn(
+ `Not enabling push the service "${push_app_server.jid}", it doesn't have the right disco identtiy.`
+ );
+ }
+ const result = await Promise.all([
+ api.disco.supports(Strophe.NS.PUSH, push_app_server.jid),
+ api.disco.supports(Strophe.NS.PUSH, domain)
+ ]);
+ if (!result[0] && !result[1]) {
+ log.warn(`Not enabling push app server "${push_app_server.jid}", no disco support from your server.`);
+ return;
+ }
+ const stanza = $iq({'type': 'set'});
+ if (domain !== _converse.bare_jid) {
+ stanza.attrs({'to': domain});
+ }
+ stanza.c('enable', {
+ 'xmlns': Strophe.NS.PUSH,
+ 'jid': push_app_server.jid,
+ 'node': push_app_server.node
+ });
+ if (push_app_server.secret) {
+ stanza.c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
+ .c('field', {'var': 'FORM_TYPE'})
+ .c('value').t(`${Strophe.NS.PUBSUB}#publish-options`).up().up()
+ .c('field', {'var': 'secret'})
+ .c('value').t(push_app_server.secret);
+ }
+ return api.sendIQ(stanza);
+}
+
+export async function enablePush (domain) {
+ domain = domain || _converse.bare_jid;
+ const push_enabled = _converse.session.get('push_enabled') || [];
+ if (push_enabled.includes(domain)) {
+ return;
+ }
+ const enabled_services = api.settings.get('push_app_servers').filter(s => !s.disable);
+ const disabled_services = api.settings.get('push_app_servers').filter(s => s.disable);
+ const enabled = enabled_services.map(s => enablePushAppServer(domain, s));
+ const disabled = disabled_services.map(s => disablePushAppServer(domain, s));
+ try {
+ await Promise.all(enabled.concat(disabled));
+ } catch (e) {
+ log.error('Could not enable or disable push App Server');
+ if (e) log.error(e);
+ } finally {
+ push_enabled.push(domain);
+ }
+ _converse.session.save('push_enabled', push_enabled);
+}
+
+export function onChatBoxAdded (model) {
+ if (model.get('type') == _converse.CHATROOMS_TYPE) {
+ enablePush(Strophe.getDomainFromJid(model.get('jid')));
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/index.js b/roles/reverseproxy/files/conversejs/src/plugins/register/index.js
new file mode 100644
index 0000000..89e046d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/index.js
@@ -0,0 +1,54 @@
+/**
+ * @module converse-register
+ * @description
+ * This is a Converse.js plugin which add support for in-band registration
+ * as specified in XEP-0077.
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import './panel.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+import { setActiveForm } from './utils.js';
+import { CONNECTION_STATUS } from '@converse/headless/shared/constants';
+
+// Strophe methods for building stanzas
+const { Strophe } = converse.env;
+
+// Add Strophe Namespaces
+Strophe.addNamespace('REGISTER', 'jabber:iq:register');
+
+// Add Strophe Statuses
+const i = Object.keys(Strophe.Status).reduce((max, k) => Math.max(max, Strophe.Status[k]), 0);
+Strophe.Status.REGIFAIL = i + 1;
+Strophe.Status.REGISTERED = i + 2;
+Strophe.Status.CONFLICT = i + 3;
+Strophe.Status.NOTACCEPTABLE = i + 5;
+
+converse.plugins.add('converse-register', {
+
+ dependencies: ['converse-controlbox'],
+
+ enabled () {
+ return true;
+ },
+
+ initialize () {
+ const { router } = _converse;
+
+ CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
+ CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
+ CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
+ CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE';
+
+ api.settings.extend({
+ 'allow_registration': true,
+ 'domain_placeholder': __(' e.g. conversejs.org'), // Placeholder text shown in the domain input on the registration form
+ 'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page
+ 'registration_domain': ''
+ });
+
+ router.route('converse/login', () => setActiveForm('login'));
+ router.route('converse/register', () => setActiveForm('register'));
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js b/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js
new file mode 100644
index 0000000..c05e27a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js
@@ -0,0 +1,434 @@
+import log from "@converse/headless/log";
+import tplFormInput from "templates/form_input.js";
+import tplFormUrl from "templates/form_url.js";
+import tplFormUsername from "templates/form_username.js";
+import tplRegisterPanel from "./templates/register_panel.js";
+import { CONNECTION_STATUS } from '@converse/headless/shared/constants';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core.js";
+import { initConnection } from '@converse/headless/utils/init.js';
+import { setActiveForm } from './utils.js';
+import { webForm2xForm } from "@converse/headless/utils/form";
+
+import './styles/register.scss';
+
+// Strophe methods for building stanzas
+const { Strophe, sizzle, $iq } = converse.env;
+const u = converse.env.utils;
+
+
+const CHOOSE_PROVIDER = 0;
+const FETCHING_FORM = 1;
+const REGISTRATION_FORM = 2;
+const REGISTRATION_FORM_ERROR = 3;
+
+
+/**
+ * @class
+ * @namespace _converse.RegisterPanel
+ * @memberOf _converse
+ */
+class RegisterPanel extends CustomElement {
+
+ static get properties () {
+ return {
+ status : { type: String },
+ alert_message: { type: String },
+ alert_type: { type: String },
+ }
+ }
+
+ constructor () {
+ super();
+ this.alert_type = 'info';
+ this.setErrorMessage = (m) => this.setMessage(m, 'danger');
+ this.setFeedbackMessage = (m) => this.setMessage(m, 'info');
+ }
+
+ initialize () {
+ this.reset();
+ this.listenTo(_converse, 'connectionInitialized', () => this.registerHooks());
+
+ const domain = api.settings.get('registration_domain');
+ if (domain) {
+ this.fetchRegistrationForm(domain);
+ } else {
+ this.status = CHOOSE_PROVIDER;
+ }
+ }
+
+ render () {
+ return tplRegisterPanel(this);
+ }
+
+ setMessage(message, type) {
+ this.alert_type = type;
+ this.alert_message = message;
+ }
+
+ /**
+ * Hook into Strophe's _connect_cb, so that we can send an IQ
+ * requesting the registration fields.
+ */
+ registerHooks () {
+ const conn = _converse.connection;
+ const connect_cb = conn._connect_cb.bind(conn);
+ conn._connect_cb = (req, callback, raw) => {
+ if (!this._registering) {
+ connect_cb(req, callback, raw);
+ } else if (this.getRegistrationFields(req, callback)) {
+ this._registering = false;
+ }
+ };
+ }
+
+ /**
+ * Send an IQ stanza to the XMPP server asking for the registration fields.
+ * @method _converse.RegisterPanel#getRegistrationFields
+ * @param { Strophe.Request } req - The current request
+ * @param { Function } callback - The callback function
+ */
+ getRegistrationFields (req, _callback) {
+ const conn = _converse.connection;
+ conn.connected = true;
+
+ const body = conn._proto._reqToData(req);
+ if (!body) { return; }
+ if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
+ this.status = CHOOSE_PROVIDER;
+ this.setErrorMessage(__("Sorry, we're unable to connect to your chosen provider."));
+ return false;
+ }
+ const register = body.getElementsByTagName("register");
+ const mechanisms = body.getElementsByTagName("mechanism");
+ if (register.length === 0 && mechanisms.length === 0) {
+ conn._proto._no_auth_received(_callback);
+ return false;
+ }
+ if (register.length === 0) {
+ conn._changeConnectStatus(Strophe.Status.REGIFAIL);
+ this.alert_type = 'danger';
+ this.setErrorMessage(
+ __("Sorry, the given provider does not support in "+
+ "band account registration. Please try with a "+
+ "different provider."));
+ return true;
+ }
+ // Send an IQ stanza to get all required data fields
+ conn._addSysHandler((s) => this.onRegistrationFields(s), null, "iq", null, null);
+ const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
+ stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
+ conn.send(stanza);
+ conn.connected = false;
+ return true;
+ }
+
+ /**
+ * Handler for {@link _converse.RegisterPanel#getRegistrationFields}
+ * @method _converse.RegisterPanel#onRegistrationFields
+ * @param { Element } stanza - The query stanza.
+ */
+ onRegistrationFields (stanza) {
+ if (stanza.getAttribute("type") === "error") {
+ this.reportErrors(stanza);
+ if (api.settings.get('registration_domain')) {
+ this.status = REGISTRATION_FORM_ERROR;
+ } else {
+ this.status = CHOOSE_PROVIDER;
+ }
+ return false;
+ }
+ this.setFields(stanza);
+ if (this.status === FETCHING_FORM) {
+ this.renderRegistrationForm(stanza);
+ }
+ return false;
+ }
+
+ reset (settings) {
+ const defaults = {
+ fields: {},
+ urls: [],
+ title: "",
+ instructions: "",
+ registered: false,
+ _registering: false,
+ domain: null,
+ form_type: null
+ };
+ Object.assign(this, defaults);
+ if (settings) Object.assign(this, settings);
+ }
+
+ /**
+ * Event handler when the #converse-register form is submitted.
+ * Depending on the available input fields, we delegate to other methods.
+ * @param { Event } ev
+ */
+ onFormSubmission (ev) {
+ ev?.preventDefault?.();
+ if (ev.target.querySelector('input[name=domain]') === null) {
+ this.submitRegistrationForm(ev.target);
+ } else {
+ this.onProviderChosen(ev.target);
+ }
+
+ }
+
+ /**
+ * Callback method that gets called when the user has chosen an XMPP provider
+ * @method _converse.RegisterPanel#onProviderChosen
+ * @param { HTMLElement } form - The form that was submitted
+ */
+ onProviderChosen (form) {
+ const domain = form.querySelector('input[name=domain]')?.value;
+ if (domain) this.fetchRegistrationForm(domain.trim());
+ }
+
+ /**
+ * Fetch a registration form from the requested domain
+ * @method _converse.RegisterPanel#fetchRegistrationForm
+ * @param { String } domain_name - XMPP server domain
+ */
+ fetchRegistrationForm (domain_name) {
+ this.status = FETCHING_FORM;
+ this.reset({
+ 'domain': Strophe.getDomainFromJid(domain_name),
+ '_registering': true
+ });
+ initConnection(this.domain);
+ // When testing, the test tears down before the async function
+ // above finishes. So we use optional chaining here
+ _converse.connection?.connect(this.domain, "", (s) => this.onConnectStatusChanged(s));
+ return false;
+ }
+
+ /**
+ * Callback function called by Strophe whenever the connection status changes.
+ * Passed to Strophe specifically during a registration attempt.
+ * @method _converse.RegisterPanel#onConnectStatusChanged
+ * @param { number } status_code - The Strophe.Status status code
+ */
+ onConnectStatusChanged(status_code) {
+ log.debug('converse-register: onConnectStatusChanged');
+ if ([Strophe.Status.DISCONNECTED,
+ Strophe.Status.CONNFAIL,
+ Strophe.Status.REGIFAIL,
+ Strophe.Status.NOTACCEPTABLE,
+ Strophe.Status.CONFLICT
+ ].includes(status_code)) {
+
+ log.error(
+ `Problem during registration: Strophe.Status is ${CONNECTION_STATUS[status_code]}`
+ );
+ this.abortRegistration();
+ } else if (status_code === Strophe.Status.REGISTERED) {
+ log.debug("Registered successfully.");
+ _converse.connection.reset();
+
+ if (["converse/login", "converse/register"].includes(_converse.router.history.getFragment())) {
+ _converse.router.navigate('', {'replace': true});
+ }
+ setActiveForm('login');
+
+ if (this.fields.password && this.fields.username) {
+ // automatically log the user in
+ _converse.connection.connect(
+ this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(),
+ this.fields.password,
+ _converse.onConnectStatusChanged
+ );
+ this.setFeedbackMessage(__('Now logging you in'));
+ } else {
+ this.setFeedbackMessage(__('Registered successfully'));
+ }
+ this.reset();
+ }
+ }
+
+ getLegacyFormFields () {
+ const input_fields = Object.keys(this.fields).map(key => {
+ if (key === "username") {
+ return tplFormUsername({
+ 'domain': ` @${this.domain}`,
+ 'name': key,
+ 'type': "text",
+ 'label': key,
+ 'value': '',
+ 'required': true
+ });
+ } else {
+ return tplFormInput({
+ 'label': key,
+ 'name': key,
+ 'placeholder': key,
+ 'required': true,
+ 'type': (key === 'password' || key === 'email') ? key : "text",
+ 'value': ''
+ })
+ }
+ });
+ const urls = this.urls.map(u => tplFormUrl({'label': '', 'value': u}));
+ return [...input_fields, ...urls];
+ }
+
+ getFormFields (stanza) {
+ if (this.form_type === 'xform') {
+ return Array.from(stanza.querySelectorAll('field')).map(field =>
+ u.xForm2TemplateResult(field, stanza, {'domain': this.domain})
+ );
+ } else {
+ return this.getLegacyFormFields();
+ }
+ }
+
+ /**
+ * Renders the registration form based on the XForm fields
+ * received from the XMPP server.
+ * @method _converse.RegisterPanel#renderRegistrationForm
+ * @param { Element } stanza - The IQ stanza received from the XMPP server.
+ */
+ renderRegistrationForm (stanza) {
+ this.form_fields = this.getFormFields(stanza);
+ this.status = REGISTRATION_FORM;
+ }
+
+ /**
+ * Report back to the user any error messages received from the
+ * XMPP server after attempted registration.
+ * @method _converse.RegisterPanel#reportErrors
+ * @param { Element } stanza - The IQ stanza received from the XMPP server
+ */
+ reportErrors (stanza) {
+ const errors = Array.from(stanza.querySelectorAll('error'));
+ if (errors.length) {
+ this.setErrorMessage(errors.reduce((result, e) => `${result}\n${e.textContent}`, ''));
+ } else {
+ this.setErrorMessage(__('The provider rejected your registration attempt. '+
+ 'Please check the values you entered for correctness.'));
+ }
+ }
+
+ renderProviderChoiceForm (ev) {
+ ev?.preventDefault?.();
+ _converse.connection._proto._abortAllRequests();
+ _converse.connection.reset();
+ this.status = CHOOSE_PROVIDER;
+ }
+
+ abortRegistration () {
+ _converse.connection._proto._abortAllRequests();
+ _converse.connection.reset();
+ if ([FETCHING_FORM, REGISTRATION_FORM].includes(this.status)) {
+ if (api.settings.get('registration_domain')) {
+ this.fetchRegistrationForm(api.settings.get('registration_domain'));
+ }
+ } else {
+ this.requestUpdate();
+ }
+ }
+
+ /**
+ * Handler, when the user submits the registration form.
+ * Provides form error feedback or starts the registration process.
+ * @method _converse.RegisterPanel#submitRegistrationForm
+ * @param { HTMLElement } form - The HTML form that was submitted
+ */
+ submitRegistrationForm (form) {
+ const inputs = sizzle(':input:not([type=button]):not([type=submit])', form);
+ const iq = $iq({'type': 'set', 'id': u.getUniqueId()})
+ .c("query", {xmlns:Strophe.NS.REGISTER});
+
+ if (this.form_type === 'xform') {
+ iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
+
+ const xml_nodes = inputs.map(i => webForm2xForm(i)).filter(n => n);
+ xml_nodes.forEach(n => iq.cnode(n).up());
+ } else {
+ inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));
+ }
+ _converse.connection._addSysHandler((iq) => this._onRegisterIQ(iq), null, "iq", null, null);
+ _converse.connection.send(iq);
+ this.setFields(iq.tree());
+ }
+
+ /**
+ * Stores the values that will be sent to the XMPP server during attempted registration.
+ * @method _converse.RegisterPanel#setFields
+ * @param { Element } stanza - the IQ stanza that will be sent to the XMPP server.
+ */
+ setFields (stanza) {
+ const query = stanza.querySelector('query');
+ const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query);
+ if (xform.length > 0) {
+ this._setFieldsFromXForm(xform.pop());
+ } else {
+ this._setFieldsFromLegacy(query);
+ }
+ }
+
+ _setFieldsFromLegacy (query) {
+ [].forEach.call(query.children, field => {
+ if (field.tagName.toLowerCase() === 'instructions') {
+ this.instructions = Strophe.getText(field);
+ return;
+ } else if (field.tagName.toLowerCase() === 'x') {
+ if (field.getAttribute('xmlns') === 'jabber:x:oob') {
+ this.urls.concat(sizzle('url', field).map(u => u.textContent));
+ }
+ return;
+ }
+ this.fields[field.tagName.toLowerCase()] = Strophe.getText(field);
+ });
+ this.form_type = 'legacy';
+ }
+
+ _setFieldsFromXForm (xform) {
+ this.title = xform.querySelector('title')?.textContent ?? '';
+ this.instructions = xform.querySelector('instructions')?.textContent ?? '';
+ xform.querySelectorAll('field').forEach(field => {
+ const _var = field.getAttribute('var');
+ if (_var) {
+ this.fields[_var.toLowerCase()] = field.querySelector('value')?.textContent ?? '';
+ } else {
+ // TODO: other option seems to be type="fixed"
+ log.warn("Found field we couldn't parse");
+ }
+ });
+ this.form_type = 'xform';
+ }
+
+ /**
+ * Callback method that gets called when a return IQ stanza
+ * is received from the XMPP server, after attempting to
+ * register a new user.
+ * @method _converse.RegisterPanel#reportErrors
+ * @param { Element } stanza - The IQ stanza.
+ */
+ _onRegisterIQ (stanza) {
+ if (stanza.getAttribute("type") === "error") {
+ log.error("Registration failed.");
+ this.reportErrors(stanza);
+
+ let error = stanza.getElementsByTagName("error");
+ if (error.length !== 1) {
+ _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown");
+ return false;
+ }
+ error = error[0].firstElementChild.tagName.toLowerCase();
+ if (error === 'conflict') {
+ _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error);
+ } else if (error === 'not-acceptable') {
+ _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error);
+ } else {
+ _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error);
+ }
+ } else {
+ _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null);
+ }
+ return false;
+ }
+}
+
+api.elements.define('converse-register-panel', RegisterPanel);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss b/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss
new file mode 100644
index 0000000..aec3676
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss
@@ -0,0 +1,61 @@
+@import "shared/styles/_mixins.scss";
+
+converse-register-panel {
+ .alert {
+ margin: auto;
+ max-width: 50vw;
+ }
+}
+
+#converse-register {
+ @include fade-in;
+ background-color: var(--controlbox-pane-background-color);
+
+ .title {
+ font-weight: bold;
+ }
+
+ .input-group {
+ input {
+ height: auto;
+ }
+ .input-group-text {
+ color: var(--text-color);
+ background-color: var(--controlbox-pane-background-color);
+ }
+ }
+
+ .info {
+ color: green;
+ font-size: 90%;
+ margin: 1.5em 0;
+ }
+
+ .form-errors {
+ color: var(--error-color);
+ margin: 1em 0;
+ }
+
+ .provider-title {
+ font-size: var(--font-size-huge);
+ margin: 0;
+ }
+
+ .provider-score {
+ width: 178px;
+ margin-bottom: 8px;
+ }
+
+ .form-help .url {
+ font-weight: bold;
+ color: var(--link-color);
+ }
+
+ .instructions {
+ color: gray;
+ font-size: 85%;
+ &:hover {
+ color: var(--controlbox-text-color);
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js
new file mode 100644
index 0000000..db56926
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js
@@ -0,0 +1,86 @@
+import tplRegistrationForm from './registration_form.js';
+import tplSpinner from 'templates/spinner.js';
+import tplSwitchForm from './switch_form.js';
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core';
+import { html } from 'lit';
+
+const tplFormRequest = (el) => {
+ const default_domain = api.settings.get('registration_domain');
+ const i18n_cancel = __('Cancel');
+ return html`
+ <form id="converse-register" class="converse-form no-scrolling" @submit=${ev => el.onFormSubmission(ev)}>
+ ${tplSpinner({ 'classes': 'hor_centered' })}
+ ${default_domain
+ ? ''
+ : html`
+ <button class="btn btn-secondary button-cancel hor_centered"
+ @click=${ev => el.renderProviderChoiceForm(ev)}>${i18n_cancel}</button>
+ `}
+ </form>
+ `;
+};
+
+const tplDomainInput = () => {
+ const domain_placeholder = api.settings.get('domain_placeholder');
+ const i18n_providers = __('Tip: A list of public XMPP providers is available');
+ const i18n_providers_link = __('here');
+ const href_providers = api.settings.get('providers_link');
+ return html`
+ <input class="form-control" required="required" type="text" name="domain" placeholder="${domain_placeholder}" />
+ <p class="form-text text-muted">
+ ${i18n_providers}
+ <a href="${href_providers}" class="url" target="_blank" rel="noopener">${i18n_providers_link}</a>.
+ </p>
+ `;
+};
+
+const tplFetchFormButtons = () => {
+ const i18n_register = __('Fetch registration form');
+ const i18n_existing_account = __('Already have a chat account?');
+ const i18n_login = __('Log in here');
+ return html`
+ <fieldset class="form-group buttons">
+ <input class="btn btn-primary" type="submit" value="${i18n_register}" />
+ </fieldset>
+ <div class="switch-form">
+ <p>${i18n_existing_account}</p>
+ <p><a class="login-here toggle-register-login" href="#converse/login">${i18n_login}</a></p>
+ </div>
+ `;
+};
+
+const tplChooseProvider = (el) => {
+ const default_domain = api.settings.get('registration_domain');
+ const i18n_create_account = __('Create your account');
+ const i18n_choose_provider = __('Please enter the XMPP provider to register with:');
+ const show_form_buttons = !default_domain && el.status === CHOOSE_PROVIDER;
+
+ return html`
+ <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}>
+ <legend class="col-form-label">${i18n_create_account}</legend>
+ <div class="form-group">
+ <label>${i18n_choose_provider}</label>
+
+ ${default_domain ? default_domain : tplDomainInput()}
+ </div>
+ ${show_form_buttons ? tplFetchFormButtons() : ''}
+ </form>
+ `;
+};
+
+const CHOOSE_PROVIDER = 0;
+const FETCHING_FORM = 1;
+const REGISTRATION_FORM = 2;
+const REGISTRATION_FORM_ERROR = 3;
+
+export default (el) => {
+ return html`
+ <converse-brand-logo></converse-brand-logo>
+ ${ el.alert_message ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert_message}</div>` : '' }
+ ${el.status === CHOOSE_PROVIDER ? tplChooseProvider(el) : ''}
+ ${el.status === FETCHING_FORM ? tplFormRequest(el) : ''}
+ ${el.status === REGISTRATION_FORM ? tplRegistrationForm(el) : ''}
+ ${el.status === REGISTRATION_FORM_ERROR ? tplSwitchForm() : '' }
+ `;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js
new file mode 100644
index 0000000..b54a8b9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js
@@ -0,0 +1,40 @@
+import tplSwitchForm from './switch_form.js';
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core';
+import { html } from 'lit';
+
+export default (el) => {
+ const i18n_choose_provider = __('Choose a different provider');
+ const i18n_legend = __('Account Registration:');
+ const i18n_register = __('Register');
+ const registration_domain = api.settings.get('registration_domain');
+
+ return html`
+ <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}>
+ <legend class="col-form-label">${i18n_legend} ${el.domain}</legend>
+ <p class="title">${el.title}</p>
+ <p class="form-help instructions">${el.instructions}</p>
+ <div class="form-errors hidden"></div>
+ ${el.form_fields}
+
+ <fieldset class="buttons form-group">
+ ${el.fields
+ ? html`
+ <input type="submit" class="btn btn-primary" value="${i18n_register}" />
+ `
+ : ''}
+ ${registration_domain
+ ? ''
+ : html`
+ <input
+ type="button"
+ class="btn btn-secondary button-cancel"
+ value="${i18n_choose_provider}"
+ @click=${ev => el.renderProviderChoiceForm(ev)}
+ />
+ `}
+ ${ tplSwitchForm() }
+ </fieldset>
+ </form>
+ `;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js
new file mode 100644
index 0000000..05aab6a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js
@@ -0,0 +1,12 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+
+export default () => {
+ const i18n_has_account = __('Already have a chat account?');
+ const i18n_login = __('Log in here');
+ return html`
+ <div class="switch-form">
+ <p>${i18n_has_account}</p>
+ <p><a class="login-here toggle-register-login" href="#converse/login">${i18n_login}</a></p>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js b/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js
new file mode 100644
index 0000000..2ed05cc
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js
@@ -0,0 +1,553 @@
+/*global mock, converse */
+
+const { stx, Strophe, $iq, sizzle, u } = converse.env;
+
+
+describe("The Registration Panel", function () {
+
+ afterEach(() => {
+ // Remove the hash
+ history.pushState("", document.title, window.location.pathname + window.location.search);
+ });
+
+ it("is not available unless allow_registration=true",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ allow_registration: false },
+ async function (_converse) {
+
+ await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+ const cbview = _converse.api.controlbox.get();
+ expect(cbview.querySelectorAll('a.register-account').length).toBe(0);
+ }));
+
+ it("can be opened by clicking on the registration tab",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox"));
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.chatboxviews.get('controlbox');
+ expect(cbview.querySelector('converse-register-panel')).toBe(null);
+
+ const register_link = await u.waitUntil(() => cbview.querySelector('a.register-account'));
+ expect(register_link.textContent).toBe("Create an account");
+ register_link.click();
+
+ expect(cbview.querySelector('converse-register-panel')).toBeDefined();
+ }));
+
+ it("allows the user to choose an XMPP provider's domain",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+
+ const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox"));
+ toggle.click();
+
+ const cbview = _converse.api.controlbox.get();
+ await u.waitUntil(() => u.isVisible(cbview));
+
+ // Open the register panel
+ cbview.querySelector('.toggle-register-login').click();
+
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ spyOn(registerview, 'onProviderChosen').and.callThrough();
+ spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
+
+ // Check the form layout
+ const form = cbview.querySelector('#converse-register');
+ expect(form.querySelectorAll('input').length).toEqual(2);
+ expect(form.querySelectorAll('input')[0].getAttribute('name')).toEqual('domain');
+ expect(sizzle('input:last', form).pop().getAttribute('type')).toEqual('submit');
+ // Check that the input[type=domain] input is required
+ const submit_button = form.querySelector('input[type=submit]');
+ submit_button.click();
+ expect(registerview.onProviderChosen).not.toHaveBeenCalled();
+
+ // Check that the form is accepted if input[type=domain] has a value
+ form.querySelector('input[name=domain]').value = 'conversejs.org';
+ submit_button.click();
+ expect(registerview.onProviderChosen).toHaveBeenCalled();
+ expect(registerview.fetchRegistrationForm).toHaveBeenCalled();
+ delete _converse.connection;
+ }));
+
+ it("allows the user to choose an XMPP provider's domain in fullscreen view mode",
+ mock.initConverse(
+ ['chatBoxesInitialized'], {
+ auto_login: false,
+ view_mode: 'fullscreen',
+ discover_connection_methods: false,
+ allow_registration: true
+ },
+ async function (_converse) {
+
+ const cbview = _converse.api.controlbox.get();
+ cbview.querySelector('.toggle-register-login').click();
+
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
+ spyOn(registerview, 'onProviderChosen').and.callThrough();
+ spyOn(registerview, 'getRegistrationFields').and.callThrough();
+ spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+
+ expect(registerview._registering).toBeFalsy();
+ expect(_converse.api.connection.connected()).toBeFalsy();
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+ expect(registerview.onProviderChosen).toHaveBeenCalled();
+ expect(registerview._registering).toBeTruthy();
+
+ await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count());
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+
+ expect(registerview.getRegistrationFields).toHaveBeenCalled();
+
+ stanza = $iq({
+ 'type': 'result',
+ 'id': 'reg1'
+ }).c('query', {'xmlns': 'jabber:iq:register'})
+ .c('instructions')
+ .t('Please choose a username, password and provide your email address').up()
+ .c('username').up()
+ .c('password').up()
+ .c('email');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(registerview.renderRegistrationForm).toHaveBeenCalled();
+
+ await u.waitUntil(() => registerview.querySelectorAll('input').length === 5);
+ expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1);
+ expect(registerview.querySelectorAll('input[type=button]').length).toBe(1);
+ }));
+
+ it("will render a registration form as received from the XMPP provider",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox"));
+ toggle.click();
+
+ const cbview = _converse.api.controlbox.get();
+ cbview.querySelector('.toggle-register-login').click();
+
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
+ spyOn(registerview, 'onProviderChosen').and.callThrough();
+ spyOn(registerview, 'getRegistrationFields').and.callThrough();
+ spyOn(registerview, 'onRegistrationFields').and.callThrough();
+ spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+
+ expect(registerview._registering).toBeFalsy();
+ expect(_converse.api.connection.connected()).toBeFalsy();
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+ expect(registerview.onProviderChosen).toHaveBeenCalled();
+ expect(registerview._registering).toBeTruthy();
+ await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count());
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+
+ expect(registerview.getRegistrationFields).toHaveBeenCalled();
+
+ stanza = $iq({
+ 'type': 'result',
+ 'id': 'reg1'
+ }).c('query', {'xmlns': 'jabber:iq:register'})
+ .c('instructions')
+ .t('Please choose a username, password and provide your email address').up()
+ .c('username').up()
+ .c('password').up()
+ .c('email');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(registerview.onRegistrationFields).toHaveBeenCalled();
+ expect(registerview.renderRegistrationForm).toHaveBeenCalled();
+
+ await u.waitUntil(() => registerview.querySelectorAll('input').length === 5);
+ expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1);
+ expect(registerview.querySelectorAll('input[type=button]').length).toBe(1);
+ }));
+
+ it("will set form_type to legacy and submit it as legacy",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = document.querySelector(".toggle-controlbox");
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.api.controlbox.get();
+ cbview.querySelector('.toggle-register-login').click();
+
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ spyOn(registerview, 'onProviderChosen').and.callThrough();
+ spyOn(registerview, 'getRegistrationFields').and.callThrough();
+ spyOn(registerview, 'onRegistrationFields').and.callThrough();
+ spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+ stanza = $iq({
+ 'type': 'result',
+ 'id': 'reg1'
+ }).c('query', {'xmlns': 'jabber:iq:register'})
+ .c('instructions')
+ .t('Please choose a username, password and provide your email address').up()
+ .c('username').up()
+ .c('password').up()
+ .c('email');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(registerview.form_type).toBe('legacy');
+
+ const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]'));
+
+ username_input.value = 'testusername';
+ registerview.querySelector('input[name=password]').value = 'testpassword';
+ registerview.querySelector('input[name=email]').value = 'test@email.local';
+
+ spyOn(_converse.connection, 'send');
+ registerview.querySelector('input[type=submit]').click();
+
+ expect(_converse.connection.send).toHaveBeenCalled();
+ stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+ expect(stanza.querySelector('query').childNodes.length).toBe(3);
+ expect(stanza.querySelector('query').firstElementChild.tagName).toBe('username');
+
+ delete _converse.connection;
+ }));
+
+ it("will set form_type to xform and submit it as xform",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = document.querySelector(".toggle-controlbox");
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.api.controlbox.get();
+ cbview.querySelector('.toggle-register-login').click();
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ spyOn(registerview, 'onProviderChosen').and.callThrough();
+ spyOn(registerview, 'getRegistrationFields').and.callThrough();
+ spyOn(registerview, 'onRegistrationFields').and.callThrough();
+ spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+ stanza = $iq({
+ 'type': 'result',
+ 'id': 'reg1'
+ }).c('query', {'xmlns': 'jabber:iq:register'})
+ .c('instructions')
+ .t('Using xform data').up()
+ .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form' })
+ .c('instructions').t('xform instructions').up()
+ .c('field', {'type': 'text-single', 'var': 'username'}).c('required').up().up()
+ .c('field', {'type': 'text-private', 'var': 'password'}).c('required').up().up()
+ .c('field', {'type': 'text-single', 'var': 'email'}).c('required').up().up();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(registerview.form_type).toBe('xform');
+
+ const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]'));
+
+ username_input.value = 'testusername';
+ registerview.querySelector('input[name=password]').value = 'testpassword';
+ registerview.querySelector('input[name=email]').value = 'test@email.local';
+
+ spyOn(_converse.connection, 'send');
+
+ registerview.querySelector('input[type=submit]').click();
+
+ expect(_converse.connection.send).toHaveBeenCalled();
+ stanza = _converse.connection.send.calls.argsFor(0)[0].tree();
+ expect(Strophe.serialize(stanza).toLocaleString().trim().replace(/(\n|\s{2,})/g, '')).toEqual(
+ '<iq id="'+stanza.getAttribute('id')+'" type="set" xmlns="jabber:client">'+
+ '<query xmlns="jabber:iq:register">'+
+ '<x type="submit" xmlns="jabber:x:data">'+
+ '<field var="username">'+
+ '<value>testusername</value>'+
+ '</field>'+
+ '<field var="password">'+
+ '<value>testpassword</value>'+
+ '</field>'+
+ '<field var="email">'+
+ '<value>test@email.local</value>'+
+ '</field>'+
+ '</x>'+
+ '</query>'+
+ '</iq>'
+ );
+
+ delete _converse.connection;
+ }));
+
+ it("renders the account registration form",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = document.querySelector(".toggle-controlbox");
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.toggle-register-login').click();
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+
+ stanza = stx`
+ <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
+ <query xmlns="jabber:iq:register">
+ <x xmlns="jabber:x:data" type="form">
+ <instructions>Choose a username and password to register with this server</instructions>
+ <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
+ <field var="username" type="text-single" label="User"><required/></field>
+ <field var="password" type="text-private" label="Password"><required/></field>
+ <field var="from" type="hidden"><value>conversations.im</value></field>
+ <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
+ <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
+ <field var="ocr" type="text-single" label="Enter the text you see">
+ <media xmlns="urn:xmpp:media-element">
+ <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri>
+ </media>
+ <required/>
+ </field>
+ </x>
+ <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org"
+ type="image/png"
+ max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
+ <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
+ </query>
+ </iq>`;
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 3);
+ expect(registerview.form_type).toBe('xform');
+
+ // Hide the controlbox so that we can see whether the test passed or failed
+ u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
+ delete _converse.connection;
+ }));
+
+ it("lets you choose a different provider",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ view_mode: 'fullscreen',
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = document.querySelector(".toggle-controlbox");
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.toggle-register-login').click();
+ const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+
+ registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+ registerview.querySelector('input[type=submit]').click();
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+
+ stanza = stx`
+ <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
+ <query xmlns="jabber:iq:register">
+ <x xmlns="jabber:x:data" type="form">
+ <instructions>Choose a username and password to register with this server</instructions>
+ <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
+ <field var="username" type="text-single" label="User"><required/></field>
+ <field var="password" type="text-private" label="Password"><required/></field>
+ <field var="from" type="hidden"><value>conversations.im</value></field>
+ <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
+ <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
+ </x>
+ </query>
+ </iq>`;
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 2);
+ expect(registerview.form_type).toBe('xform');
+
+ const button = await u.waitUntil(() => registerview.querySelector('.btn-secondary'));
+ expect(button.value).toBe("Choose a different provider");
+ button.click();
+
+ await u.waitUntil(() => registerview.querySelector('input[name="domain"]'));
+ expect(registerview.querySelectorAll('input[required]').length).toBe(1);
+
+ // Hide the controlbox so that we can see whether the test passed or failed
+ u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
+ delete _converse.connection;
+ }));
+
+ it("renders errors",
+ mock.initConverse(
+ ['chatBoxesInitialized'],
+ { auto_login: false,
+ view_mode: 'fullscreen',
+ discover_connection_methods: false,
+ allow_registration: true },
+ async function (_converse) {
+
+ const toggle = document.querySelector(".toggle-controlbox");
+ if (!u.isVisible(document.querySelector("#controlbox"))) {
+ if (!u.isVisible(toggle)) {
+ u.removeClass('hidden', toggle);
+ }
+ toggle.click();
+ }
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.toggle-register-login').click();
+ const view = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+
+ view.querySelector('input[name=domain]').value = 'conversejs.org';
+ view.querySelector('input[type=submit]').click();
+
+ let stanza = new Strophe.Builder("stream:features", {
+ 'xmlns:stream': "http://etherx.jabber.org/streams",
+ 'xmlns': "jabber:client"
+ })
+ .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up()
+ .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+ _converse.connection._connect_cb(mock.createRequest(stanza));
+
+ stanza = stx`
+ <iq xmlns="jabber:client" type="result" from="conversejs.org" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
+ <query xmlns="jabber:iq:register">
+ <x xmlns="jabber:x:data" type="form">
+ <instructions>Choose a username and password to register with this server</instructions>
+ <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
+ <field var="username" type="text-single" label="User"><required/></field>
+ <field var="password" type="text-private" label="Password"><required/></field>
+ <field var="from" type="hidden"><value>conversejs.org</value></field>
+ <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
+ <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
+ <field var="ocr" type="text-single" label="Enter the text you see">
+ <media xmlns="urn:xmpp:media-element">
+ <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri>
+ </media>
+ <required/>
+ </field>
+ </x>
+ <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org"
+ type="image/png"
+ max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
+ <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
+ </query>
+ </iq>`;
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ spyOn(view, 'submitRegistrationForm').and.callThrough();
+
+ const username_input = await u.waitUntil(() => view.querySelector('[name="username"]'));
+ username_input.value = 'romeo';
+ const password_input = view.querySelector('[name="password"]');
+ password_input.value = 'secret';
+ const ocr_input = view.querySelector('[name="ocr"]');
+ ocr_input.value = '8m9D88';
+ view.querySelector('[type="submit"]').click();
+ expect(view.submitRegistrationForm).toHaveBeenCalled();
+
+ const response_IQ = stx`
+ <iq xml:lang='en' from='conversejs.org' type='error' id='d9917b7a-588f-4ef6-8a56-0d6d3ad538ae:sendIQ' xmlns="jabber:client">
+ <query xmlns='jabber:iq:register'/>
+ <error code='500' type='wait'>
+ <resource-constraint xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ <text xml:lang='en' xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Too many CAPTCHA requests</text>
+ </error>
+ </iq>`;
+ _converse.connection._dataRecv(mock.createRequest(response_IQ));
+
+ const alert = await u.waitUntil(() => view.querySelector('.alert'));
+ expect(alert.textContent.trim()).toBe('Too many CAPTCHA requests');
+ // Hide the controlbox so that we can see whether the test passed or failed
+ u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
+ delete _converse.connection;
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js
new file mode 100644
index 0000000..250b722
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js
@@ -0,0 +1,7 @@
+import { _converse, api } from '@converse/headless/core';
+
+export async function setActiveForm (value) {
+ await api.waitUntil('controlBoxInitialized');
+ const controlbox = _converse.chatboxes.get('controlbox');
+ controlbox.set({ 'active-form': value });
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js
new file mode 100644
index 0000000..3c422a7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js
@@ -0,0 +1,23 @@
+/**
+ * @description
+ * Converse.js plugin which shows a list of currently open
+ * rooms in the "Rooms Panel" of the ControlBox.
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import "@converse/headless/plugins/muc/index.js";
+import './view.js';
+import { converse } from "@converse/headless/core";
+
+
+converse.plugins.add('converse-roomslist', {
+
+ dependencies: [
+ "converse-singleton",
+ "converse-controlbox",
+ "converse-muc",
+ "converse-bookmarks"
+ ],
+
+ initialize () { }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js
new file mode 100644
index 0000000..e920f0d
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js
@@ -0,0 +1,27 @@
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from "@converse/headless/core";
+
+const { Strophe } = converse.env;
+
+const RoomsListModel = Model.extend({
+
+ defaults: function () {
+ return {
+ 'muc_domain': api.settings.get('muc_domain'),
+ 'nick': _converse.getDefaultMUCNickname(),
+ 'toggle_state': _converse.OPENED,
+ };
+ },
+
+ initialize () {
+ api.settings.listen.on('change:muc_domain', (muc_domain) => this.setDomain(muc_domain));
+ },
+
+ setDomain (jid) {
+ if (!api.settings.get('locked_muc_domain')) {
+ this.save('muc_domain', Strophe.getDomainFromJid(jid));
+ }
+ }
+});
+
+export default RoomsListModel;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js
new file mode 100644
index 0000000..2834b21
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js
@@ -0,0 +1,117 @@
+import 'plugins/muc-views/modals/add-muc.js';
+import 'plugins/muc-views/modals/muc-list.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+import { html } from "lit";
+import { isUniView } from '@converse/headless/utils/core.js';
+import { addBookmarkViaEvent } from 'plugins/bookmark-views/utils.js';
+
+
+function isCurrentlyOpen (room) {
+ return isUniView() && !room.get('hidden');
+}
+
+function tplBookmark (room) {
+ const bm = room.get('bookmarked') ?? false;
+ const i18n_bookmark = __('Bookmark');
+ return html`
+ <a class="list-item-action add-bookmark"
+ data-room-jid="${room.get('jid')}"
+ data-bookmark-name="${room.getDisplayName()}"
+ @click=${ev => addBookmarkViaEvent(ev)}
+ title="${ i18n_bookmark }">
+
+ <converse-icon class="fa ${bm ? 'fa-bookmark' : 'fa-bookmark-empty'}"
+ size="1.2em"
+ color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
+ </a>`;
+}
+
+
+const tplUnreadIndicator = (room) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ room.get('num_unread') }</span>`;
+
+const tplActivityIndicator = () => html`<span class="list-item-badge badge badge--muc msgs-indicator"></span>`;
+
+
+function tplRoomItem (el, room) {
+ const i18n_leave_room = __('Leave this groupchat');
+ const has_unread_msgs = room.get('num_unread_general') || room.get('has_activity');
+ return html`
+ <div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ isCurrentlyOpen(room) ? 'open' : '' } ${ has_unread_msgs ? 'unread-msgs' : '' }"
+ data-room-jid="${room.get('jid')}">
+
+ ${ room.get('num_unread') ? tplUnreadIndicator(room) : (room.get('has_activity') ? tplActivityIndicator() : '') }
+
+ <a class="list-item-link open-room available-room w-100"
+ data-room-jid="${room.get('jid')}"
+ title="${__('Click to open this groupchat')}"
+ @click=${ev => el.openRoom(ev)}>${room.getDisplayName()}</a>
+
+ ${ api.settings.get('allow_bookmarks') ? tplBookmark(room) : '' }
+
+ <a class="list-item-action room-info"
+ data-room-jid="${room.get('jid')}"
+ title="${__('Show more information on this groupchat')}"
+ @click=${ev => el.showRoomDetailsModal(ev)}>
+
+ <converse-icon class="fa fa-info-circle" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
+ </a>
+
+ <a class="list-item-action close-room"
+ data-room-jid="${room.get('jid')}"
+ data-room-name="${room.getDisplayName()}"
+ title="${i18n_leave_room}"
+ @click=${ev => el.closeRoom(ev)}>
+ <converse-icon class="fa fa-sign-out-alt" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon>
+ </a>
+ </div>`;
+}
+
+export default (el) => {
+ const { chatboxes, CHATROOMS_TYPE, CLOSED } = _converse;
+ const rooms = chatboxes.filter(m => m.get('type') === CHATROOMS_TYPE);
+ rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1));
+
+ const i18n_desc_rooms = __('Click to toggle the list of open groupchats');
+ const i18n_heading_chatrooms = __('Groupchats');
+ const i18n_title_list_rooms = __('Query for groupchats');
+ const i18n_title_new_room = __('Add a new groupchat');
+ const i18n_show_bookmarks = __('Show bookmarked groupchats');
+ const is_closed = el.model.get('toggle_state') === CLOSED;
+ return html`
+ <div class="d-flex controlbox-padded">
+ <span class="w-100 controlbox-heading controlbox-heading--groupchats">
+ <a class="list-toggle open-rooms-toggle" title="${i18n_desc_rooms}" @click=${ev => el.toggleRoomsList(ev)}>
+ <converse-icon
+ class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }"
+ size="1em"
+ color="var(--muc-color)"></converse-icon>
+ ${i18n_heading_chatrooms}
+ </a>
+ </span>
+
+ <a class="controlbox-heading__btn show-bookmark-list-modal"
+ @click=${(ev) => api.modal.show('converse-bookmark-list-modal', { 'model': el.model }, ev)}
+ title="${i18n_show_bookmarks}"
+ data-toggle="modal">
+ <converse-icon class="fa fa-bookmark right" size="1em"></converse-icon>
+ </a>
+
+ <a class="controlbox-heading__btn show-list-muc-modal"
+ @click=${(ev) => api.modal.show('converse-muc-list-modal', { 'model': el.model }, ev)}
+ title="${i18n_title_list_rooms}" data-toggle="modal" data-target="#muc-list-modal">
+ <converse-icon class="fa fa-list-ul right" size="1em"></converse-icon>
+ </a>
+ <a class="controlbox-heading__btn show-add-muc-modal"
+ @click=${(ev) => api.modal.show('converse-add-muc-modal', { 'model': el.model }, ev)}
+ title="${i18n_title_new_room}" data-toggle="modal" data-target="#add-chatrooms-modal">
+ <converse-icon class="fa fa-plus right" size="1em"></converse-icon>
+ </a>
+ </div>
+
+ <div class="list-container list-container--openrooms ${ rooms.length ? '' : 'hidden' }">
+ <div class="items-list rooms-list open-rooms-list ${ is_closed ? 'collapsed' : '' }">
+ ${ rooms.map(room => tplRoomItem(el, room)) }
+ </div>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js
new file mode 100644
index 0000000..8ac7c4e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js
@@ -0,0 +1,400 @@
+/* global mock, converse */
+
+const { $msg, u } = converse.env;
+
+
+describe("A list of open groupchats", function () {
+
+ it("is shown in controlbox", mock.initConverse(
+ ['chatBoxesFetched'],
+ { allow_bookmarks: false // Makes testing easier, otherwise we
+ // have to mock stanza traffic.
+ }, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ let list = controlbox.querySelector('.list-container--openrooms');
+ expect(u.hasClass('hidden', list)).toBeTruthy();
+ await mock.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
+
+ const lview = controlbox.querySelector('converse-rooms-list');
+ await u.waitUntil(() => lview.querySelectorAll(".open-room").length);
+ let room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(1);
+ expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit');
+
+ await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+ await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1);
+ room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(2);
+
+ let view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
+ await view.close();
+ room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(1);
+ expect(room_els[0].innerText).toBe('lounge@montague.lit');
+ list = controlbox.querySelector('.list-container--openrooms');
+ u.waitUntil(() => Array.from(list.classList).includes('hidden'));
+
+ view = _converse.chatboxviews.get('lounge@montague.lit');
+ await view.close();
+ room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(0);
+
+ list = controlbox.querySelector('.list-container--openrooms');
+ expect(Array.from(list.classList).includes('hidden')).toBeTruthy();
+ }));
+
+ it("shows the number of unread mentions received",
+ mock.initConverse(
+ [], {'allow_bookmarks': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
+ expect(roomspanel.querySelectorAll('.available-room').length).toBe(0);
+
+ const muc_jid = 'kitchen@conference.shakespeare.lit';
+ const message = 'fires: Your attention is required';
+ await mock.openAndEnterChatRoom(_converse, muc_jid, 'fires');
+ const view = _converse.chatboxviews.get(muc_jid);
+ await u.waitUntil(() => roomspanel.querySelectorAll('.available-room').length);
+ expect(roomspanel.querySelectorAll('.available-room').length).toBe(1);
+ expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(0);
+
+ view.model.set({'minimized': true});
+
+ const nick = mock.chatroom_names[0];
+ await view.model.handleMessageStanza($msg({
+ from: muc_jid+'/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t(message).tree());
+ await u.waitUntil(() => view.model.messages.length);
+ expect(roomspanel.querySelectorAll('.available-room').length).toBe(1);
+ expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(1);
+ expect(roomspanel.querySelector('.msgs-indicator').textContent.trim()).toBe('1');
+
+ await view.model.handleMessageStanza($msg({
+ 'from': muc_jid+'/'+nick,
+ 'id': u.getUniqueId(),
+ 'to': 'romeo@montague.lit',
+ 'type': 'groupchat'
+ }).c('body').t(message).tree());
+ await u.waitUntil(() => view.model.messages.length > 1);
+ expect(roomspanel.querySelectorAll('.available-room').length).toBe(1);
+ expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(1);
+ expect(roomspanel.querySelector('.msgs-indicator').textContent.trim()).toBe('2');
+ view.model.set({'minimized': false});
+ expect(roomspanel.querySelectorAll('.available-room').length).toBe(1);
+ await u.waitUntil(() => roomspanel.querySelectorAll('.msgs-indicator').length === 0);
+ }));
+
+ it("uses bookmarks to determine groupchat names",
+ mock.initConverse(
+ ['chatBoxesFetched'],
+ {'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ const { Strophe, $iq, $pres, sizzle } = converse.env;
+ const u = converse.env.utils;
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+ let stanza = $pres({
+ to: 'romeo@montague.lit/orchard',
+ from: 'lounge@montague.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ spyOn(_converse.Bookmarks.prototype, 'fetchBookmarks').and.callThrough();
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.bare_jid,
+ [{'category': 'pubsub', 'type':'pep'}],
+ [`${Strophe.NS.PUBSUB}#publish-options`]
+ );
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+
+ '<items node="storage:bookmarks"/>'+
+ '</pubsub>'+
+ '</iq>');
+
+ stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')})
+ .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
+ .c('items', {'node': 'storage:bookmarks'})
+ .c('item', {'id': 'current'})
+ .c('storage', {'xmlns': 'storage:bookmarks'})
+ .c('conference', {
+ 'name': 'Bookmarked Lounge',
+ 'jid': 'lounge@montague.lit'
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await _converse.api.waitUntil('roomsListInitialized');
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ const list = controlbox.querySelector('.list-container--openrooms');
+ expect(Array.from(list.classList).includes('hidden')).toBeFalsy();
+ const items = list.querySelectorAll('.list-item');
+ expect(items.length).toBe(1);
+ await u.waitUntil(() => list.querySelector('.list-item').textContent.trim() === 'Bookmarked Lounge');
+ expect(_converse.bookmarks.fetchBookmarks).toHaveBeenCalled();
+ }));
+});
+
+describe("A groupchat shown in the groupchats list", function () {
+
+ it("is highlighted if it's currently open", mock.initConverse(
+ ['chatBoxesFetched'],
+ { view_mode: 'fullscreen',
+ allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+ }, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ const u = converse.env.utils;
+ const muc_jid = 'coven@chat.shakespeare.lit';
+ await _converse.api.rooms.open(muc_jid, {'nick': 'some1'}, true);
+ const lview = controlbox.querySelector('converse-rooms-list');
+ await u.waitUntil(() => lview.querySelectorAll(".open-room").length);
+ let room_els = lview.querySelectorAll(".available-chatroom");
+ expect(room_els.length).toBe(1);
+
+ let item = room_els[0];
+ await u.waitUntil(() => _converse.chatboxes.get(muc_jid).get('hidden') === false);
+ await u.waitUntil(() => u.hasClass('open', item), 1000);
+ expect(item.textContent.trim()).toBe('coven@chat.shakespeare.lit');
+ await _converse.api.rooms.open('balcony@chat.shakespeare.lit', {'nick': 'some1'}, true);
+ await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1);
+ room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(2);
+
+ room_els = lview.querySelectorAll(".available-chatroom.open");
+ expect(room_els.length).toBe(1);
+ item = room_els[0];
+ expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit');
+ }));
+
+ it("has an info icon which opens a details modal when clicked", mock.initConverse(
+ ['chatBoxesFetched'],
+ { whitelisted_plugins: ['converse-roomslist'],
+ allow_bookmarks: false // Makes testing easier, otherwise we
+ // have to mock stanza traffic.
+ }, async function (_converse) {
+
+ const { Strophe, $iq, $pres } = converse.env;
+ const u = converse.env.utils;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const room_jid = 'coven@chat.shakespeare.lit';
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ await _converse.api.rooms.open(room_jid, {'nick': 'some1'});
+
+ const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
+ const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
+ const features_stanza = $iq({
+ 'from': 'coven@chat.shakespeare.lit',
+ 'id': features_query.getAttribute('id'),
+ 'to': 'romeo@montague.lit/desktop',
+ 'type': 'result'
+ })
+ .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'conference',
+ 'name': 'A Dark Cave',
+ 'type': 'text'
+ }).up()
+ .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+ .c('feature', {'var': 'muc_passwordprotected'}).up()
+ .c('feature', {'var': 'muc_hidden'}).up()
+ .c('feature', {'var': 'muc_temporary'}).up()
+ .c('feature', {'var': 'muc_open'}).up()
+ .c('feature', {'var': 'muc_unmoderated'}).up()
+ .c('feature', {'var': 'muc_nonanonymous'}).up()
+ .c('feature', {'var': 'urn:xmpp:mam:0'}).up()
+ .c('x', { 'xmlns':'jabber:x:data', 'type':'result'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up()
+ .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'})
+ .c('value').t('This is the description').up().up()
+ .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
+ .c('value').t(0);
+ _converse.connection._dataRecv(mock.createRequest(features_stanza));
+
+ const view = _converse.chatboxviews.get(room_jid);
+ await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)
+ let presence = $pres({
+ to: _converse.connection.jid,
+ from: 'coven@chat.shakespeare.lit/some1',
+ id: 'DC352437-C019-40EC-B590-AF29E879AF97'
+ }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+ .c('item').attrs({
+ affiliation: 'member',
+ jid: _converse.bare_jid,
+ role: 'participant'
+ }).up()
+ .c('status').attrs({code:'110'});
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ const rooms_list = document.querySelector('converse-rooms-list');
+ await u.waitUntil(() => rooms_list.querySelectorAll(".open-room").length, 500);
+ const room_els = rooms_list.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(1);
+ const info_el = rooms_list.querySelector(".room-info");
+ info_el.click();
+
+ const modal = _converse.api.modal.get('converse-muc-details-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ let els = modal.querySelectorAll('p.room-info');
+ expect(els[0].textContent).toBe("Name: A Dark Cave")
+
+ expect(els[1].querySelector('strong').textContent).toBe("XMPP address");
+ expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join");
+ expect(els[2].querySelector('strong').textContent).toBe("Description");
+ expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description");
+
+ expect(els[3].textContent).toBe("Online users: 1")
+ const features_list = modal.querySelector('.features-list');
+ expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
+ 'Password protected - This groupchat requires a password before entry'+
+ 'Hidden - This groupchat is not publicly searchable'+
+ 'Open - Anyone can join this groupchat'+
+ 'Temporary - This groupchat will disappear once the last person leaves'+
+ 'Not anonymous - All other groupchat participants can see your XMPP address'+
+ 'Not moderated - Participants entering this groupchat can write right away'
+ );
+ presence = $pres({
+ to: 'romeo@montague.lit/_converse.js-29092160',
+ from: 'coven@chat.shakespeare.lit/newguy'
+ })
+ .c('x', {xmlns: Strophe.NS.MUC_USER})
+ .c('item', {
+ 'affiliation': 'none',
+ 'jid': 'newguy@montague.lit/_converse.js-290929789',
+ 'role': 'participant'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+
+ els = modal.querySelectorAll('p.room-info');
+ expect(els[3].textContent).toBe("Online users: 2")
+
+ view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}});
+ els = modal.querySelectorAll('p.room-info');
+ expect(els[0].textContent).toBe("Name: A Dark Cave")
+
+ expect(els[1].querySelector('strong').textContent).toBe("XMPP address");
+ expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join");
+ expect(els[2].querySelector('strong').textContent).toBe("Description");
+ expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description");
+ expect(els[3].querySelector('strong').textContent).toBe("Topic");
+ await u.waitUntil(() => els[3].querySelector('converse-rich-text').textContent === "Hatching dark plots");
+
+ expect(els[4].textContent).toBe("Topic author: someone")
+ expect(els[5].textContent).toBe("Online users: 2")
+ }));
+
+ it("can be closed", mock.initConverse(
+ [],
+ { whitelisted_plugins: ['converse-roomslist'],
+ allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+ },
+ async function (_converse) {
+
+ const u = converse.env.utils;
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ expect(_converse.chatboxes.length).toBe(1);
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openChatRoom(_converse, 'lounge', 'conference.shakespeare.lit', 'JC');
+ expect(_converse.chatboxes.length).toBe(2);
+
+ await mock.openControlBox(_converse);
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ const lview = controlbox.querySelector('converse-rooms-list');
+ await u.waitUntil(() => lview.querySelectorAll(".open-room").length);
+ const room_els = lview.querySelectorAll(".open-room");
+ expect(room_els.length).toBe(1);
+ const rooms_list = document.querySelector('converse-rooms-list');
+ const close_el = rooms_list.querySelector(".close-room");
+ close_el.click();
+ expect(_converse.api.confirm).toHaveBeenCalledWith(
+ 'Are you sure you want to leave the groupchat lounge@conference.shakespeare.lit?');
+
+ await u.waitUntil(() => rooms_list.querySelectorAll(".open-room").length === 0);
+ expect(_converse.chatboxes.length).toBe(1);
+ }));
+
+ it("shows unread messages directed at the user", mock.initConverse(
+ null,
+ { whitelisted_plugins: ['converse-roomslist'],
+ allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic.
+ }, async (_converse) => {
+
+ const { $msg } = converse.env;
+ const u = converse.env.utils;
+ await mock.openControlBox(_converse);
+ const room_jid = 'kitchen@conference.shakespeare.lit';
+ const rooms_list = document.querySelector('converse-rooms-list');
+ await u.waitUntil(() => rooms_list !== undefined, 500);
+ await mock.openAndEnterChatRoom(_converse, room_jid, 'romeo');
+ const view = _converse.chatboxviews.get(room_jid);
+ view.model.set({'minimized': true});
+ const nick = mock.chatroom_names[0];
+ await view.model.handleMessageStanza(
+ $msg({
+ from: room_jid+'/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('foo').tree());
+
+ // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold
+ const controlbox = _converse.chatboxviews.get('controlbox');
+ const lview = controlbox.querySelector('converse-rooms-list');
+ let room_el = await u.waitUntil(() => lview.querySelector(".available-chatroom"));
+ expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+
+ // If the user is mentioned, the counter also gets updated
+ await view.model.handleMessageStanza(
+ $msg({
+ from: room_jid+'/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('romeo: Your attention is required').tree()
+ );
+
+ let indicator_el = await u.waitUntil(() => lview.querySelector(".msgs-indicator"));
+ expect(indicator_el.textContent).toBe('1');
+
+ spyOn(view.model, 'handleUnreadMessage').and.callThrough();
+ await view.model.handleMessageStanza(
+ $msg({
+ from: room_jid+'/'+nick,
+ id: u.getUniqueId(),
+ to: 'romeo@montague.lit',
+ type: 'groupchat'
+ }).c('body').t('romeo: and another thing...').tree()
+ );
+ await u.waitUntil(() => view.model.handleUnreadMessage.calls.count());
+ await u.waitUntil(() => lview.querySelector(".msgs-indicator").textContent === '2', 1000);
+
+ // When the chat gets maximized again, the unread indicators are removed
+ view.model.set({'minimized': false});
+ indicator_el = lview.querySelector(".msgs-indicator");
+ expect(indicator_el === null);
+ room_el = lview.querySelector(".available-chatroom");
+ await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs') === false);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js
new file mode 100644
index 0000000..dea738b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js
@@ -0,0 +1,83 @@
+import 'plugins/muc-views/modals/muc-details.js';
+import RoomsListModel from './model.js';
+import tplRoomslist from "./templates/roomslist.js";
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { initStorage } from '@converse/headless/utils/storage.js';
+
+const { Strophe, u } = converse.env;
+
+export class RoomsList extends CustomElement {
+
+ initialize () {
+ const id = `converse.roomspanel${_converse.bare_jid}`;
+ this.model = new RoomsListModel({ id });
+ initStorage(this.model, id);
+ this.model.fetch();
+
+ this.listenTo(_converse.chatboxes, 'add', this.renderIfChatRoom);
+ this.listenTo(_converse.chatboxes, 'remove', this.renderIfChatRoom);
+ this.listenTo(_converse.chatboxes, 'destroy', this.renderIfChatRoom);
+ this.listenTo(_converse.chatboxes, 'change', this.renderIfRelevantChange);
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+
+ this.requestUpdate();
+ }
+
+ render () {
+ return tplRoomslist(this);
+ }
+
+ renderIfChatRoom (model) {
+ u.isChatRoom(model) && this.requestUpdate();
+ }
+
+ renderIfRelevantChange (model) {
+ const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity'];
+ const changed = model.changed || {};
+ if (u.isChatRoom(model) && Object.keys(changed).filter(m => attrs.includes(m)).length) {
+ this.requestUpdate();
+ }
+ }
+
+ showRoomDetailsModal (ev) { // eslint-disable-line class-methods-use-this
+ const jid = ev.currentTarget.getAttribute('data-room-jid');
+ const room = _converse.chatboxes.get(jid);
+ ev.preventDefault();
+ api.modal.show('converse-muc-details-modal', {'model': room}, ev);
+ }
+
+ async openRoom (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ const name = ev.target.textContent;
+ const jid = ev.target.getAttribute('data-room-jid');
+ const data = {
+ 'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
+ }
+ await api.rooms.open(jid, data, true);
+ }
+
+ async closeRoom (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ const name = ev.currentTarget.getAttribute('data-room-name');
+ const jid = ev.currentTarget.getAttribute('data-room-jid');
+ const result = await api.confirm(__("Are you sure you want to leave the groupchat %1$s?", name));
+ if (result) {
+ const room = await api.rooms.get(jid);
+ room.close();
+ }
+ }
+
+ toggleRoomsList (ev) {
+ ev?.preventDefault?.();
+ const list_el = this.querySelector('.open-rooms-list');
+ if (this.model.get('toggle_state') === _converse.CLOSED) {
+ u.slideOut(list_el).then(() => this.model.save({'toggle_state': _converse.OPENED}));
+ } else {
+ u.slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED}));
+ }
+ }
+}
+
+api.elements.define('converse-rooms-list', RoomsList);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js
new file mode 100644
index 0000000..97f6f6c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js
@@ -0,0 +1,26 @@
+import ConverseRoot from './root.js';
+import { api, converse } from '@converse/headless/core';
+import { ensureElement } from './utils.js';
+
+
+converse.plugins.add('converse-rootview', {
+
+ initialize () {
+ // Configuration values for this plugin
+ // ====================================
+ // Refer to docs/source/configuration.rst for explanations of these
+ // configuration settings.
+ api.settings.extend({
+ 'auto_insert': true,
+ 'theme': 'classic',
+ 'dark_theme': 'dracula',
+ });
+
+ api.listen.on('chatBoxesInitialized', ensureElement);
+
+ // Only define the element now, otherwise it it's already in the DOM
+ // before `converse.initialized` has been called it will render too
+ // early.
+ api.elements.define('converse-root', ConverseRoot);
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js
new file mode 100644
index 0000000..4c8092f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js
@@ -0,0 +1,40 @@
+import tplRoot from "./templates/root.js";
+import { api } from '@converse/headless/core';
+import { CustomElement } from 'shared/components/element.js';
+import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
+import { getTheme } from './utils.js';
+
+import './styles/root.scss';
+
+
+/**
+ * `converse-root` is an optional custom element which can be used to
+ * declaratively insert the Converse UI into the DOM.
+ *
+ * It can be inserted into the DOM before or after Converse has loaded or been
+ * initialized.
+ */
+export default class ConverseRoot extends CustomElement {
+
+ render () { // eslint-disable-line class-methods-use-this
+ return tplRoot();
+ }
+
+ initialize () {
+ this.setAttribute('id', 'conversejs');
+ this.setClasses();
+ const settings = getAppSettings();
+ this.listenTo(settings, 'change:view_mode', () => this.setClasses())
+ this.listenTo(settings, 'change:singleton', () => this.setClasses())
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(() => this.setClasses());
+ window.matchMedia('(prefers-color-scheme: light)').addListener(() => this.setClasses());
+ }
+
+ setClasses () {
+ this.className = "";
+ this.classList.add('conversejs');
+ this.classList.add(`converse-${api.settings.get('view_mode')}`);
+ this.classList.add(`theme-${getTheme()}`);
+ this.requestUpdate();
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss b/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss
new file mode 100644
index 0000000..7c165c8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss
@@ -0,0 +1,16 @@
+converse-root.converse-js {
+ &.converse-fullpage,
+ &.converse-overlayed,
+ &.converse-mobile {
+ bottom: 0;
+ height: 100%;
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ position: fixed;
+ z-index: 1031; // One more than bootstrap navbar
+ }
+
+ &.converse-embedded {
+ position: relative;
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js
new file mode 100644
index 0000000..73efc63
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js
@@ -0,0 +1,13 @@
+import 'shared/components/font-awesome.js';
+import { api } from '@converse/headless/core';
+import { html } from 'lit';
+
+export default () => {
+ const extra_classes = api.settings.get('singleton') ? ['converse-singleton'] : [];
+ extra_classes.push(`converse-${api.settings.get('view_mode')}`);
+ return html`
+ <converse-chats class="converse-chatboxes row no-gutters ${extra_classes.join(' ')}"></converse-chats>
+ <div id="converse-modals" class="modals"></div>
+ <converse-fontawesome></converse-fontawesome>
+ `;
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js
new file mode 100644
index 0000000..581f15a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js
@@ -0,0 +1,16 @@
+/* global mock, converse */
+
+const u = converse.env.utils;
+
+describe("Converse", function() {
+
+ it("Can be inserted into a converse-root custom element after having been initialized",
+ mock.initConverse([], {'root': new DocumentFragment()}, async (_converse) => {
+
+ const { api } = _converse;
+ expect(document.body.querySelector('#conversejs')).toBe(null);
+ expect(api.settings.get('root').firstElementChild.nodeName.toLowerCase()).toBe('converse-root');
+ document.body.appendChild(document.createElement('converse-root'));
+ await u.waitUntil(() => document.body.querySelector('#conversejs') !== null);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js
new file mode 100644
index 0000000..9ee7790
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js
@@ -0,0 +1,25 @@
+import { api } from '@converse/headless/core';
+
+export function getTheme() {
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ return api.settings.get('dark_theme');
+ } else {
+ return api.settings.get('theme');
+ }
+}
+
+export function ensureElement () {
+ if (!api.settings.get('auto_insert')) {
+ return;
+ }
+ const root = api.settings.get('root');
+ if (!root.querySelector('converse-root')) {
+ const el = document.createElement('converse-root');
+ const body = root.querySelector('body');
+ if (body) {
+ body.appendChild(el);
+ } else {
+ root.appendChild(el); // Perhaps inside a web component?
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js
new file mode 100644
index 0000000..6d96c80
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js
@@ -0,0 +1,10 @@
+import { __ } from 'i18n';
+
+export const STATUSES = {
+ 'dnd': __('This contact is busy'),
+ 'online': __('This contact is online'),
+ 'offline': __('This contact is offline'),
+ 'unavailable': __('This contact is unavailable'),
+ 'xa': __('This contact is away for an extended period'),
+ 'away': __('This contact is away')
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js
new file mode 100644
index 0000000..b4ac5fe
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js
@@ -0,0 +1,91 @@
+import log from "@converse/headless/log.js";
+import tplRequestingContact from "./templates/requesting_contact.js";
+import tplRosterItem from "./templates/roster_item.js";
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+
+export default class RosterContact extends CustomElement {
+
+ static get properties () {
+ return {
+ model: { type: Object }
+ }
+ }
+
+ initialize () {
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+ this.listenTo(this.model, 'highlight', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:add', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
+ this.listenTo(this.model, 'presenceChanged', () => this.requestUpdate());
+ }
+
+ render () {
+ if (this.model.get('requesting') === true) {
+ const display_name = this.model.getDisplayName();
+ return tplRequestingContact(
+ Object.assign(this.model.toJSON(), {
+ display_name,
+ 'openChat': ev => this.openChat(ev),
+ 'acceptRequest': ev => this.acceptRequest(ev),
+ 'declineRequest': ev => this.declineRequest(ev),
+ 'desc_accept': __("Click to accept the contact request from %1$s", display_name),
+ 'desc_decline': __("Click to decline the contact request from %1$s", display_name),
+ })
+ );
+ } else {
+ return tplRosterItem(this, this.model);
+ }
+ }
+
+ openChat (ev) {
+ ev?.preventDefault?.();
+ this.model.openChat();
+ }
+
+ async removeContact (ev) {
+ ev?.preventDefault?.();
+ if (!api.settings.get('allow_contact_removal')) { return; }
+
+ const result = await api.confirm(__("Are you sure you want to remove this contact?"));
+ if (!result) return;
+
+ try {
+ this.model.removeFromRoster();
+ if (this.model.collection) {
+ // The model might have already been removed as
+ // result of a roster push.
+ this.model.destroy();
+ }
+ } catch (e) {
+ log.error(e);
+ api.alert('error', __('Error'),
+ [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())]
+ );
+ }
+ }
+
+ async acceptRequest (ev) {
+ ev?.preventDefault?.();
+
+ await _converse.roster.sendContactAddIQ(
+ this.model.get('jid'),
+ this.model.getFullname(),
+ []
+ );
+ this.model.authorize().subscribe();
+ }
+
+ async declineRequest (ev) {
+ if (ev && ev.preventDefault) { ev.preventDefault(); }
+ const result = await api.confirm(__("Are you sure you want to decline this contact request?"));
+ if (result) {
+ this.model.unauthorize().destroy();
+ }
+ return this;
+ }
+}
+
+api.elements.define('converse-roster-contact', RosterContact);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js
new file mode 100644
index 0000000..55f5208
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js
@@ -0,0 +1,92 @@
+import debounce from "lodash-es/debounce";
+import tplRosterFilter from "./templates/roster_filter.js";
+import { CustomElement } from 'shared/components/element.js';
+import { _converse, api } from "@converse/headless/core";
+import { ancestor } from 'utils/html.js';
+
+
+export class RosterFilterView extends CustomElement {
+
+ async initialize () {
+ await api.waitUntil('rosterInitialized')
+ this.model = _converse.roster_filter;
+
+ this.liveFilter = debounce(() => {
+ this.model.save({'filter_text': this.querySelector('.roster-filter').value});
+ }, 250);
+
+ this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate());
+ this.listenTo(_converse.presences, 'change:show', () => this.requestUpdate());
+ this.listenTo(_converse.roster, "add", () => this.requestUpdate());
+ this.listenTo(_converse.roster, "destroy", () => this.requestUpdate());
+ this.listenTo(_converse.roster, "remove", () => this.requestUpdate());
+ this.listenTo(this.model, 'change', this.dispatchUpdateEvent);
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+
+ this.requestUpdate();
+ }
+
+ render () {
+ return this.model ?
+ tplRosterFilter(
+ Object.assign(this.model.toJSON(), {
+ visible: this.shouldBeVisible(),
+ changeChatStateFilter: ev => this.changeChatStateFilter(ev),
+ changeTypeFilter: ev => this.changeTypeFilter(ev),
+ clearFilter: ev => this.clearFilter(ev),
+ liveFilter: ev => this.liveFilter(ev),
+ submitFilter: ev => this.submitFilter(ev),
+ })) : '';
+ }
+
+ dispatchUpdateEvent () {
+ this.dispatchEvent(new CustomEvent('update', { 'detail': this.model.changed }));
+ }
+
+ changeChatStateFilter (ev) {
+ ev && ev.preventDefault();
+ this.model.save({'chat_state': this.querySelector('.state-type').value});
+ }
+
+ changeTypeFilter (ev) {
+ ev && ev.preventDefault();
+ const type = ancestor(ev.target, 'converse-icon')?.dataset.type || 'contacts';
+ if (type === 'state') {
+ this.model.save({
+ 'filter_type': type,
+ 'chat_state': this.querySelector('.state-type').value
+ });
+ } else {
+ this.model.save({
+ 'filter_type': type,
+ 'filter_text': this.querySelector('.roster-filter').value
+ });
+ }
+ }
+
+ submitFilter (ev) {
+ ev && ev.preventDefault();
+ this.liveFilter();
+ }
+
+ /**
+ * Returns true if the filter is enabled (i.e. if the user
+ * has added values to the filter).
+ * @private
+ * @method _converse.RosterFilterView#isActive
+ */
+ isActive () {
+ return (this.model.get('filter_type') === 'state' || this.model.get('filter_text'));
+ }
+
+ shouldBeVisible () {
+ return _converse.roster?.length >= 5 || this.isActive();
+ }
+
+ clearFilter (ev) {
+ ev && ev.preventDefault();
+ this.model.save({'filter_text': ''});
+ }
+}
+
+api.elements.define('converse-roster-filter', RosterFilterView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js
new file mode 100644
index 0000000..53c5f82
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js
@@ -0,0 +1,46 @@
+/**
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import "../modal";
+import "@converse/headless/plugins/chatboxes/index.js";
+import "@converse/headless/plugins/roster/index.js";
+import "./modals/add-contact.js";
+import './rosterview.js';
+import RosterContactView from './contactview.js';
+import { RosterFilter } from '@converse/headless/plugins/roster/filter.js';
+import { RosterFilterView } from './filterview.js';
+import { _converse, api, converse } from "@converse/headless/core";
+import { highlightRosterItem } from './utils.js';
+
+import 'shared/styles/status.scss';
+import './styles/roster.scss';
+
+
+converse.plugins.add('converse-rosterview', {
+
+ dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"],
+
+ initialize () {
+ api.settings.extend({
+ 'autocomplete_add_contact': true,
+ 'allow_contact_removal': true,
+ 'hide_offline_users': false,
+ 'roster_groups': true,
+ 'xhr_user_search_url': null,
+ });
+ api.promises.add('rosterViewInitialized');
+
+ _converse.RosterFilter = RosterFilter;
+ _converse.RosterFilterView = RosterFilterView;
+ _converse.RosterContactView = RosterContactView;
+
+ /* -------- Event Handlers ----------- */
+ api.listen.on('chatBoxesInitialized', () => {
+ _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox));
+ _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox));
+ });
+
+ api.listen.on('afterTearDown', () => _converse.rotergroups?.off().reset());
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js
new file mode 100644
index 0000000..dcfff99
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js
@@ -0,0 +1,155 @@
+import 'shared/autocomplete/index.js';
+import BaseModal from "plugins/modal/modal.js";
+import api from '@converse/headless/shared/api';
+import compact from 'lodash-es/compact';
+import debounce from 'lodash-es/debounce';
+import tplAddContactModal from "./templates/add-contact.js";
+import { Strophe } from 'strophe.js/src/core.js';
+import { __ } from 'i18n';
+import { _converse } from "@converse/headless/core";
+import { addClass, removeClass } from 'utils/html.js';
+
+export default class AddContactModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render());
+ this.render();
+ this.addEventListener('shown.bs.modal', () => this.querySelector('input[name="jid"]')?.focus(), false);
+ }
+
+ renderModal () {
+ return tplAddContactModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Add a Contact');
+ }
+
+ afterRender () {
+ if (typeof api.settings.get('xhr_user_search_url') === 'string') {
+ this.initXHRAutoComplete();
+ } else {
+ this.initJIDAutoComplete();
+ }
+ }
+
+ initJIDAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return;
+ }
+ const el = this.querySelector('.suggestion-box__jid').parentElement;
+ this.jid_auto_complete = new _converse.AutoComplete(el, {
+ 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
+ });
+ }
+
+ initGroupAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return;
+ }
+ const el = this.querySelector('.suggestion-box__jid').parentElement;
+ this.jid_auto_complete = new _converse.AutoComplete(el, {
+ 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
+ });
+ }
+
+ initXHRAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return this.initXHRFetch();
+ }
+ const el = this.querySelector('.suggestion-box__name').parentElement;
+ this.name_auto_complete = new _converse.AutoComplete(el, {
+ 'auto_evaluate': false,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': []
+ });
+ const xhr = new window.XMLHttpRequest();
+ // `open` must be called after `onload` for mock/testing purposes.
+ xhr.onload = () => {
+ if (xhr.responseText) {
+ const r = xhr.responseText;
+ this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+ this.name_auto_complete.auto_completing = true;
+ this.name_auto_complete.evaluate();
+ }
+ };
+ const input_el = this.querySelector('input[name="name"]');
+ input_el.addEventListener('input', debounce(() => {
+ xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
+ xhr.send()
+ } , 300));
+ this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
+ this.querySelector('input[name="name"]').value = ev.text.label;
+ this.querySelector('input[name="jid"]').value = ev.text.value;
+ });
+ }
+
+ initXHRFetch () {
+ this.xhr = new window.XMLHttpRequest();
+ this.xhr.onload = () => {
+ if (this.xhr.responseText) {
+ const r = this.xhr.responseText;
+ const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+ if (list.length !== 1) {
+ const el = this.querySelector('.invalid-feedback');
+ el.textContent = __('Sorry, could not find a contact with that name')
+ addClass('d-block', el);
+ return;
+ }
+ const jid = list[0].value;
+ if (this.validateSubmission(jid)) {
+ const form = this.querySelector('form');
+ const name = list[0].label;
+ this.afterSubmission(form, jid, name);
+ }
+ }
+ };
+ }
+
+ validateSubmission (jid) {
+ const el = this.querySelector('.invalid-feedback');
+ if (!jid || compact(jid.split('@')).length < 2) {
+ addClass('is-invalid', this.querySelector('input[name="jid"]'));
+ addClass('d-block', el);
+ return false;
+ } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
+ el.textContent = __('This contact has already been added')
+ addClass('d-block', el);
+ return false;
+ }
+ removeClass('d-block', el);
+ return true;
+ }
+
+ afterSubmission (_form, jid, name, group) {
+ if (group && !Array.isArray(group)) {
+ group = [group];
+ }
+ _converse.roster.addAndSubscribe(jid, name, group);
+ this.model.clear();
+ this.modal.hide();
+ }
+
+ addContactFromForm (ev) {
+ ev.preventDefault();
+ const data = new FormData(ev.target);
+ const jid = (data.get('jid') || '').trim();
+
+ if (!jid && typeof api.settings.get('xhr_user_search_url') === 'string') {
+ const input_el = this.querySelector('input[name="name"]');
+ this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
+ this.xhr.send()
+ return;
+ }
+ if (this.validateSubmission(jid)) {
+ this.afterSubmission(ev.target, jid, data.get('name'), data.get('group'));
+ }
+ }
+}
+
+api.elements.define('converse-add-contact-modal', AddContactModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js
new file mode 100644
index 0000000..f18dc14
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js
@@ -0,0 +1,48 @@
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core.js';
+import { getGroupsAutoCompleteList } from '@converse/headless/plugins/roster/utils.js';
+import { html } from "lit";
+
+
+export default (el) => {
+ const i18n_add = __('Add');
+ const i18n_contact_placeholder = __('name@example.org');
+ const i18n_error_message = __('Please enter a valid XMPP address');
+ const i18n_group = __('Group');
+ const i18n_nickname = __('Name');
+ const i18n_xmpp_address = __('XMPP Address');
+
+ return html`
+ <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
+ <div class="modal-body">
+ <span class="modal-alert"></span>
+ <div class="form-group add-xmpp-contact__jid">
+ <label class="clearfix" for="jid">${i18n_xmpp_address}:</label>
+ <div class="suggestion-box suggestion-box__jid">
+ <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul>
+ <input type="text" name="jid" ?required=${(!api.settings.get('xhr_user_search_url'))}
+ value="${el.model.get('jid') || ''}"
+ class="form-control suggestion-box__input"
+ placeholder="${i18n_contact_placeholder}"/>
+ <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+ </div>
+ </div>
+
+ <div class="form-group add-xmpp-contact__name">
+ <label class="clearfix" for="name">${i18n_nickname}:</label>
+ <div class="suggestion-box suggestion-box__name">
+ <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+ <input type="text" name="name" value="${el.model.get('nickname') || ''}"
+ class="form-control suggestion-box__input"/>
+ <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+ </div>
+ </div>
+ <div class="form-group add-xmpp-contact__group">
+ <label class="clearfix" for="name">${i18n_group}:</label>
+ <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete>
+ </div>
+ <div class="form-group"><div class="invalid-feedback">${i18n_error_message}</div></div>
+ <button type="submit" class="btn btn-primary">${i18n_add}</button>
+ </div>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js
new file mode 100644
index 0000000..f79da2b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js
@@ -0,0 +1,75 @@
+import tplRoster from "./templates/roster.js";
+import { CustomElement } from 'shared/components/element.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api } from "@converse/headless/core";
+import { initStorage } from '@converse/headless/utils/storage.js';
+import { slideIn, slideOut } from 'utils/html.js';
+
+
+/**
+ * @class
+ * @namespace _converse.RosterView
+ * @memberOf _converse
+ */
+export default class RosterView extends CustomElement {
+
+ async initialize () {
+ const id = `converse.contacts-panel${_converse.bare_jid}`;
+ this.model = new Model({ id });
+ initStorage(this.model, id);
+ this.model.fetch();
+
+ await api.waitUntil('rosterInitialized')
+
+ const { chatboxes, presences, roster } = _converse;
+ this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate());
+ this.listenTo(presences, 'change:show', () => this.requestUpdate());
+ this.listenTo(chatboxes, 'change:hidden', () => this.requestUpdate());
+ this.listenTo(roster, 'add', () => this.requestUpdate());
+ this.listenTo(roster, 'destroy', () => this.requestUpdate());
+ this.listenTo(roster, 'remove', () => this.requestUpdate());
+ this.listenTo(roster, 'change', () => this.requestUpdate());
+ this.listenTo(roster.state, 'change', () => this.requestUpdate());
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+ /**
+ * Triggered once the _converse.RosterView instance has been created and initialized.
+ * @event _converse#rosterViewInitialized
+ * @example _converse.api.listen.on('rosterViewInitialized', () => { ... });
+ */
+ api.trigger('rosterViewInitialized');
+ }
+
+ render () {
+ return tplRoster(this);
+ }
+
+ showAddContactModal (ev) { // eslint-disable-line class-methods-use-this
+ api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev);
+ }
+
+ async syncContacts (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ const { roster } = _converse;
+ this.syncing_contacts = true;
+ this.requestUpdate();
+
+ roster.data.save('version', null);
+ await roster.fetchFromServer();
+ api.user.presence.send();
+
+ this.syncing_contacts = false;
+ this.requestUpdate();
+ }
+
+ toggleRoster (ev) {
+ ev?.preventDefault?.();
+ const list_el = this.querySelector('.list-container.roster-contacts');
+ if (this.model.get('toggle_state') === _converse.CLOSED) {
+ slideOut(list_el).then(() => this.model.save({'toggle_state': _converse.OPENED}));
+ } else {
+ slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED}));
+ }
+ }
+}
+
+api.elements.define('converse-roster', RosterView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss
new file mode 100644
index 0000000..207daff
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss
@@ -0,0 +1,191 @@
+.conversejs {
+
+ #controlbox {
+ .open-contacts-toggle, .open-contacts-toggle .fa {
+ color: var(--chat-color) !important;
+ &:hover {
+ color: var(--chat-color) !important;
+ }
+ }
+
+ .open-contacts-toggle {
+ white-space: nowrap;
+ }
+
+ }
+
+ #converse-roster {
+ text-align: left;
+ width: 100%;
+ position: relative;
+ margin: 0;
+ height: var(--roster-height);
+ padding: 0;
+ overflow: hidden;
+ // XXX: FIXME
+ height: calc(100% - 70px);
+
+
+ /* Custom addition for CSP */
+ #online-count {
+ display: none;
+ }
+
+ .search-xmpp {
+ ul {
+ li.chat-info {
+ padding-left: 10px;
+ }
+ }
+ }
+
+ .roster-filter-form {
+ width: 100%;
+
+ .button-group {
+ padding: 0.2em;
+ }
+
+ converse-icon {
+ padding: 0.25em;
+ }
+
+ .roster-filter {
+ width: 100%;
+ margin: 0.2em;
+ font-size: calc(var(--font-size) - 2px);
+ }
+
+ .state-type {
+ font-size: calc(var(--font-size) - 2px);
+ width: 100%;
+ }
+ }
+
+ .roster-contacts {
+ padding: 0;
+ margin: 0 0 0.2em 0;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+ color: var(--text-color);
+
+ .roster-group-contacts {
+ .list-item {
+ &:hover {
+ .list-item-action {
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ converse-roster-contact {
+ width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-action {
+ line-height: 2em;
+ }
+
+ &:hover {
+ .list-item-action {
+ opacity: 1;
+ }
+ }
+ }
+
+ .group-toggle {
+ font-family: var(--heading-font);
+ display: block;
+ width: 100%;
+ margin: 0.75em 0 0.25em 0;
+ }
+
+ .group-toggle, .group-toggle .fa {
+ color: var(--chat-head-color-dark) !important;
+ &:hover {
+ color: var(--chat-head-color-darker) !important;
+ }
+ }
+
+ .current-xmpp-contact {
+ margin: 0.25em 0;
+ }
+
+ .list-item {
+ &.requesting-xmpp-contact {
+ a {
+ line-height: var(--line-height);
+ }
+ .req-contact-name {
+ padding: 0 0.2em 0 0;
+ }
+ }
+
+ .open-chat {
+ margin: 0;
+ padding: 0;
+ &.unread-msgs {
+ font-weight: bold;
+ color: var(--unread-msgs-color);
+ .contact-name {
+ width: 70%;
+ }
+ }
+
+ .msgs-indicator {
+ color: var(--text-color-invert);
+ background-color: var(--chat-color);
+ opacity: 1;
+ border-radius: 10%;
+ padding: 0.2em 0.4em;
+ font-size: var(--font-size-small);
+ margin-right: 0;
+ }
+
+ .contact-name {
+ padding: 0;
+ margin: 0;
+ max-width: 85%;
+ float: none;
+ height: 100%;
+ &.unread-msgs {
+ max-width: 60%;
+ }
+ &.contact-name--offline {
+ margin-left: 0.25em;
+ }
+ }
+ }
+ &.odd {
+ background-color: #DCEAC5;
+ /* Make this difference */
+ }
+ a, span {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ .span {
+ display: inline-block;
+ }
+ .decline-xmpp-request {
+ margin-left: 5px;
+ }
+ &:hover {
+ background-color: var(--controlbox-pane-bg-hover-color);
+ }
+ }
+ }
+ span {
+ &.pending-contact-name {
+ line-height: var(--line-height);
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js
new file mode 100644
index 0000000..562bfe6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js
@@ -0,0 +1,63 @@
+import 'shared/components/icons.js';
+import { __ } from 'i18n';
+import { _converse, converse } from "@converse/headless/core";
+import { html } from "lit";
+import { isUniView } from '@converse/headless/utils/core.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { toggleGroup } from '../utils.js';
+
+const { u } = converse.env;
+
+
+function renderContact (contact) {
+ const jid = contact.get('jid');
+ const extra_classes = [];
+ if (isUniView()) {
+ const chatbox = _converse.chatboxes.get(jid);
+ if (chatbox && !chatbox.get('hidden')) {
+ extra_classes.push('open');
+ }
+ }
+ const ask = contact.get('ask');
+ const requesting = contact.get('requesting');
+ const subscription = contact.get('subscription');
+ if ((ask === 'subscribe') || (subscription === 'from')) {
+ /* ask === 'subscribe'
+ * Means we have asked to subscribe to them.
+ *
+ * subscription === 'from'
+ * They are subscribed to us, but not vice versa.
+ * We assume that there is a pending subscription
+ * from us to them (otherwise we're in a state not
+ * supported by converse.js).
+ *
+ * So in both cases the user is a "pending" contact.
+ */
+ extra_classes.push('pending-xmpp-contact');
+ } else if (requesting === true) {
+ extra_classes.push('requesting-xmpp-contact');
+ } else if (subscription === 'both' || subscription === 'to' || u.isSameBareJID(jid, _converse.connection.jid)) {
+ extra_classes.push('current-xmpp-contact');
+ extra_classes.push(subscription);
+ extra_classes.push(contact.presence.get('show'));
+ }
+ return html`
+ <li class="list-item d-flex controlbox-padded ${extra_classes.join(' ')}" data-status="${contact.presence.get('show')}">
+ <converse-roster-contact .model=${contact}></converse-roster-contact>
+ </li>`;
+}
+
+
+export default (o) => {
+ const i18n_title = __('Click to hide these contacts');
+ const collapsed = _converse.roster.state.get('collapsed_groups');
+ return html`
+ <div class="roster-group" data-group="${o.name}">
+ <a href="#" class="list-toggle group-toggle controlbox-padded" title="${i18n_title}" @click=${ev => toggleGroup(ev, o.name)}>
+ <converse-icon color="var(--chat-head-color-dark)" size="1em" class="fa ${ (collapsed.includes(o.name)) ? 'fa-caret-right' : 'fa-caret-down' }"></converse-icon> ${o.name}
+ </a>
+ <ul class="items-list roster-group-contacts ${ (collapsed.includes(o.name)) ? 'collapsed' : '' }" data-group="${o.name}">
+ ${ repeat(o.contacts, (c) => c.get('jid'), renderContact) }
+ </ul>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js
new file mode 100644
index 0000000..513fb46
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js
@@ -0,0 +1,19 @@
+import { html } from "lit";
+
+export default (o) => html`
+ <a class="open-chat w-100" href="#" @click=${o.openChat}>
+ <span class="req-contact-name w-100" title="JID: ${o.jid}">${o.display_name}</span>
+ </a>
+ <a class="accept-xmpp-request list-item-action list-item-action--visible"
+ @click=${o.acceptRequest}
+ aria-label="${o.desc_accept}" title="${o.desc_accept}" href="#">
+
+ <converse-icon class="fa fa-check" size="1em"></converse-icon>
+ </a>
+
+ <a class="decline-xmpp-request list-item-action list-item-action--visible"
+ @click=${o.declineRequest}
+ aria-label="${o.desc_decline}" title="${o.desc_decline}" href="#">
+
+ <converse-icon class="fa fa-times" size="1em"></converse-icon>
+ </a>`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js
new file mode 100644
index 0000000..d347f2a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js
@@ -0,0 +1,57 @@
+import tplGroup from "./group.js";
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+import { contactsComparator, groupsComparator } from '@converse/headless/plugins/roster/utils.js';
+import { html } from "lit";
+import { repeat } from 'lit/directives/repeat.js';
+import { shouldShowContact, shouldShowGroup, populateContactsMap } from '../utils.js';
+
+
+export default (el) => {
+ const i18n_heading_contacts = __('Contacts');
+ const i18n_toggle_contacts = __('Click to toggle contacts');
+ const i18n_title_add_contact = __('Add a contact');
+ const i18n_title_sync_contacts = __('Re-sync your contacts');
+ const roster = _converse.roster || [];
+ const contacts_map = roster.reduce((acc, contact) => populateContactsMap(acc, contact), {});
+ const groupnames = Object.keys(contacts_map).filter(shouldShowGroup);
+ const is_closed = el.model.get('toggle_state') === _converse.CLOSED;
+ groupnames.sort(groupsComparator);
+
+ return html`
+ <div class="d-flex controlbox-padded">
+ <span class="w-100 controlbox-heading controlbox-heading--contacts">
+ <a class="list-toggle open-contacts-toggle" title="${i18n_toggle_contacts}" @click=${el.toggleRoster}>
+ <converse-icon
+ class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }"
+ size="1em"
+ color="var(--chat-color)"></converse-icon>
+ ${i18n_heading_contacts}
+ </a>
+ </span>
+ <a class="controlbox-heading__btn sync-contacts"
+ @click=${ev => el.syncContacts(ev)}
+ title="${i18n_title_sync_contacts}">
+
+ <converse-icon class="fa fa-sync right ${el.syncing_contacts ? 'fa-spin' : ''}" size="1em"></converse-icon>
+ </a>
+ ${ api.settings.get('allow_contact_requests') ? html`
+ <a class="controlbox-heading__btn add-contact"
+ @click=${ev => el.showAddContactModal(ev)}
+ title="${i18n_title_add_contact}"
+ data-toggle="modal"
+ data-target="#add-contact-modal">
+ <converse-icon class="fa fa-user-plus right" size="1.25em"></converse-icon>
+ </a>` : '' }
+ </div>
+
+ <div class="list-container roster-contacts ${ is_closed ? 'hidden' : '' }">
+ <converse-roster-filter @update=${() => el.requestUpdate()}></converse-roster-filter>
+ ${ repeat(groupnames, (n) => n, (name) => {
+ const contacts = contacts_map[name].filter(c => shouldShowContact(c, name));
+ contacts.sort(contactsComparator);
+ return contacts.length ? tplGroup({ contacts, name }) : '';
+ }) }
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js
new file mode 100644
index 0000000..44e7ce8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js
@@ -0,0 +1,50 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (o) => {
+ const i18n_placeholder = __('Filter');
+ const title_contact_filter = __('Filter by contact name');
+ const title_group_filter = __('Filter by group name');
+ const title_status_filter = __('Filter by status');
+ const label_any = __('Any');
+ const label_unread_messages = __('Unread');
+ const label_online = __('Online');
+ const label_chatty = __('Chatty');
+ const label_busy = __('Busy');
+ const label_away = __('Away');
+ const label_xa = __('Extended Away');
+ const label_offline = __('Offline');
+
+ return html`
+ <form class="controlbox-padded roster-filter-form input-button-group ${ (!o.visible) ? 'hidden' : 'fade-in' }"
+ @submit=${o.submitFilter}>
+ <div class="form-inline flex-nowrap">
+ <div class="filter-by d-flex flex-nowrap">
+ <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-user clickable ${ (o.filter_type === 'contacts') ? 'selected' : '' }" data-type="contacts" title="${title_contact_filter}"></converse-icon>
+ <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-users clickable ${ (o.filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"></converse-icon>
+ <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-circle clickable ${ (o.filter_type === 'state') ? 'selected' : '' }" data-type="state" title="${title_status_filter}"></converse-icon>
+ </div>
+ <div class="btn-group">
+ <input .value="${o.filter_text || ''}"
+ @keydown=${o.liveFilter}
+ class="roster-filter form-control ${ (o.filter_type === 'state') ? 'hidden' : '' }"
+ placeholder="${i18n_placeholder}"/>
+ <converse-icon size="1em" class="fa fa-times clear-input ${ (!o.filter_text || o.filter_type === 'state') ? 'hidden' : '' }"
+ @click=${o.clearFilter}>
+ </converse-icon>
+ </div>
+ <select class="form-control state-type ${ (o.filter_type !== 'state') ? 'hidden' : '' }"
+ @change=${o.changeChatStateFilter}>
+ <option value="">${label_any}</option>
+ <option ?selected=${o.chat_state === 'unread_messages'} value="unread_messages">${label_unread_messages}</option>
+ <option ?selected=${o.chat_state === 'online'} value="online">${label_online}</option>
+ <option ?selected=${o.chat_state === 'chat'} value="chat">${label_chatty}</option>
+ <option ?selected=${o.chat_state === 'dnd'} value="dnd">${label_busy}</option>
+ <option ?selected=${o.chat_state === 'away'} value="away">${label_away}</option>
+ <option ?selected=${o.chat_state === 'xa'} value="xa">${label_xa}</option>
+ <option ?selected=${o.chat_state === 'offline'} value="offline">${label_offline}</option>
+ </select>
+ </div>
+ </form>`
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js
new file mode 100644
index 0000000..86b6b94
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js
@@ -0,0 +1,50 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core.js";
+import { html } from "lit";
+import { STATUSES } from '../constants.js';
+
+const tplRemoveLink = (el, item) => {
+ const display_name = item.getDisplayName();
+ const i18n_remove = __('Click to remove %1$s as a contact', display_name);
+ return html`
+ <a class="list-item-action remove-xmpp-contact" @click=${el.removeContact} title="${i18n_remove}" href="#">
+ <converse-icon class="fa fa-trash-alt" size="1.5em"></converse-icon>
+ </a>
+ `;
+}
+
+export default (el, item) => {
+ const show = item.presence.get('show') || 'offline';
+ let classes, color;
+ if (show === 'online') {
+ [classes, color] = ['fa fa-circle', 'chat-status-online'];
+ } else if (show === 'dnd') {
+ [classes, color] = ['fa fa-minus-circle', 'chat-status-busy'];
+ } else if (show === 'away') {
+ [classes, color] = ['fa fa-circle', 'chat-status-away'];
+ } else {
+ [classes, color] = ['fa fa-circle', 'subdued-color'];
+ }
+ const desc_status = STATUSES[show];
+ const num_unread = item.get('num_unread') || 0;
+ const display_name = item.getDisplayName();
+ const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, el.model.get('jid'));
+ return html`
+ <a class="list-item-link cbox-list-item open-chat ${ num_unread ? 'unread-msgs' : '' }" title="${i18n_chat}" href="#" @click=${el.openChat}>
+ <span>
+ <converse-avatar
+ class="avatar"
+ .data=${el.model.vcard?.attributes}
+ nonce=${el.model.vcard?.get('vcard_updated')}
+ height="30" width="30"></converse-avatar>
+ <converse-icon
+ title="${desc_status}"
+ color="var(--${color})"
+ size="1em"
+ class="${classes} chat-status chat-status--avatar"></converse-icon>
+ </span>
+ ${ num_unread ? html`<span class="msgs-indicator">${ num_unread }</span>` : '' }
+ <span class="contact-name contact-name--${el.show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span>
+ </a>
+ ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el, item) : '' }`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
new file mode 100644
index 0000000..a809cd7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
@@ -0,0 +1,195 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+
+describe("The 'Add Contact' widget", function () {
+
+ it("opens up an add modal when you click on it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+
+ const input_jid = modal.querySelector('input[name="jid"]');
+ const input_name = modal.querySelector('input[name="name"]');
+ input_jid.value = 'someone@';
+
+ const evt = new Event('input');
+ input_jid.dispatchEvent(evt);
+ expect(modal.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
+ input_jid.value = 'someone@montague.lit';
+ input_name.value = 'Someone';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
+ `</iq>`);
+ }));
+
+ it("can be configured to not provide search suggestions",
+ mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+ const input_jid = modal.querySelector('input[name="jid"]');
+ input_jid.value = 'someone@montague.lit';
+ modal.querySelector('button[type="submit"]').click();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
+ );
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+ `</iq>`
+ );
+ }));
+
+ it("integrates with xhr_user_search_url to search for contacts",
+ mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ this.onload();
+ }
+ }
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ // We only have autocomplete for the name input
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'marty';
+ input_el.dispatchEvent(new Event('input'));
+ await u.waitUntil(() => modal.querySelector('.suggestion-box li'), 1000);
+ expect(modal.querySelectorAll('.suggestion-box li').length).toBe(1);
+ const suggestion = modal.querySelector('.suggestion-box li');
+ expect(suggestion.textContent).toBe('Marty McFly');
+
+ // Mock selection
+ modal.name_auto_complete.select(suggestion);
+
+ expect(input_el.value).toBe('Marty McFly');
+ expect(modal.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+
+ it("can be configured to not provide search suggestions for XHR search results",
+ mock.initConverse([],
+ { 'autocomplete_add_contact': false,
+ 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ const value = modal.querySelector('input[name="name"]').value;
+ if (value === 'existing') {
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
+ } else if (value === 'romeo') {
+ this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
+ } else if (value === 'ambiguous') {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ } else if (value === 'insufficient') {
+ this.responseText = JSON.stringify([]);
+ } else {
+ this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
+ }
+ this.onload();
+ }
+ }
+
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'ambiguous';
+ modal.querySelector('button[type="submit"]').click();
+ let feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'insufficient';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'existing';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('This contact has already been added');
+
+ input_el.value = 'Marty McFly';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
new file mode 100644
index 0000000..2ef07a0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
@@ -0,0 +1,54 @@
+/*global mock, converse */
+
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A sent presence stanza", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("includes the saved status message",
+ mock.initConverse([], {}, async (_converse) => {
+
+ const { u, Strophe } = converse.env;
+ mock.openControlBox(_converse);
+ spyOn(_converse.connection, 'send').and.callThrough();
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ const change_status_el = await u.waitUntil(() => cbview.querySelector('.change-status'));
+ change_status_el.click()
+ let modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const msg = 'My custom status';
+ modal.querySelector('input[name="status_message"]').value = msg;
+ modal.querySelector('[type="submit"]').click();
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+ expect(Strophe.serialize(sent_presence))
+ .toBe(`<presence xmlns="jabber:client">`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "true");
+ await u.waitUntil(() => !u.isVisible(modal));
+
+ cbview.querySelector('.change-status').click()
+ modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "false", 1000);
+ modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+ modal.querySelector('[type="submit"]').click();
+
+ await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
+ sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
+ expect(Strophe.serialize(sent_presence))
+ .toBe(
+ `<presence xmlns="jabber:client">`+
+ `<show>dnd</show>`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
new file mode 100644
index 0000000..01439c5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
@@ -0,0 +1,537 @@
+/*global mock, converse */
+
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+const { Strophe, stx } = converse.env;
+
+describe("The Protocol", function () {
+
+ beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+ describe("Integration of Roster Items and Presence Subscriptions", function () {
+ /* Some level of integration between roster items and presence
+ * subscriptions is normally expected by an instant messaging user
+ * regarding the user's subscriptions to and from other contacts. This
+ * section describes the level of integration that MUST be supported
+ * within an XMPP instant messaging applications.
+ *
+ * There are four primary subscription states:
+ *
+ * None -- the user does not have a subscription to the contact's
+ * presence information, and the contact does not have a subscription
+ * to the user's presence information
+ * To -- the user has a subscription to the contact's presence
+ * information, but the contact does not have a subscription to the
+ * user's presence information
+ * From -- the contact has a subscription to the user's presence
+ * information, but the user does not have a subscription to the
+ * contact's presence information
+ * Both -- both the user and the contact have subscriptions to each
+ * other's presence information (i.e., the union of 'from' and 'to')
+ *
+ * Each of these states is reflected in the roster of both the user and
+ * the contact, thus resulting in durable subscription states.
+ *
+ * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
+ * included, their values SHOULD be the full JID of the resource for
+ * that session. A client MUST acknowledge each roster push with an IQ
+ * stanza of type "result".
+ */
+ it("Subscribe to contact, contact accepts and subscribes back",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, $pres, sizzle, Strophe } = converse.env;
+ let stanza;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300);
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+
+ spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
+ spyOn(_converse.roster, "addContactToRoster").and.callThrough();
+ spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
+ spyOn(_converse.api.vcard, "get").and.callThrough();
+
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ modal.delegateEvents();
+
+ // Fill in the form and submit
+ const form = modal.querySelector('form.add-xmpp-contact');
+ form.querySelector('input[name="jid"]').value = 'contact@example.org';
+ form.querySelector('input[name="name"]').value = 'Chris Contact';
+ form.querySelector('input[name="group"]').value = 'My Buddies';
+ form.querySelector('[type="submit"]').click();
+
+ /* In preparation for being able to render the contact in the
+ * user's client interface and for the server to keep track of the
+ * subscription, the user's client SHOULD perform a "roster set"
+ * for the new roster item.
+ */
+ expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+ expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
+
+ /* The request consists of sending an IQ
+ * stanza of type='set' containing a <query/> element qualified by
+ * the 'jabber:iq:roster' namespace, which in turn contains an
+ * <item/> element that defines the new roster item; the <item/>
+ * element MUST possess a 'jid' attribute, MAY possess a 'name'
+ * attribute, MUST NOT possess a 'subscription' attribute, and MAY
+ * contain one or more <group/> child elements:
+ *
+ * <iq type='set' id='set1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ await mock.waitForRoster(_converse, 'all', 0);
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const roster_set_stanza = IQ_stanzas.filter(s => sizzle('query[xmlns="jabber:iq:roster"]', s)).pop();
+
+ expect(Strophe.serialize(roster_set_stanza)).toBe(
+ `<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" name="Chris Contact">`+
+ `<group>My Buddies</group>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`
+ );
+
+ const sent_stanzas = [];
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+ sent_stanza = stanza;
+ sent_stanzas.push(stanza);
+ });
+
+ /* As a result, the user's server (1) MUST initiate a roster push
+ * for the new roster item to all available resources associated
+ * with the user that have requested the roster, setting the
+ * 'subscription' attribute to a value of "none"; and (2) MUST
+ * reply to the sending resource with an IQ result indicating the
+ * success of the roster set:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'result', 'id': roster_set_stanza.getAttribute('id')})
+ ));
+
+ await u.waitUntil(() => _converse.roster.length === 1);
+
+ // A contact should now have been created
+ const contact = _converse.roster.at(0);
+ expect(contact.get('jid')).toBe('contact@example.org');
+ expect(contact.get('nickname')).toBe('Chris Contact');
+ expect(contact.get('groups')).toEqual(['My Buddies']);
+ await u.waitUntil(() => contact.initialized);
+
+ /* To subscribe to the contact's presence information,
+ * the user's client MUST send a presence stanza of
+ * type='subscribe' to the contact:
+ *
+ * <presence to='contact@example.org' type='subscribe'/>
+ */
+ const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence')).pop());
+ expect(sent_presence).toEqualStanza(stx`
+ <presence to="contact@example.org" type="subscribe" xmlns="jabber:client">
+ <nick xmlns="http://jabber.org/protocol/nick">Romeo</nick>
+ <priority>0</priority>
+ <c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>
+ </presence>
+ `);
+
+ /* As a result, the user's server MUST initiate a second roster
+ * push to all of the user's available resources that have
+ * requested the roster, setting the contact to the pending
+ * sub-state of the 'none' subscription state; The pending
+ * sub-state is denoted by the inclusion of the ask='subscribe'
+ * attribute in the roster item:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * ask='subscribe'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set', 'from': _converse.bare_jid})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ const rosterview = document.querySelector('converse-roster');
+
+ // Check that the user is now properly shown as a pending contact in the roster.
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ const contacts = Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible);
+ return contacts.length;
+ }, 600);
+
+ let header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ let contacts = header.parentElement.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ expect(u.isVisible(contacts[0])).toBe(true);
+ sent_stanza = ""; // Reset
+
+ spyOn(contact, "ackSubscribe").and.callThrough();
+
+ /* Here we assume the "happy path" that the contact
+ * approves the subscription request
+ *
+ * <presence
+ * to='user@example.com'
+ * from='contact@example.org'
+ * type='subscribed'/>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribed'
+ })
+ ));
+
+ /* Upon receiving the presence stanza of type "subscribed",
+ * the user SHOULD acknowledge receipt of that
+ * subscription state notification by sending a presence
+ * stanza of type "subscribe".
+ */
+ expect(contact.ackSubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>`
+ );
+
+ /* The user's server MUST initiate a roster push to all of the user's
+ * available resources that have requested the roster,
+ * containing an updated roster item for the contact with
+ * the 'subscription' attribute set to a value of "to";
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='to'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ const IQ_id = _converse.connection.getUniqueId('roster');
+ stanza = $iq({'type': 'set', 'id': IQ_id})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'to',
+ 'name': 'Nicky'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Check that the IQ set was acknowledged.
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
+ );
+
+ // The contact should now be visible as an existing contact (but still offline).
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ return sizzle('li', header?.parentNode).filter(l => u.isVisible(l)).length;
+ }, 600);
+ header = sizzle('a:contains("My contacts")', rosterview);
+ expect(header.length).toBe(1);
+ expect(u.isVisible(header[0])).toBeTruthy();
+ contacts = header[0].parentNode.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ // Check that it has the right classes and text
+ expect(u.hasClass('to', contacts[0])).toBeTruthy();
+ expect(u.hasClass('both', contacts[0])).toBeFalsy();
+ expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
+
+ await u.waitUntil(() => contacts[0].textContent.trim() === 'Nicky');
+
+ expect(contact.presence.get('show')).toBe('offline');
+
+ /* <presence
+ * from='contact@example.org/resource'
+ * to='user@example.com/resource'/>
+ */
+ stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Now the contact should also be online.
+ expect(contact.presence.get('show')).toBe('online');
+
+ /* Section 8.3. Creating a Mutual Subscription
+ *
+ * If the contact wants to create a mutual subscription,
+ * the contact MUST send a subscription request to the
+ * user.
+ *
+ * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
+ */
+ spyOn(contact, 'authorize').and.callThrough();
+ spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org/resource',
+ 'type': 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
+
+ /* The user's client MUST send a presence stanza of type
+ * "subscribed" to the contact in order to approve the
+ * subscription request.
+ *
+ * <presence to='contact@example.org' type='subscribed'/>
+ */
+ expect(contact.authorize).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>`
+ );
+
+ /* As a result, the user's server MUST initiate a
+ * roster push containing a roster item for the
+ * contact with the 'subscription' attribute set to
+ * a value of "both".
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='both'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'both',
+ 'name': 'contact@example.org'})
+ ));
+
+ // The class on the contact will now have switched.
+ await u.waitUntil(() => !u.hasClass('to', contacts[0]));
+ expect(u.hasClass('both', contacts[0])).toBe(true);
+
+ }));
+
+ it("Alternate Flow: Contact Declines Subscription Request",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { $iq, $pres } = converse.env;
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ var contact, stanza, sent_stanza, sent_IQ;
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.openControlBox(_converse);
+ // Add a new roster contact via roster push
+ stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'contact@example.org'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // A pending contact should now exist.
+ contact = _converse.roster.get('contact@example.org');
+ expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
+ spyOn(contact, "ackUnsubscribe").and.callThrough();
+
+ spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+ spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq });
+ /* We now assume the contact declines the subscription
+ * requests.
+ *
+ * Upon receiving the presence stanza of type "unsubscribed"
+ * addressed to the user, the user's server (1) MUST deliver
+ * that presence stanza to the user and (2) MUST initiate a
+ * roster push to all of the user's available resources that
+ * have requested the roster, containing an updated roster
+ * item for the contact with the 'subscription' attribute
+ * set to a value of "none" and with no 'ask' attribute:
+ *
+ * <presence
+ * from='contact@example.org'
+ * to='user@example.com'
+ * type='unsubscribed'/>
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ // FIXME: also add the <iq>
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'unsubscribed'
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ /* Upon receiving the presence stanza of type "unsubscribed",
+ * the user SHOULD acknowledge receipt of that subscription
+ * state notification through either "affirming" it by
+ * sending a presence stanza of type "unsubscribe
+ */
+ expect(contact.ackUnsubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>`
+ );
+
+ /* _converse.js will then also automatically remove the
+ * contact from the user's roster.
+ */
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("Unsubscribe to a contact when subscription is mutual",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, sizzle, Strophe } = converse.env;
+ const jid = 'abram@montague.lit';
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ // We now have a contact we want to remove
+ expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
+
+ const rosterview = document.querySelector('converse-roster');
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ await u.waitUntil(() => header.parentElement.querySelectorAll('li').length);
+
+ // remove the first user
+ header.parentElement.querySelector('li .remove-xmpp-contact').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ /* Section 8.6 Removing a Roster Item and Cancelling All
+ * Subscriptions
+ *
+ * First the user is removed from the roster
+ * Because there may be many steps involved in completely
+ * removing a roster item and cancelling subscriptions in
+ * both directions, the roster management protocol includes
+ * a "shortcut" method for doing so. The process may be
+ * initiated no matter what the current subscription state
+ * is by sending a roster set containing an item for the
+ * contact with the 'subscription' attribute set to a value
+ * of "remove":
+ *
+ * <iq type='set' id='remove1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item jid='contact@example.org' subscription='remove'/>
+ * </query>
+ * </iq>
+ */
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="abram@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ const sent_iq = iq_stanzas.at(-1);
+
+ // Receive confirmation from the contact's server
+ // <iq type='result' id='remove1'/>
+ const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Our contact has now been removed
+ await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined");
+ }));
+
+ it("Receiving a subscription request", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const { u, $pres, sizzle, Strophe } = converse.env;
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ /* <presence
+ * from='user@example.com'
+ * to='contact@example.org'
+ * type='subscribe'/>
+ */
+ const stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribe'
+ }).c('nick', {
+ 'xmlns': Strophe.NS.NICK,
+ }).t('Clint Contact');
+
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ return Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible)?.length;
+ }, 500);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
+
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ expect(u.isVisible(header)).toBe(true);
+ const contacts = header.nextElementSibling.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
new file mode 100644
index 0000000..e52c833
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
@@ -0,0 +1,1365 @@
+/*global mock, converse, _ */
+
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+const checkHeaderToggling = async function (group) {
+ const toggle = group.querySelector('a.group-toggle');
+ expect(u.isVisible(group)).toBeTruthy();
+ expect(group.querySelectorAll('ul.collapsed').length).toBe(0);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+ toggle.click();
+
+ await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy();
+ toggle.click();
+ await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+};
+
+
+describe("The Contacts Roster", function () {
+
+ it("verifies the origin of roster pushes", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ // See: https://gultsch.de/gajim_roster_push_and_message_interception.html
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 1);
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+
+ spyOn(converse.env.log, 'warn');
+ let roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="remove" jid="${contact_jid}"/>
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(1);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" />
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(2);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+ }));
+
+ it("is populated once we have registered a presence handler", mock.initConverse([], {}, async function (_converse) {
+ const IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(
+ () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ const result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true);
+ }));
+
+ it("supports roster versioning", mock.initConverse([], {}, async function (_converse) {
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(
+ () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()
+ );
+ expect(_converse.roster.data.get('version')).toBeUndefined();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ let result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster',
+ 'ver': 'ver7'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ await u.waitUntil(() => _converse.roster.models.length > 1);
+ expect(_converse.roster.data.get('version')).toBe('ver7');
+ expect(_converse.roster.models.length).toBe(2);
+
+ _converse.roster.fetchFromServer();
+ stanza = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query ver="ver7" xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+
+ result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ });
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'});
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com');
+ }));
+
+ it("also contains contacts with subscription of none", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription: 'from'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'none'
+ }).c('group').t('Acquaintences')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 3);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net', 'lord.capulet@example.net']);
+ expect(_converse.roster.get('lord.capulet@example.net').get('subscription')).toBe('none');
+ }));
+
+ it("can be refreshed", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 2);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net']);
+
+ const rosterview = document.querySelector('converse-roster');
+ const sync_button = rosterview.querySelector('.sync-contacts');
+ sync_button.click();
+
+ stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'from'
+ }).c('group').t('Acquaintences')));
+
+ await u.waitUntil(() => _converse.roster.pluck('jid').includes('lord.capulet@example.net'));
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'lord.capulet@example.net']);
+ }));
+
+ it("will also show contacts added afterwards", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
+ filter.value = "la";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
+
+ // Five roster contact is now visible
+ const visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(4);
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(4);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+
+ _converse.roster.create({
+ jid: 'lad@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['newgroup'],
+ fullname: 'Lad'
+ });
+ await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300);
+ visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(5);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('newgroup');
+ expect(visible_groups[4].textContent.trim()).toBe('ænemies');
+ expect(roster.querySelectorAll('.roster-group').length).toBe(5);
+ }));
+
+ describe("The live filter", function () {
+
+ it("will only appear when roster contacts flow over the visible area",
+ mock.initConverse([], {}, async function (_converse) {
+
+ expect(document.querySelector('converse-roster')).toBe(null);
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const view = _converse.chatboxviews.get('controlbox');
+ const flyout = view.querySelector('.box-flyout');
+ const panel = flyout.querySelector('.controlbox-pane');
+ function hasScrollBar (el) {
+ return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
+ }
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const el = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
+ }));
+
+ it("can be used to filter the contacts shown",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ let filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "juliet";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600);
+ // Only one roster contact is now visible
+ let visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(1);
+ expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
+ // Only one foster group is still visible
+ expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
+ const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
+ expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences');
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "j";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
+
+ visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(2);
+
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(2);
+ expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600);
+ visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(0);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ }));
+
+ it("can be used to filter the groups shown", mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ const button = rosterview.querySelector('converse-icon[data-type="groups"]');
+ button.click();
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
+
+ let filter = rosterview.querySelector('.roster-filter');
+ filter.value = "colleagues";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600);
+ expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues');
+ expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6);
+ // Check that all contacts under the group are shown
+ expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (roster.querySelectorAll('.roster-group').length === 0), 700);
+
+ filter = rosterview.querySelector('.roster-filter');
+ filter.value = ""; // Check that groups are shown again, when the filter string is cleared.
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700);
+ expect(sizzle('div.roster-group', roster).length).toBe(0);
+ }));
+
+ it("has a button with which its contents can be cleared",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ filter.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ expect(_.includes(filter.classList, "x")).toBeFalsy();
+ expect(u.hasClass('hidden', rosterview.querySelector('.roster-filter-form .clear-input'))).toBeTruthy();
+
+ const isHidden = (el) => u.hasClass('hidden', el);
+ await u.waitUntil(() => !isHidden(rosterview.querySelector('.roster-filter-form .clear-input')), 900);
+ rosterview.querySelector('.clear-input').click();
+ await u.waitUntil(() => document.querySelector('.roster-filter').value == '');
+ }));
+
+ // Disabling for now, because since recently this test consistently
+ // fails on Travis and I couldn't get it to pass there.
+ xit("can be used to filter contacts by their chat state",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ mock.waitForRoster(_converse, 'all');
+ let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ const button = rosterview.querySelector('span[data-type="state"]');
+ button.click();
+ const roster = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900);
+ const filter = rosterview.querySelector('.state-type');
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "online";
+ u.triggerEvent(filter, 'change');
+
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
+ expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
+ await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900);
+ const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
+ expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
+ filter.value = "dnd";
+ u.triggerEvent(filter, 'change');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
+ }));
+ });
+
+ describe("A Roster Group", function () {
+
+ it("is created to show contacts with unread messages",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+
+ // Check that the groups appear alphabetically and that
+ // requesting and pending contacts are last.
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ let group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = await _converse.api.contacts.get(contact_jid);
+ contact.save({'num_unread': 5});
+
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 7);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+
+ expect(group_titles).toEqual([
+ "New messages",
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ const contacts = sizzle('.roster-group[data-group="New messages"] li', rosterview);
+ expect(contacts.length).toBe(1);
+ expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio");
+ expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5");
+
+ contact.save({'num_unread': 0});
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ }));
+
+
+ it("can be used to organize existing contacts",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ const group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+ // Check that usernames appear alphabetically per group
+ Object.keys(mock.groups).forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ });
+ }));
+
+ it("gets created when a contact's \"groups\" attribute changes",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ _converse.roster.create({
+ jid: 'groupchanger@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['firstgroup'],
+ fullname: 'George Groupchanger'
+ });
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 1);
+ let group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['firstgroup']);
+
+ const contact = _converse.roster.get('groupchanger@montague.lit');
+ contact.set({'groups': ['secondgroup']});
+ await u.waitUntil(() => sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview).length);
+ group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['secondgroup']);
+ }));
+
+ it("can share contacts with other roster groups",
+ mock.initConverse( [], {'roster_groups': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const groups = ['Colleagues', 'friends'];
+ await mock.openControlBox(_converse);
+ for (let i=0; i<mock.cur_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: groups,
+ fullname: mock.cur_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 30));
+ // Check that usernames appear alphabetically per group
+ groups.forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ expect(names.length).toEqual(mock.cur_names.length);
+ });
+ }));
+
+ it("remembers whether it is closed or opened",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+
+ let i=0, j=0;
+ const groups = {
+ 'Colleagues': 3,
+ 'friends & acquaintences': 3,
+ 'Ungrouped': 2
+ };
+ Object.keys(groups).forEach(function (name) {
+ j = i;
+ for (i=j; i<j+groups[name]; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: name === 'ungrouped'? [] : [name],
+ fullname: mock.cur_names[i]
+ });
+ }
+ });
+
+ const state = _converse.roster.state;
+ expect(state.get('collapsed_groups')).toEqual([]);
+ const rosterview = document.querySelector('converse-roster');
+ const toggle = await u.waitUntil(() => rosterview.querySelector('a.group-toggle'));
+ toggle.click();
+ await u.waitUntil(() => state.get('collapsed_groups').length);
+ expect(state.get('collapsed_groups')).toEqual(['Colleagues']);
+ toggle.click();
+ expect(state.get('collapsed_groups')).toEqual([]);
+ }));
+ });
+
+ describe("Pending Contacts", function () {
+
+ it("can be collapsed under their own header (if roster_groups is false)",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('[data-group="Pending contacts"]')]);
+ }));
+
+ it("can be added to the roster",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ _converse.roster.create({
+ jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[0]
+ });
+ expect(u.isVisible(rosterview)).toBe(true);
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length === 1);
+ }));
+
+ it("are shown in the roster when hide_offline_users",
+ mock.initConverse(
+ [], {'hide_offline_users': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'pending');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500)
+ expect(u.isVisible(rosterview)).toBe(true);
+ expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(3);
+ expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(1);
+ }));
+
+ it("can be removed by the user", mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const name = mock.pend_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ await u.waitUntil(() => !sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ ['VCardsInitialized'], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.pend_names[0];
+ _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const el = rosterview.querySelector(`ul[data-group="Pending contacts"]`);
+ return u.isVisible(el) && Array.from(el.querySelectorAll('li')).filter(li => u.isVisible(li)).length;
+ }, 700)
+
+ const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop());
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ remove_el.click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+
+ const iq = iq_stanzas.at(-1);
+ const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname'))
+ const rosterview = document.querySelector('converse-roster');
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ for (let i=0; i<mock.pend_names.length; i++) {
+ const name = mock.pend_names[i];
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ }
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ let i;
+ for (i=0; i<mock.pend_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview.querySelector(`ul[data-group="Pending contacts"]`)).filter(u.isVisible).length);
+ // Check that they are sorted alphabetically
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`));
+ const spans = el.querySelectorAll('.pending-xmpp-contact span');
+
+ await u.waitUntil(
+ () => Array.from(spans).reduce((result, value) => result + value.textContent?.trim(), '') ===
+ mock.pend_names.slice(0,i+1).sort().join('')
+ );
+ expect(true).toBe(true);
+ }));
+ });
+
+ describe("Existing Contacts", function () {
+ async function _addContacts (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ }
+
+ it("can be collapsed under their own header",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('.roster-group')]);
+ }));
+
+ it("will be hidden when appearing under a collapsed group",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ rosterview.querySelector('.group-toggle').click();
+ const name = "Romeo Montague";
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.create({
+ ask: null,
+ fullname: name,
+ jid: jid,
+ requesting: false,
+ subscription: 'both'
+ });
+ await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="My contacts"]`)) === true);
+ expect(true).toBe(true);
+ }));
+
+ it("will have their online statuses shown correctly",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const icon_el = document.querySelector('converse-roster-contact converse-icon');
+ expect(icon_el.getAttribute('color')).toBe('var(--subdued-color)');
+
+ let pres = $pres({from: 'mercutio@montague.lit/resource'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-online)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'away');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-away)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'xa');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'dnd');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-busy)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource', type: 'unavailable'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.cur_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => sizzle('li', rosterview).length);
+ // Check that they are sorted alphabetically
+ const els = sizzle('.current-xmpp-contact.offline a.open-chat', rosterview)
+ const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
+ expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('li').length);
+ const name = mock.cur_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => sent_IQ);
+
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+
+ `</iq>`);
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", rosterview).length === 0);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.cur_names[0];
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => callback?.());
+ expect(u.isVisible(rosterview.querySelector('.roster-group'))).toBe(true);
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => _converse.connection.sendIQ.calls.count());
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length === 0);
+ }));
+
+ it("can change their status to online and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.online a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to busy and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to away and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.away a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to xa and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.xa a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContenc?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to unavailable and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 500)
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("are ordered according to status: online, busy, away, xa, unavailable, offline",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ let i, jid;
+ for (i=0; i<3; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ }
+ for (i=3; i<6; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ }
+ for (i=6; i<9; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ }
+ for (i=9; i<12; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ }
+ for (i=12; i<15; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ }
+
+ await u.waitUntil(() => u.isVisible(rosterview.querySelector('li:first-child')), 900);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+
+ const group = groups[0];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ await u.waitUntil(() => els.map(e => e.getAttribute('data-status')).join(" ") === "online online away xa xa xa");
+
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ expect(els.length).toBe(mock.groups_map[groupname].length);
+
+ if (groupname === "Colleagues") {
+
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online away xa xa xa");
+ expect(status_classes.join(" ")).toBe("online online away xa xa xa");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "friends & acquaintences") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "Family") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online dnd");
+ expect(status_classes.join(" ")).toBe("online dnd");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ } else if (groupname === "ænemies") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("away");
+ expect(status_classes.join(" ")).toBe("away");
+ expect(subscription_classes.join(" ")).toBe("both");
+ } else if (groupname === "Ungrouped") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("unavailable unavailable");
+ expect(status_classes.join(" ")).toBe("unavailable unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ }
+ }
+ }));
+ });
+
+ describe("Requesting Contacts", function () {
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, "current", 0);
+ await mock.openControlBox(_converse);
+ let names = [];
+ const addName = function (item) {
+ if (!u.hasClass('request-actions', item)) {
+ names.push(item.textContent.replace(/^\s+|\s+$/g, ''));
+ }
+ };
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.req_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: null,
+ requesting: true,
+ nickname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Contact requests"] li`).length, 700);
+ // Check that they are sorted alphabetically
+ const children = rosterview.querySelector(`ul[data-group="Contact requests"]`).querySelectorAll('.requesting-xmpp-contact span');
+ names = [];
+ Array.from(children).forEach(addName);
+ expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
+ }));
+
+ it("do not have a header if there aren't any", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, "current", 0);
+ const name = mock.req_names[0];
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ _converse.roster.create({
+ 'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ 'subscription': 'none',
+ 'ask': null,
+ 'requesting': true,
+ 'nickname': name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 900);
+ expect(u.isVisible(rosterview.querySelector(`ul[data-group="Contact requests"]`))).toEqual(true);
+ expect(sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1);
+ sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`) === null);
+ }));
+
+ it("can be collapsed under their own header", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 700);
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`));
+ await checkHeaderToggling.apply(_converse, [el.parentElement]);
+ }));
+
+ it("can have their requests accepted by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ const name = mock.req_names.sort()[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(contact, 'authorize').and.callFake(() => contact);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length)
+ // TODO: Testing can be more thorough here, the user is
+ // actually not accepted/authorized because of
+ // mock_connection.
+ spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve());
+ const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop();
+ req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+ await u.waitUntil(() => contact.authorize.calls.count());
+ expect(contact.authorize).toHaveBeenCalled();
+ }));
+
+ it("can have their requests denied by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const name = mock.req_names.sort()[1];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", rosterview).pop());
+ req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => contact.unauthorize.calls.count());
+ // There should now be one less contact
+ expect(_converse.roster.length).toEqual(mock.req_names.length-1);
+ }));
+
+ it("are persisted even if other contacts' change their presence ", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ // Taken from the spec
+ // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
+ const result = $iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.org',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends').up().up()
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+
+ expect(_converse.roster.pluck('jid').length).toBe(1);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('a:contains("Contact requests")', rosterview).length, 700);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {
+ jid: 'benvolio@example.org',
+ name: 'Benvolio',
+ subscription:'both'
+ }).c('group').t('Friends');
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(4);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+ }));
+ });
+
+ describe("All Contacts", function () {
+
+ it("are saved to, and can be retrieved from browserStorage",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ var new_attrs, old_attrs, attrs;
+ var num_contacts = _converse.roster.length;
+ var new_roster = new _converse.RosterContacts();
+ // Roster items are yet to be fetched from browserStorage
+ expect(new_roster.length).toEqual(0);
+ new_roster.browserStorage = _converse.roster.browserStorage;
+ await new Promise(success => new_roster.fetch({success}));
+ expect(new_roster.length).toEqual(num_contacts);
+ // Check that the roster items retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ attrs = ['jid', 'fullname', 'subscription', 'ask'];
+ for (var i=0; i<attrs.length; i++) {
+ new_attrs = new_roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ old_attrs = _converse.roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ // Roster items in storage are not necessarily sorted,
+ // so we have to sort them here to do a proper
+ // comparison
+ expect(new_attrs.sort()).toEqual(old_attrs.sort());
+ }
+ }));
+
+ it("will show fullname and jid properties on tooltip",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 'all');
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ await Promise.all(mock.cur_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.getAttribute('title')).toContain(name);
+ expect(child.getAttribute('title')).toContain(jid);
+ }));
+ await Promise.all(mock.req_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.firstElementChild.getAttribute('title')).toContain(jid);
+ }));
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js
new file mode 100644
index 0000000..8b5c694
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js
@@ -0,0 +1,114 @@
+import log from "@converse/headless/log";
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+export function removeContact (contact) {
+ contact.removeFromRoster(
+ () => contact.destroy(),
+ (e) => {
+ e && log.error(e);
+ api.alert('error', __('Error'), [
+ __('Sorry, there was an error while trying to remove %1$s as a contact.',
+ contact.getDisplayName())
+ ]);
+ }
+ );
+}
+
+export function highlightRosterItem (chatbox) {
+ _converse.roster?.get(chatbox.get('jid'))?.trigger('highlight');
+}
+
+export function toggleGroup (ev, name) {
+ ev?.preventDefault?.();
+ const collapsed = _converse.roster.state.get('collapsed_groups');
+ if (collapsed.includes(name)) {
+ _converse.roster.state.save('collapsed_groups', collapsed.filter(n => n !== name));
+ } else {
+ _converse.roster.state.save('collapsed_groups', [...collapsed, name]);
+ }
+}
+
+export function isContactFiltered (contact, groupname) {
+ const filter = _converse.roster_filter;
+ const type = filter.get('filter_type');
+ const q = (type === 'state') ?
+ filter.get('chat_state').toLowerCase() :
+ filter.get('filter_text').toLowerCase();
+
+ if (!q) return false;
+
+ if (type === 'state') {
+ const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD];
+ if (sticky_groups.includes(groupname)) {
+ // When filtering by chat state, we still want to
+ // show sticky groups, even though they don't
+ // match the state in question.
+ return false;
+ } else if (q === 'unread_messages') {
+ return contact.get('num_unread') === 0;
+ } else if (q === 'online') {
+ return ["offline", "unavailable"].includes(contact.presence.get('show'));
+ } else {
+ return !contact.presence.get('show').includes(q);
+ }
+ } else if (type === 'contacts') {
+ return !contact.getFilterCriteria().includes(q);
+ }
+}
+
+export function shouldShowContact (contact, groupname) {
+ const chat_status = contact.presence.get('show');
+ if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
+ // If pending or requesting, show
+ if ((contact.get('ask') === 'subscribe') ||
+ (contact.get('subscription') === 'from') ||
+ (contact.get('requesting') === true)) {
+ return !isContactFiltered(contact, groupname);
+ }
+ return false;
+ }
+ return !isContactFiltered(contact, groupname);
+}
+
+export function shouldShowGroup (group) {
+ const filter = _converse.roster_filter;
+ const type = filter.get('filter_type');
+ if (type === 'groups') {
+ const q = filter.get('filter_text')?.toLowerCase();
+ if (!q) {
+ return true;
+ }
+ if (!group.toLowerCase().includes(q)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function populateContactsMap (contacts_map, contact) {
+ if (contact.get('requesting')) {
+ const name = _converse.HEADER_REQUESTING_CONTACTS;
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ } else {
+ let contact_groups;
+ if (api.settings.get('roster_groups')) {
+ contact_groups = contact.get('groups');
+ contact_groups = (contact_groups.length === 0) ? [_converse.HEADER_UNGROUPED] : contact_groups;
+ } else {
+ if (contact.get('ask') === 'subscribe') {
+ contact_groups = [_converse.HEADER_PENDING_CONTACTS];
+ } else {
+ contact_groups = [_converse.HEADER_CURRENT_CONTACTS];
+ }
+ }
+ for (const name of contact_groups) {
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ }
+ }
+ if (contact.get('num_unread')) {
+ const name = _converse.HEADER_UNREAD;
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ }
+ return contacts_map;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js b/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js
new file mode 100644
index 0000000..e9ad110
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js
@@ -0,0 +1,41 @@
+/**
+ * @copyright JC Brand
+ * @license Mozilla Public License (MPLv2)
+ * @description A plugin which restricts Converse to only one chat.
+ */
+import { api, converse } from "@converse/headless/core";
+
+import './singleton.scss';
+
+
+converse.plugins.add('converse-singleton', {
+
+ enabled (_converse) {
+ return _converse.api.settings.get("singleton");
+ },
+
+ initialize () {
+ api.settings.extend({
+ 'allow_logout': false, // No point in logging out when we have auto_login as true.
+ 'allow_muc_invitations': false, // Doesn't make sense to allow because only
+ // roster contacts can be invited
+ 'hide_muc_server': true
+ });
+
+ const auto_join_rooms = api.settings.get('auto_join_rooms');
+ const auto_join_private_chats = api.settings.get('auto_join_private_chats');
+
+ if (!Array.isArray(auto_join_rooms) && !Array.isArray(auto_join_private_chats)) {
+ throw new Error("converse-singleton: auto_join_rooms must be an Array");
+ }
+ if (auto_join_rooms.length === 0 && auto_join_private_chats.length === 0) {
+ throw new Error("If you set singleton set to true, you need "+
+ "to specify auto_join_rooms or auto_join_private_chats");
+ }
+ if (auto_join_rooms.length > 0 && auto_join_private_chats.length > 0) {
+ throw new Error("It doesn't make sense to have singleton set to true and " +
+ "auto_join_rooms or auto_join_private_chats set to more then one, " +
+ "since only one chat room may be open at any time.");
+ }
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss b/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss
new file mode 100644
index 0000000..a0ae8d8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss
@@ -0,0 +1,49 @@
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
+@import "bootstrap/scss/mixins";
+@import "bootstrap/scss/media";
+@import "shared/styles/_variables.scss";
+@import "shared/styles/_mixins.scss";
+
+
+.conversejs {
+ converse-chats.converse-embedded,
+ converse-chats.converse-fullscreen {
+ &.converse-singleton {
+ .flyout {
+ border: none !important;
+ }
+ .chat-head {
+ padding: 0.5em;
+ }
+ .chatbox {
+ margin: 0;
+ position: relative;
+ margin-left: -15px;
+ @media screen and (max-width: $mobile-portrait-length) {
+ margin-left: 0;
+ }
+ @include media-breakpoint-down(sm) {
+ margin-left: 0;
+ }
+ }
+ }
+ }
+
+ converse-chats.converse-fullscreen {
+ &.converse-singleton {
+ .chatbox {
+ @include make-col-ready();
+ @include media-breakpoint-up(md) {
+ @include make-col(12);
+ }
+ @include media-breakpoint-up(lg) {
+ @include make-col(12);
+ }
+ @include media-breakpoint-up(xl) {
+ @include make-col(12);
+ }
+ }
+ }
+ }
+}