فهرست منبع

Merge remote-tracking branch 'origin/master'

Hosted Weblate 5 ماه پیش
والد
کامیت
8b39de561b
100فایلهای تغییر یافته به همراه4426 افزوده شده و 1553 حذف شده
  1. 21 0
      .aiderignore
  2. 1 0
      .gitignore
  3. 30 4
      CHANGES.md
  4. 0 6
      README.md
  5. 17 0
      conversejs.doap
  6. 2 0
      dev.html
  7. 0 4
      docs/source/_templates/sponsors.html
  8. 17 1
      docs/source/configuration.rst
  9. 57 60
      docs/source/troubleshooting.rst
  10. 0 1
      index.html
  11. 11 2
      karma.conf.js
  12. 3 3
      package-lock.json
  13. 2 2
      src/headless/index.js
  14. 1 1
      src/headless/package.json
  15. 51 0
      src/headless/plugins/blocklist/api.js
  16. 103 0
      src/headless/plugins/blocklist/collection.js
  17. 1 0
      src/headless/plugins/blocklist/index.js
  18. 16 0
      src/headless/plugins/blocklist/model.js
  19. 88 0
      src/headless/plugins/blocklist/plugin.js
  20. 216 0
      src/headless/plugins/blocklist/tests/blocklist.js
  21. 34 0
      src/headless/plugins/blocklist/utils.js
  22. 39 0
      src/headless/plugins/bookmarks/api.js
  23. 188 116
      src/headless/plugins/bookmarks/collection.js
  24. 2 2
      src/headless/plugins/bookmarks/model.js
  25. 35 0
      src/headless/plugins/bookmarks/parsers.js
  26. 81 24
      src/headless/plugins/bookmarks/plugin.js
  27. 430 67
      src/headless/plugins/bookmarks/tests/bookmarks.js
  28. 258 0
      src/headless/plugins/bookmarks/tests/deprecated.js
  29. 8 0
      src/headless/plugins/bookmarks/types.ts
  30. 15 12
      src/headless/plugins/bookmarks/utils.js
  31. 3 3
      src/headless/plugins/chat/api.js
  32. 14 0
      src/headless/plugins/chat/message.js
  33. 11 3
      src/headless/plugins/chat/model.js
  34. 8 10
      src/headless/plugins/chat/parsers.js
  35. 19 7
      src/headless/plugins/chat/types.ts
  36. 1 3
      src/headless/plugins/chat/utils.js
  37. 8 9
      src/headless/plugins/chatboxes/chatboxes.js
  38. 131 121
      src/headless/plugins/disco/api.js
  39. 7 3
      src/headless/plugins/disco/entity.js
  40. 37 85
      src/headless/plugins/disco/tests/disco.js
  41. 3 0
      src/headless/plugins/disco/utils.js
  42. 2 2
      src/headless/plugins/emoji/plugin.js
  43. 2 2
      src/headless/plugins/mam/placeholder.js
  44. 4 2
      src/headless/plugins/mam/utils.js
  45. 7 6
      src/headless/plugins/muc/affiliations/utils.js
  46. 0 3
      src/headless/plugins/muc/api.js
  47. 28 2
      src/headless/plugins/muc/constants.js
  48. 2 2
      src/headless/plugins/muc/index.js
  49. 11 10
      src/headless/plugins/muc/message.js
  50. 225 218
      src/headless/plugins/muc/muc.js
  51. 14 1
      src/headless/plugins/muc/occupant.js
  52. 1 1
      src/headless/plugins/muc/occupants.js
  53. 245 135
      src/headless/plugins/muc/parsers.js
  54. 43 72
      src/headless/plugins/muc/plugin.js
  55. 20 24
      src/headless/plugins/muc/tests/affiliations.js
  56. 34 29
      src/headless/plugins/muc/tests/messages.js
  57. 27 16
      src/headless/plugins/muc/tests/occupants.js
  58. 74 57
      src/headless/plugins/muc/tests/registration.js
  59. 80 8
      src/headless/plugins/muc/types.ts
  60. 20 1
      src/headless/plugins/muc/utils.js
  61. 1 1
      src/headless/plugins/ping/api.js
  62. 0 93
      src/headless/plugins/pubsub.js
  63. 202 0
      src/headless/plugins/pubsub/api.js
  64. 40 0
      src/headless/plugins/pubsub/index.js
  65. 16 0
      src/headless/plugins/pubsub/parsers.js
  66. 622 0
      src/headless/plugins/pubsub/tests/config.js
  67. 42 0
      src/headless/plugins/pubsub/types.ts
  68. 12 0
      src/headless/plugins/roster/api.js
  69. 29 20
      src/headless/plugins/roster/contact.js
  70. 29 12
      src/headless/plugins/roster/contacts.js
  71. 4 3
      src/headless/plugins/roster/plugin.js
  72. 5 5
      src/headless/plugins/roster/utils.js
  73. 5 4
      src/headless/plugins/smacks/tests/smacks.js
  74. 6 7
      src/headless/plugins/smacks/utils.js
  75. 34 30
      src/headless/plugins/status/status.js
  76. 6 4
      src/headless/plugins/vcard/plugin.js
  77. 1 1
      src/headless/shared/_converse.js
  78. 20 26
      src/headless/shared/actions.js
  79. 1 1
      src/headless/shared/api/events.js
  80. 8 6
      src/headless/shared/api/public.js
  81. 22 29
      src/headless/shared/api/send.js
  82. 6 2
      src/headless/shared/constants.js
  83. 62 3
      src/headless/shared/errors.js
  84. 2 1
      src/headless/shared/index.js
  85. 17 18
      src/headless/shared/model-with-contact.js
  86. 105 101
      src/headless/shared/model-with-messages.js
  87. 120 34
      src/headless/shared/parsers.js
  88. 3 1
      src/headless/shared/settings/constants.js
  89. 37 0
      src/headless/shared/types.ts
  90. 26 0
      src/headless/types/plugins/blocklist/api.d.ts
  91. 25 0
      src/headless/types/plugins/blocklist/collection.d.ts
  92. 2 0
      src/headless/types/plugins/blocklist/index.d.ts
  93. 6 0
      src/headless/types/plugins/blocklist/model.d.ts
  94. 2 0
      src/headless/types/plugins/blocklist/plugin.d.ts
  95. 11 0
      src/headless/types/plugins/blocklist/utils.d.ts
  96. 23 0
      src/headless/types/plugins/bookmarks/api.d.ts
  97. 41 9
      src/headless/types/plugins/bookmarks/collection.d.ts
  98. 6 0
      src/headless/types/plugins/bookmarks/parsers.d.ts
  99. 9 0
      src/headless/types/plugins/bookmarks/types.d.ts
  100. 10 2
      src/headless/types/plugins/bookmarks/utils.d.ts

+ 21 - 0
.aiderignore

@@ -0,0 +1,21 @@
+ # Add files and directories to ignore by Aider
+ *.log
+ *.tmp
+ 3rdparty/libsignal-protocol.min.js
+ LICENSE
+ build/
+ certs/
+ demo/
+ develop-eggs/
+ dist/
+ docs/doctrees
+ docs/html/
+ images/
+ logo/
+ media/
+ node_modules/
+ share/
+ sounds/
+ src/headless/types
+ src/types/
+ tags

+ 1 - 0
.gitignore

@@ -67,3 +67,4 @@ node_modules
 # Builds
 .sv?
 /vendor/
+.aider*

+ 30 - 4
CHANGES.md

@@ -2,15 +2,26 @@
 
 ## 11.0.0 (Unreleased)
 
+### Github Issues
 - #122: Set horizontal layout direction based on the language
+- #317: Add the ability to render audio streams. New config option [fetch_url_headers](https://conversejs.org/docs/html/configuration.html#fetch-url-headers)
 - #698: Add support for MUC private messages
+- #1021: Message from non-roster contacts don't appear in fullscreen view_mode
+- #1038: Support setting node config manually
 - #1057: Removed the `mobile` view mode. Instead of setting `view_mode` to `mobile`, set it to `fullscreen`.
 - #1174: Show MUC avatars in the rooms list
 - #1195: Add actions to quote and copy messages
+- #1303: Display non-contacts who sent us a message somehow in fullscreen 
 - #1349: XEP-0392 Consistent Color Generation
+- #2383: Add modal to start chats with JIDs not in the roster
+- #2586: Add support for XEP-0402 Bookmarks
+- #2623: Merge MUC join and bookmark, leave and unset autojoin 
 - #2716: Fix issue with chat display when opening via URL
 - #2980: Allow setting an avatar for MUCs
 - #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
+- #3038: Message to self from other client is ignored
+- #3038: Support showing yourself in the left sidebar. Adds new config option `[show_self_in_roster](https://conversejs.org/docs/html/configuration.html#show-self-in-roster)`.
+- #3100: fixed width `.box-flyout` breaks responsive design in embedded, mobile viewport mode.
 - #3155: Some ad-hoc commands not working
 - #3155: Some adhoc commands aren't working
 - #3299: Registration fails when a password contains an &
@@ -23,6 +34,8 @@
 - #3476: better UI for form "fixed" fields
 - #3478: MUC participant status indicator misplaced 
 - #3529: Unbookmarked channels no longer change their name when clicked with an unread indicator (or text icon)
+
+### Bugfixes
 - Fix: MUC occupant list does not sort itself on nicknames or roles changes
 - Fix: refresh the MUC sidebar when participants collection is sorted
 - Fix: room information not correctly refreshed when modifications are made by other users
@@ -32,22 +45,35 @@
 - Fix: unhandled exception in disconnect function when controlbox is not shown by UI
 - Fix: "Click to mention..." title was misplaced in MUC occupant list.
 - Fix: removing the "add to contact" button in occupant modal in singleton mode (as there is no roster).
+- Fix: trying to use emojis with an uppercase letter breaks the message field.
+- Fix: renaming getEmojisByAtrribute to getEmojisByAttribute.
+
+### Changes and features
+- Upgrade to the latest versions of XEP-0424 and XEP-0425 (Message Retraction and Message Moderation).
+  Converse loses the ability to retract or moderate messages in the older format,
+  so you might need to upgrade your XMPP server's implementation of these as well.
+- Embed the Spotify player for links to Spotify tracks. New config option [embed_3rd_party_media_players](https://conversejs.org/docs/html/configuration.html#embed-3rd-party-media-players).
+- Add support for XEP-0191 Blocking Command
+- Upgrade to Bootstrap 5
 - Add an occupants filter to the MUC sidebar
 - Change contacts filter to rename the anachronistic `Online` state to `Available`.
 - Enable [reuse_scram_keys](https://conversejs.org/docs/html/configuration.html#reuse-scram-keys) by default.
 - New `loadEmojis` hook, to customize emojis at runtime.
-- Upgrade to Bootstrap 5
 - Add new themes 'Cyberpunk' and 'Nord' and remove the old 'Concord' theme.
 - Improved accessibility.
 - New "getOccupantActionButtons" hook, so that plugins can add actions on MUC occupants.
 - MUC occupants badges: displays short labels, with full label as title.
-- Fix: trying to use emojis with an uppercase letter breaks the message field.
-- Fix: renaming getEmojisByAtrribute to getEmojisByAttribute.
 - New config option [stanza_timeout](https://conversejs.org/docs/html/configuration.html#show-background)
+- Update the "Add MUC" modal to add validation and to allow specifying only the MUC name and not the whole address.
+
+### Default config changes
 - Make `fullscreen` the default `view_mode`.
+- Set `auto_register_muc_nickname` default to `'unregister'` so that your
+  nickname is automatically registered with a MUC upon entering and
+  unregistered upon explicitly leaving the MUC (by closing it).
+- The `allow_non_roster_messaging` setting now defaults to `true`.
 
 ### Breaking changes:
-
 - Remove the old `_converse.BootstrapModal` in favor of `_converse.BaseModal` which is a web component.
 - The connection is no longer available on the `_converse` object. Instead, use `api.connection.get()`.
 - Add a new `exports` attribute on the `_converse` object which is meant for

+ 0 - 6
README.md

@@ -165,12 +165,6 @@ We accept donations via [Patreon](https://www.patreon.com/jcbrand) and [Liberapa
 
 ## Sponsors
 
-<p>
-  <a href="https://www.dotcom-monitor.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener">
-    <img alt="Dotcom-Monitor" src="https://raw.githubusercontent.com/conversejs/media/main/logos/dotcom-monitor.svg" width="200">
-                                   
-  </a>
-</p>
 <p>
   <a href="https://bairesdev.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener">
     <img alt="BairesDev" src="https://raw.githubusercontent.com/conversejs/media/main/logos/bairesdev-primary.png" width="200">

+ 17 - 0
conversejs.doap

@@ -125,6 +125,12 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0184.html"/>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0191.html"/>
+        <xmpp:since>11.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0198.html"/>
@@ -146,6 +152,11 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0206.html"/>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0223.html"/>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0245.html"/>
@@ -255,6 +266,12 @@
         <xmpp:since>8.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0402.html"/>
+        <xmpp:since>11.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0410.html"/>

+ 2 - 0
dev.html

@@ -26,6 +26,7 @@
     });
 
     converse.initialize({
+        i18n: 'af',
         theme: 'cyberpunk',
         auto_away: 300,
         enable_smacks: true,
@@ -37,6 +38,7 @@
         muc_show_logs_before_join: true,
         notify_all_room_messages: ['discuss@conference.conversejs.org'],
         websocket_url: 'wss://conversejs.org/xmpp-websocket',
+        // view_mode: 'overlayed',
         // websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
         whitelisted_plugins: ['converse-debug'],
         // connection_options: { worker: '/dist/shared-connection-worker.js' }

+ 0 - 4
docs/source/_templates/sponsors.html

@@ -2,10 +2,6 @@
 <h4 class="sidebar-title">Sponsored by</h4>
 </span>
 <ul class="sponsors-list">
-    <li><a href="https://www.dotcom-monitor.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener">
-            <img style="width: 10em" src="/media/logos/dotcom-monitor.svg" alt="Dotcom-Monitor">
-        </a>
-    </li>
     <li><a href="https://bairesdev.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener">
             <img style="width: 10em" src="/media/logos/bairesdev-primary.png" alt="BairesDev">
         </a>

+ 17 - 1
docs/source/configuration.rst

@@ -452,7 +452,7 @@ or `electron <http://electron.atom.io/>`_.
 auto_register_muc_nickname
 --------------------------
 
-* Default: ``false``
+* Default: ``unregister``
 * Allowed values: ``false``, ``true``, ``'unregister'``
 
 If truthy, Converse will automatically register a user's nickname upon entering
@@ -809,6 +809,14 @@ domain_placeholder
 
 The placeholder text shown in the domain input on the registration form.
 
+embed_3rd_party_media_players
+-----------------------------
+
+* Default: ``true``
+
+If ``true``, links to 3rd party media sites, such as Spotify will be turned
+into embedded media players from those sites (if supported by Converse).
+
 
 emoji_categories
 ----------------
@@ -917,6 +925,14 @@ support is turned on or not.
 Recommended to set to ``true`` if a websocket connection is used.
 Please see the :ref:`websocket-url` configuration setting.
 
+fetch_url_headers
+-----------------
+
+* Default: ``true``
+
+If set to ``false``, then Converse won't fetch the headers of URLs to determine
+whether they link to media that can be embedded (e.g. streaming audio).
+
 filter_by_resource
 ------------------
 

+ 57 - 60
docs/source/troubleshooting.rst

@@ -12,53 +12,49 @@ General tips on debugging Converse
 Enabling debug output
 ---------------------
 
-Converse has a :ref:`loglevel` configuration setting which lets you to turn on
-debug logging in the browser's developer console.
+Converse has a :ref:`loglevel` configuration setting which lets you turn on
+debug logging in the browser's developer console. With the ``loglevel`` set
+to ``debug``, Converse will log all XML traffic between itself and the XMPP server.
 
-When debugging, you'll want to make sure that this setting is set to
-``debug`` when calling ``converse.initialize``.
+When debugging, you'll want to ensure this is set to ``debug`` when 
+calling ``converse.initialize``.
 
-When the ``loglevel`` is set to ``debug``, Converse will log all XML traffic
-between it and the XMPP server.
-
-You can also enable debug output via the URL, which is useful when you don't
-have access to the server where Converse is hosted.
-
-To do so, add ``#converse?loglevel=debug`` to the URL in the browser's address bar.
-Make sure to first remove any already existing URL fragment (the URL fragment
+Alternatively, you can enable debug output via the URL. This is useful when you don't
+have access to the server where Converse is hosted. For this, 
+add ``#converse?loglevel=debug`` to the URL in the browser's address bar, ensuring
+any already existing URL fragment is removed first (the URL fragment
 is the part that starts with a ``#``).
 
-With debug logging on, you can open the browser's developer console (e.g. by pressing F12)
-and study the debug data that is logged to it. Make sure that verbose logging
+With debug logging on, the browser's developer console (often opened by pressing F12)
+can be studied to see all Converse debug output logged to it. Ensure verbose logging
 is enabled in the browser's developer console, otherwise not all logs from
 Converse might be visible.
 
-In Chrome you can right click in the developer console and save its contents to
-a file for later study.
+In Chrome and other browsers, contents of the developer console can be saved to
+a file for later study by right clicking within the developer console.
 
 What is logged at the debug loglevel?
 -------------------------------------
 
 `Strope.js <http://strophe.im/>`_, the underlying XMPP library which Converse
-uses, swallows errors so that messaging can continue in cases where
-non-critical errors occur.
-
-This is a useful feature and provides more stability, but it makes debugging
-trickier, because the app doesn't crash when something goes wrong somewhere.
+uses, quietly swallows non-critical errors so that messaging activities can generally continue 
+in cases where non-critical errors have occurred. This is a useful feature and provides 
+a better user experience and more stability in the client, but debugging is more challenging 
+because the app doesn't obviously crash when something goes wrong somewhere.
 
 That's why checking the debug output in the browser console is important.
 If something goes wrong somewhere, the error will be logged there and you'll be
 able to see it.
 
-Additionally, Converse will in debug mode also log all XMPP stanzas
+Additionally, in debug mode Converse also logs all XMPP stanzas
 (the XML snippets being sent between it and the server) to the console.
 This is very useful for debugging issues relating to the XMPP protocol.
 
 For example, if a message or presence update doesn't appear, one of the first
-things you can do is to set ``loglevel: debug`` and then to check in the console
-whether the relevant XMPP stanzas are actually logged (which would mean that
-they were received by Converse). If they're not logged, then the problem is
-more likely on the XMPP server's end (perhaps a misconfiguration?). If they
+things you can do is to set ``loglevel: debug`` and then confirm in the console
+whether or not the relevant XMPP stanzas have actually been logged (meaning
+they were at least received by Converse). If they're not present in the log, 
+the problem is more likely on the XMPP server's end (perhaps a misconfiguration?). If they
 **are** logged, then there might be a bug or misconfiguration in Converse.
 
 Performance issues with large rosters
@@ -74,11 +70,12 @@ Chrome <https://developer.chrome.com/devtools/docs/cpu-profiling>`_ to find
 bottlenecks in the code.
 
 However, with large rosters (more than 1000 contacts), rendering in
-Converse slows down a lot and it may become intolerably slow.
+Converse is known to slow down considerably. It may become intolerably slow
+in these cases.
 
 One simple trick to improve performance is to set ``show_only_online_users: true``.
-This will (usually) reduce the amount of contacts that get rendered in the
-roster, which eases one of the remaining performance bottlenecks.
+This usually reduces the number of contacts shown in the
+roster, which eases this known performance bottlenecks.
 
 File upload is not working
 ==========================
@@ -97,7 +94,7 @@ is *upload.example.org*, then the HTTP file server needs to enable CORS.
 If you're not sure what the domain of the HTTP file server is, take a look at
 the console of your browser's developer tools.
 
-You might see an error like this one::
+You might see an error like::
 
     Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://example.de:5443/...
 
@@ -113,11 +110,10 @@ support CORS and the browser will prevent requests from being made to it.
 
 This will prevent you from uploading files to it.
 
-How you solve a CORS-related issue depends on your particular setup, specifically it depends on
-what you're using as the HTTP file server.
-
-CORS is enabled by adding an ``Access-Control-Allow-Origin`` header, so you'll
-have to configure your file server to add this header.
+Solving a CORS-related issue depends on your particular setup, most especially
+what you're using as the HTTP file server. CORS is enabled by adding 
+an ``Access-Control-Allow-Origin`` header, so you will
+need to adjust your HTTP file server configuration to add this header.
 
 Users don't stay logged in across page reloads
 ==============================================
@@ -128,8 +124,8 @@ is that users are logged out when they reload the page.
 The main way in which websites and web apps maintain a user's login session is via
 authentication cookies, which are included in every HTTP request sent to the server.
 
-XMPP is however not HTTP, cookies aren't automatically included in traffic to
-the XMPP server and XMPP servers don't rely on cookies for authentication.
+But XMPP is not HTTP. Cookies aren't automatically included in traffic to
+the XMPP server, and XMPP servers don't rely on cookies for authentication.
 
 Instead, an XMPP client is expected to store the user credentials (username and
 password, either plaintext or hashed and salted if
@@ -147,9 +143,9 @@ Use the Web Auth API
 ********************
 
 Converse supports the `Web Authentication API <https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API>`_
-which let's it use the secure credential management of the browser to get the
-uesr credentials to automatically log the user in. This however requires that
-the user saves his or her username and password in the browser. Often the user
+which leverages the secure credential management of the web browser to get the
+user credentials that are used to automatically log the user in. However, this requires
+the user to save his or her username and password in the browser. Often the user
 is automatically asked by the browser whether he/she wants to store the
 credentials. If that doesn't happen, the user has to do so manually, usually by
 clicking the key icon in the address bar. This works well on most modern browsers,
@@ -161,47 +157,48 @@ What can users do to stay logged in?
 Outsource credential management to something else
 *************************************************
 
-The issues mentioned above mostly related to users logging in manually, and not
-to integrations where Converse automatically fetches user credentials from the
-backend via the :ref:`credentials_url` setting.
+The issues mentioned above are mostly associated with integrations where users log in
+manually. They do not pertain to integrations where Converse automatically fetches user 
+credentials from the backend via the :ref:`credentials_url` setting.
 
-Use BOSH instead of websocket
+Use BOSH instead of Websocket
 *****************************
 
 `BOSH <https://xmpp.org/extensions/xep-0206.html>`_ can be thought of
-XMPP-over-HTTP and because HTTP is stateless, BOSH needs to maintain login
+as XMPP-over-HTTP, and because HTTP is stateless BOSH needs to maintain login
 sessions for a certain amount of time (usually 60 seconds) even if there is no
 HTTP traffic between the client and server. This means that if you have a BOSH
 session running, you can reload the page and you will stay logged in.
 
-Note, Websocket connections are however faster and have less overhead than BOSH.
+The tradeoff, however, is that BOSH connections are slower and have more overhead than 
+Websocket connections.
 
 User a browser with adequate support for the Web Auth API
 *********************************************************
 
-Another option is to only use a browser with proper support for the Web Auth
+Another option is to only use a browser with well-developed support for the Web Auth
 API (which mainly means avoiding Firefox) and then to save your credentials in the browser.
 
 Use Converse Desktop
 ********************
 
 The `desktop version of Converse <https://github.com/conversejs/converse-desktop>`_
-also doesn't have this problem, since the credentials are stored in Electron
+does not experience this problem, since login credentials are stored in Electron
 and there is no significant risk of other malicious scripts running.
 
 What else can Converse do to keep users logged in?
 --------------------------------------------------
 
-This problem could also potentially be fixed by storing the
+This problem can also potentially be fixed by storing the
 XMPP credentials securely with web crypto and IndexedDB. This could be done by
 generating a private encryption key in non-exportable format, and then using that
 to encrypt the credentials before storing them in IndexedDB.
 
 This would protect the credentials from someone who has access to your
-computer (or harddrive), but it still won't protect them from malicious scripts
-running in the same domain as Converse is being hosted, since they would have the
-same level of access as Converse itself (which legitimately needs access to the
-credentials).
+computer (or physical storage within your computer), but it still won't serve as
+protection against malicious scripts running in the same domain as Converse is being hosted,
+since they would have the same level of access as Converse itself (which legitimately needs
+access to the credentials).
 
 Common errors
 =============
@@ -209,11 +206,11 @@ Common errors
 Error: A "url" property or function must be specified
 -----------------------------------------------------
 
-That's a relatively generic `Skeletor <https://github.com/conversejs/skeletor>`_ (or `Backbone <http://backbonejs.org/>_`)
-error and by itself it usually doesn't give enough information to know how to fix the underlying issue.
+This is a relatively generic `Skeletor <https://github.com/conversejs/skeletor>`_ (or `Backbone <http://backbonejs.org/>_`)
+error, and by itself it usually doesn't give enough information to know how to fix the underlying issue.
 
-Generally, this error happens when a Model is being persisted (e.g. when model.save() is called,
-but there is no information specifying where/how it should be persisted.
+Generally, this error happens when a Model is being persisted, such as when model.save() is called
+but no information has been specified as to where/how it should be persisted.
 
 The Converse models are persisted to browser storage (e.g. sessionStorage, localStorage or IndexedDB),
 and this happens by adding a browserStorage attribute on the model, or on the collection containing the model.
@@ -221,9 +218,9 @@ and this happens by adding a browserStorage attribute on the model, or on the co
 See for example here: https://github.com/conversejs/converse.js/blob/395aa8cb959bbb7e26472ed3356160c8044be081/src/headless/converse-chat.js#L359
 
 If this error occurs, it means that a model being persisted doesn't have the ``browserStorage`` attribute,
-and it's containing collection (if there is one) also doesn't have that attribute.
+and its containing collection (if there is one) also doesn't have that attribute.
 
 This usually happens when a model has been removed from a collection, and then ``.save()`` is called on it.
 
-In the context of Converse it might mean that there's an attempt to persist data before all models have been properly initialized,
-or conversely after models have been removed from their containing collections.
+In the context of Converse, it might indicate that an attempt has been made to persist data either 
+before all models were properly initialized, or after models were removed from their containing collections.

+ 0 - 1
index.html

@@ -239,7 +239,6 @@
                     <div class="sponsors">
                         <h2>Converse is supported by:</h2>
                         <ul >
-                            <li><a href="https://www.dotcom-monitor.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener"><img style="width: 13em" src="/media/logos/dotcom-monitor.svg" alt="Dotcom-Monitor"></a></li>
                             <li><a href="https://bairesdev.com/sponsoring-open-source-projects/?utm_source=conversejs" target="_blank" rel="noopener"><img style="width: 13em" src="/media/logos/bairesdev-primary.png" alt="BairesDev"></a></li>
                             <li><a href="https://blokt.com?utm_source=conversejs" target="_blank" rel="noopener"><img style="width: 12em" src="/logo/blokt.png" alt="Blokt Crypto & Privacy"></a></li>
                             <li><a href="https://www.keycdn.com?utm_source=conversejs" target="_blank" rel="noopener"><img style="height: 3em" src="/logo/keycdn.svg" alt="KeyCDN"></a></li>

+ 11 - 2
karma.conf.js

@@ -24,7 +24,9 @@ module.exports = function(config) {
       },
       { pattern: "src/shared/tests/mock.js", type: 'module' },
 
+      { pattern: "src/headless/plugins/blocklist/tests/blocklist.js", type: 'module' },
       { pattern: "src/headless/plugins/bookmarks/tests/bookmarks.js", type: 'module' },
+      { pattern: "src/headless/plugins/bookmarks/tests/deprecated.js", type: 'module' },
       { pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
@@ -36,6 +38,7 @@ module.exports = function(config) {
       { pattern: "src/headless/plugins/muc/tests/pruning.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/registration.js", type: 'module' },
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
+      { pattern: "src/headless/plugins/pubsub/tests/config.js", type: 'module' },
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
       { pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
       { pattern: "src/headless/plugins/status/tests/status.js", type: 'module' },
@@ -46,9 +49,11 @@ module.exports = function(config) {
       { pattern: "src/plugins/adhoc-views/tests/adhoc.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks-list.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
+      { pattern: "src/plugins/bookmark-views/tests/deprecated.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/deprecated-retractions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
@@ -61,6 +66,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/oob.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/receipts.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/retractions.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/styling.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/unreads.js", type: 'module' },
@@ -77,6 +83,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/csn.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/deprecated-retractions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
@@ -95,10 +102,10 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/muc-list-modal.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-mentions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-messages.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/mute.js", type: 'module' },
-      { pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },
@@ -122,6 +129,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/roomslist/tests/grouplists.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/add-contact-modal.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/new-chat-modal.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
@@ -131,7 +139,8 @@ module.exports = function(config) {
     ],
 
     proxies: {
-      "/dist/images/custom_emojis/": "/base/dist/images/custom_emojis/"
+      "/dist/images/custom_emojis/": "/base/dist/images/custom_emojis/",
+      "/images/logo/": "/base/dist/images/logo/"
     },
 
     client: {

+ 3 - 3
package-lock.json

@@ -9868,8 +9868,8 @@
     },
     "node_modules/strophe.js": {
       "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-3.1.0.tgz",
-      "integrity": "sha512-Nf8VlqohZHBn0dn2o7UX/ea2yHVz0svXosUiz13NM6qAGREvKZaXj/mbks61YkflEqfIRDlDdEneiMHnlYI82g==",
+      "resolved": "git+ssh://git@github.com/strophe/strophejs.git#f54d83199ec62272ee8b4987b2e63f188e1b2e29",
+      "integrity": "sha512-ivy/25C19VudvLDMPhW4oZ4gIpicc0+AnnBzzV/YUikTbaS/ujy4Y/vO416alCFovqEmcc3AEXoQ4O8KqYEKug==",
       "license": "MIT",
       "optionalDependencies": {
         "@types/jsdom": "^21.1.7",
@@ -11121,7 +11121,7 @@
         "pluggable.js": "3.0.1",
         "sizzle": "^2.3.5",
         "sprintf-js": "^1.1.2",
-        "strophe.js": "3.1.0",
+        "strophe.js": "strophe/strophejs#f54d83199ec62272ee8b4987b2e63f188e1b2e29",
         "urijs": "^1.19.10"
       },
       "devDependencies": {}

+ 2 - 2
src/headless/index.js

@@ -15,6 +15,7 @@ import log from './log.js';
 
 export { EmojiPicker } from './plugins/emoji/index.js';
 export { Bookmark, Bookmarks } from './plugins/bookmarks/index.js'; // XEP-0199 XMPP Ping
+import './plugins/blocklist/index.js';
 import './plugins/bosh/index.js'; // XEP-0206 BOSH
 import './plugins/caps/index.js'; // XEP-0115 Entity Capabilities
 export { ChatBox, Message, Messages } from './plugins/chat/index.js'; // RFC-6121 Instant messaging
@@ -32,9 +33,8 @@ export { MAMPlaceholderMessage } from './plugins/mam/index.js';
 // XEP-0045 Multi-user chat
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants } from './plugins/muc/index.js';
 
-
 import './plugins/ping/index.js'; // XEP-0199 XMPP Ping
-import './plugins/pubsub.js'; // XEP-0060 Pubsub
+import './plugins/pubsub/index.js'; // XEP-0060 Pubsub
 
 // RFC-6121 Contacts Roster
 export { RosterContact, RosterContacts, RosterFilter, Presence, Presences } from './plugins/roster/index.js';

+ 1 - 1
src/headless/package.json

@@ -42,7 +42,7 @@
     "pluggable.js": "3.0.1",
     "sizzle": "^2.3.5",
     "sprintf-js": "^1.1.2",
-    "strophe.js": "3.1.0",
+    "strophe.js": "strophe/strophejs#f54d83199ec62272ee8b4987b2e63f188e1b2e29",
     "urijs": "^1.19.10"
   },
   "devDependencies": {}

+ 51 - 0
src/headless/plugins/blocklist/api.js

@@ -0,0 +1,51 @@
+import promise_api from '../../shared/api/promise.js';
+import { sendBlockStanza, sendUnblockStanza } from './utils.js';
+
+const { waitUntil } = promise_api;
+
+/**
+ * Groups methods relevant to XEP-0191 Blocking Command
+ * @namespace api.blocklist
+ * @memberOf api
+ */
+const blocklist = {
+    /**
+     * Retrieves the current user's blocklist
+     * @returns {Promise<import('./collection').default>}
+     */
+    async get() {
+        return await waitUntil('blocklistInitialized');
+    },
+
+    /**
+     * Adds a new entity to the blocklist
+     * @param {string|string[]} jid
+     * @param {boolean} [send_stanza=true]
+     * @returns {Promise<import('./collection').default>}
+     */
+    async add(jid, send_stanza = true) {
+        const blocklist = await waitUntil('blocklistInitialized');
+        const jids = Array.isArray(jid) ? jid : [jid];
+        if (send_stanza) await sendBlockStanza(jids);
+        jids.forEach((jid) => blocklist.create({ jid }));
+        return blocklist;
+    },
+
+    /**
+     * Removes an entity from the blocklist
+     * @param {string|string[]} jid
+     * @param {boolean} [send_stanza=true]
+     * @returns {Promise<import('./collection').default>}
+     */
+    async remove(jid, send_stanza = true) {
+        const blocklist = await waitUntil('blocklistInitialized');
+        const jids = Array.isArray(jid) ? jid : [jid];
+        if (send_stanza) await sendUnblockStanza(jids);
+        blocklist.remove(jids);
+        return blocklist;
+    },
+};
+
+const blocklist_api = { blocklist };
+
+export default blocklist_api;

+ 103 - 0
src/headless/plugins/blocklist/collection.js

@@ -0,0 +1,103 @@
+import { getOpenPromise } from '@converse/openpromise';
+import { Collection } from '@converse/skeletor';
+import log from '../../log.js';
+import _converse from '../../shared/_converse.js';
+import { initStorage } from '../../utils/storage.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
+import BlockedEntity from './model.js';
+
+const { stx, u } = converse.env;
+
+class Blocklist extends Collection {
+    get idAttribute() {
+        return 'jid';
+    }
+
+    constructor() {
+        super();
+        this.model = BlockedEntity;
+    }
+
+    async initialize() {
+        const { session } = _converse;
+        const cache_key = `converse.blocklist-${session.get('bare_jid')}`;
+        this.fetched_flag = `${cache_key}-fetched`;
+        initStorage(this, cache_key);
+
+        this.on('add', this.rejectContactRequest);
+
+        await this.fetchBlocklist();
+
+        /**
+         * Triggered once the {@link Blocklist} collection
+         * has been created and cached blocklist have been fetched.
+         * @event _converse#blocklistInitialized
+         * @type {Blocklist}
+         * @example _converse.api.listen.on('blocklistInitialized', (blocklist) => { ... });
+         */
+        api.trigger('blocklistInitialized', this);
+    }
+
+    /**
+     * @param {BlockedEntity} item
+     */
+    async rejectContactRequest(item) {
+        const roster = await api.waitUntil('rosterContactsFetched');
+        const contact = roster.get(item.get('jid'));
+        if (contact?.get('requesting')) {
+            const chat = await api.chats.get(contact.get('jid'));
+            chat?.close();
+            contact.unauthorize().destroy();
+        }
+    }
+
+    fetchBlocklist() {
+        const deferred = getOpenPromise();
+        if (window.sessionStorage.getItem(this.fetched_flag)) {
+            this.fetch({
+                success: () => deferred.resolve(),
+                error: () => deferred.resolve(),
+            });
+        } else {
+            this.fetchBlocklistFromServer(deferred);
+        }
+        return deferred;
+    }
+
+    /**
+     * @param {Object} deferred
+     */
+    async fetchBlocklistFromServer(deferred) {
+        const stanza = stx`<iq xmlns="jabber:client"
+            type="get"
+            id="${u.getUniqueId()}"><blocklist xmlns="urn:xmpp:blocking"/></iq>`;
+
+        try {
+            this.onBlocklistReceived(deferred, await api.sendIQ(stanza));
+        } catch (e) {
+            log.error(e);
+            deferred.resolve();
+            return;
+        }
+    }
+
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    async onBlocklistReceived(deferred, iq) {
+        Array.from(iq.querySelectorAll('blocklist item')).forEach((item) => {
+            const jid = item.getAttribute('jid');
+            const blocked = this.get(jid);
+            blocked ? blocked.save({ jid }) : this.create({ jid });
+        });
+
+        window.sessionStorage.setItem(this.fetched_flag, 'true');
+        if (deferred !== undefined) {
+            return deferred.resolve();
+        }
+    }
+}
+
+export default Blocklist;

+ 1 - 0
src/headless/plugins/blocklist/index.js

@@ -0,0 +1 @@
+import './plugin.js';

+ 16 - 0
src/headless/plugins/blocklist/model.js

@@ -0,0 +1,16 @@
+import { Model } from '@converse/skeletor';
+import converse from '../../shared/api/public.js';
+
+const { Strophe } = converse.env;
+
+class BlockedEntity extends Model {
+    get idAttribute () {
+        return 'jid';
+    }
+
+    getDisplayName () {
+        return Strophe.xmlunescape(this.get('name'));
+    }
+}
+
+export default BlockedEntity;

+ 88 - 0
src/headless/plugins/blocklist/plugin.js

@@ -0,0 +1,88 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ * @description Adds support for XEP-0191 Blocking Command
+ */
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import converse from '../../shared/api/public.js';
+import log from '../../log.js';
+import Blocklist from './collection.js';
+import BlockedEntity from './model.js';
+import blocklist_api from './api.js';
+
+const { Strophe, sizzle } = converse.env;
+
+Strophe.addNamespace('BLOCKING', 'urn:xmpp:blocking');
+
+converse.plugins.add('converse-blocklist', {
+    dependencies: ['converse-disco'],
+
+    initialize() {
+        const exports = { Blocklist, BlockedEntity };
+        Object.assign(_converse.exports, exports);
+        Object.assign(api, blocklist_api);
+
+        api.promises.add(['blocklistInitialized']);
+
+        api.listen.on(
+            'getErrorAttributesForMessage',
+            /**
+             * @param {import('plugins/chat/types').MessageAttributes} attrs
+             * @param {import('plugins/chat/types').MessageErrorAttributes} new_attrs
+             */
+            (attrs, new_attrs) => {
+                if (attrs.errors.find((e) => e.name === 'blocked' && e.xmlns === `${Strophe.NS.BLOCKING}:errors`)) {
+                    const { __ } = _converse;
+                    new_attrs.error = __('You are blocked from sending messages.');
+                }
+                return new_attrs;
+            }
+        );
+
+        api.listen.on('connected', () => {
+            const connection = api.connection.get();
+            connection.addHandler(
+                /** @param {Element} stanza */ (stanza) => {
+                    const bare_jid = _converse.session.get('bare_jid');
+                    const from = stanza.getAttribute('from');
+                    if (Strophe.getBareJidFromJid(from ?? bare_jid) != bare_jid) {
+                        log.warn(`Received a blocklist push stanza from a suspicious JID ${from}`);
+                        return true;
+                    }
+
+                    const add_jids = sizzle(`block[xmlns="${Strophe.NS.BLOCKING}"] item`, stanza).map(
+                        /** @param {Element} item */ (item) => item.getAttribute('jid')
+                    );
+                    if (add_jids.length) api.blocklist.add(add_jids, false);
+
+                    const remove_jids = sizzle(`unblock[xmlns="${Strophe.NS.BLOCKING}"] item`, stanza).map(
+                        /** @param {Element} item */ (item) => item.getAttribute('jid')
+                    );
+                    if (remove_jids.length) api.blocklist.remove(remove_jids, false);
+
+                    return true;
+                },
+                Strophe.NS.BLOCKING,
+                'iq',
+                'set'
+            );
+        });
+
+        api.listen.on('clearSession', () => {
+            const { state } = _converse;
+            if (state.blocklist) {
+                state.blocklist.clearStore({ 'silent': true });
+                window.sessionStorage.removeItem(state.blocklist.fetched_flag);
+                delete state.blocklist;
+            }
+        });
+
+        api.listen.on('discoInitialized', async () => {
+            const domain = _converse.session.get('domain');
+            if (await api.disco.supports(Strophe.NS.BLOCKING, domain)) {
+                _converse.state.blocklist = new _converse.exports.Blocklist();
+            }
+        });
+    },
+});

+ 216 - 0
src/headless/plugins/blocklist/tests/blocklist.js

@@ -0,0 +1,216 @@
+/*global mock, converse */
+const { u, stx } = converse.env;
+
+describe('A blocklist', function () {
+    beforeEach(() => {
+        jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza });
+        window.sessionStorage.removeItem('converse.blocklist-romeo@montague.lit-fetched');
+    });
+
+    it(
+        'is automatically fetched from the server once the user logs in',
+        mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+
+            const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+            const sent_stanza = await u.waitUntil(() => IQ_stanzas.find((s) => s.querySelector('iq blocklist')));
+
+            expect(sent_stanza).toEqualStanza(stx`
+                <iq xmlns="jabber:client" type="get" id="${sent_stanza.getAttribute('id')}">
+                    <blocklist xmlns="urn:xmpp:blocking"/>
+                </iq>`);
+
+            const stanza = stx`
+                    <iq xmlns="jabber:client"
+                        to="${_converse.api.connection.get().jid}"
+                        type="result"
+                        id="${sent_stanza.getAttribute('id')}">
+                    <blocklist xmlns='urn:xmpp:blocking'>
+                        <item jid='iago@shakespeare.lit'/>
+                        <item jid='juliet@capulet.lit'/>
+                    </blocklist>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            const blocklist = await api.waitUntil('blocklistInitialized');
+            expect(blocklist.length).toBe(2);
+            expect(blocklist.models.map((m) => m.get('jid'))).toEqual(['iago@shakespeare.lit', 'juliet@capulet.lit']);
+        })
+    );
+
+    it(
+        'is updated when the server sends IQ stanzas',
+        mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+            const { api, domain } = _converse;
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+
+            const IQ_stanzas = api.connection.get().IQ_stanzas;
+            let sent_stanza = await u.waitUntil(() => IQ_stanzas.find((s) => s.querySelector('iq blocklist')));
+
+            const stanza = stx`
+                    <iq xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="result"
+                        id="${sent_stanza.getAttribute('id')}">
+                    <blocklist xmlns='urn:xmpp:blocking'>
+                        <item jid='iago@shakespeare.lit'/>
+                    </blocklist>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            const blocklist = await api.waitUntil('blocklistInitialized');
+            expect(blocklist.length).toBe(1);
+
+            // The server sends a push IQ stanza
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(
+                    stx`
+                    <iq xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="set"
+                        id="${u.getUniqueId()}">
+                    <block xmlns='urn:xmpp:blocking'>
+                        <item jid='juliet@capulet.lit'/>
+                    </block>
+                </iq>`
+                )
+            );
+            await u.waitUntil(() => blocklist.length === 2);
+            expect(blocklist.models.map((m) => m.get('jid'))).toEqual(['iago@shakespeare.lit', 'juliet@capulet.lit']);
+
+            // The server sends a push IQ stanza
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(
+                    stx`
+                    <iq xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="set"
+                        id="${u.getUniqueId()}">
+                    <unblock xmlns='urn:xmpp:blocking'>
+                        <item jid='juliet@capulet.lit'/>
+                    </unblock>
+                </iq>`
+                )
+            );
+            await u.waitUntil(() => blocklist.length === 1);
+            expect(blocklist.models.map((m) => m.get('jid'))).toEqual(['iago@shakespeare.lit']);
+        })
+    );
+
+    it(
+        'can be updated via the api',
+        mock.initConverse(['discoInitialized'], {}, async function (_converse) {
+            const { api, domain } = _converse;
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+
+            const IQ_stanzas = api.connection.get().IQ_stanzas;
+            let sent_stanza = await u.waitUntil(() => IQ_stanzas.find((s) => s.querySelector('iq blocklist')));
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(
+                    stx`<iq xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="result"
+                        id="${sent_stanza.getAttribute('id')}">
+                    <blocklist xmlns='urn:xmpp:blocking'>
+                        <item jid='iago@shakespeare.lit'/>
+                    </blocklist>
+                </iq>`
+                )
+            );
+
+            const blocklist = await api.waitUntil('blocklistInitialized');
+            expect(blocklist.length).toBe(1);
+
+            api.blocklist.add('juliet@capulet.lit');
+
+            sent_stanza = await u.waitUntil(() => IQ_stanzas.find((s) => s.querySelector('iq block')));
+            expect(sent_stanza).toEqualStanza(stx`
+                <iq xmlns="jabber:client" type="set" id="${sent_stanza.getAttribute('id')}">
+                    <block xmlns='urn:xmpp:blocking'>
+                        <item jid='juliet@capulet.lit'/>
+                    </block>
+                </iq>`);
+
+            _converse.api.connection
+                .get()
+                ._dataRecv(
+                    mock.createRequest(
+                        stx`<iq xmlns="jabber:client" type="result" id="${sent_stanza.getAttribute('id')}"/>`
+                    )
+                );
+
+            await u.waitUntil(() => blocklist.length === 2);
+            expect(blocklist.models.map((m) => m.get('jid'))).toEqual(['iago@shakespeare.lit', 'juliet@capulet.lit']);
+
+            api.blocklist.remove('juliet@capulet.lit');
+
+            sent_stanza = await u.waitUntil(() => IQ_stanzas.find((s) => s.querySelector('iq unblock')));
+            expect(sent_stanza).toEqualStanza(stx`
+                <iq xmlns="jabber:client" type="set" id="${sent_stanza.getAttribute('id')}">
+                    <unblock xmlns='urn:xmpp:blocking'>
+                        <item jid='juliet@capulet.lit'/>
+                    </unblock>
+                </iq>`);
+
+            _converse.api.connection
+                .get()
+                ._dataRecv(
+                    mock.createRequest(
+                        stx`<iq xmlns="jabber:client" type="result" id="${sent_stanza.getAttribute('id')}"/>`
+                    )
+                );
+
+            await u.waitUntil(() => blocklist.length === 1);
+            expect(blocklist.models.map((m) => m.get('jid'))).toEqual(['iago@shakespeare.lit']);
+        })
+    );
+});
+
+describe('A Chat Message', function () {
+    it(
+        "will show an error message if it's rejected due to being banned",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 1);
+            const sender_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const chat = await api.chats.open(sender_jid);
+            const msg_text = 'This message will not be sent, due to an error';
+            const message = await chat.sendMessage({ body: msg_text });
+
+            api.connection.get()._dataRecv(mock.createRequest(stx`
+                <message xmlns="jabber:client"
+                    to="${api.connection.get().jid}"
+                    type="error"
+                    id="${message.get('msgid')}"
+                    from="${sender_jid}">
+                    <error type="cancel">
+                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                        <blocked xmlns='urn:xmpp:blocking:errors'/>
+                    </error>
+                </message>`));
+
+            await u.waitUntil(() => message.get('is_error') === true);
+            expect(message.get('error')).toBe('You are blocked from sending messages.');
+        })
+    );
+});

+ 34 - 0
src/headless/plugins/blocklist/utils.js

@@ -0,0 +1,34 @@
+import converse from '../../shared/api/public.js';
+import send_api from '../../shared/api/send.js';
+
+const { Strophe, stx, u } = converse.env;
+
+/**
+ * Sends an IQ stanza to remove one or more JIDs from the blocklist
+ * @param {string|string[]} jid
+ */
+export async function sendUnblockStanza(jid) {
+    const jids = Array.isArray(jid) ? jid : [jid];
+    const stanza = stx`
+        <iq xmlns="jabber:client" type="set" id="${u.getUniqueId()}">
+            <unblock xmlns="${Strophe.NS.BLOCKING}">
+                ${jids.map((id) => stx`<item jid="${id}"/>`)}
+            </unblock>
+        </iq>`;
+    await send_api.sendIQ(stanza);
+}
+
+/**
+ * Sends an IQ stanza to add one or more JIDs from the blocklist
+ * @param {string|string[]} jid
+ */
+export async function sendBlockStanza(jid) {
+    const jids = Array.isArray(jid) ? jid : [jid];
+    const stanza = stx`
+        <iq xmlns="jabber:client" type="set" id="${u.getUniqueId()}">
+            <block xmlns="${Strophe.NS.BLOCKING}">
+                ${jids.map((id) => stx`<item jid="${id}"/>`)}
+            </block>
+        </iq>`;
+    await send_api.sendIQ(stanza);
+}

+ 39 - 0
src/headless/plugins/bookmarks/api.js

@@ -0,0 +1,39 @@
+import _converse from '../../shared/_converse.js';
+import promise_api from '../../shared/api/promise.js';
+
+const { waitUntil } = promise_api;
+
+/**
+ * Groups methods relevant to XEP-0402 (and XEP-0048) MUC bookmarks.
+ * @namespace api.bookmarks
+ * @memberOf api
+ */
+const bookmarks = {
+    /**
+     * Calling this function will result in an IQ stanza being sent out to set
+     * the bookmark on the server.
+     *
+     * @method api.bookmarks.set
+     * @param {import('./types').BookmarkAttrs} attrs - The room attributes
+     * @param {boolean} create=true - Whether the bookmark should be created if it doesn't exist
+     * @returns {Promise<import('./model').default>}
+     */
+    async set(attrs, create = true) {
+        const bookmarks = await waitUntil('bookmarksInitialized');
+        return bookmarks.setBookmark(attrs, create);
+    },
+
+    /**
+     * @method api.bookmarks.get
+     * @param {string} jid - The JID of the bookmark to return.
+     * @returns {Promise<import('./model').default|undefined>}
+     */
+    async get(jid) {
+        const bookmarks = await waitUntil('bookmarksInitialized');
+        return bookmarks.get(jid);
+    },
+};
+
+const bookmarks_api = { bookmarks };
+
+export default bookmarks_api;

+ 188 - 116
src/headless/plugins/bookmarks/collection.js

@@ -1,39 +1,45 @@
 /**
  * @typedef {import('../muc/muc.js').default} MUC
  */
-import { Collection } from "@converse/skeletor";
+import { Stanza } from 'strophe.js';
+import { Collection } from '@converse/skeletor';
 import { getOpenPromise } from '@converse/openpromise';
-import "../../plugins/muc/index.js";
 import Bookmark from './model.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import log from "../../log.js";
+import { parseErrorStanza } from '../../shared/parsers.js';
+import log from '../../log.js';
 import { initStorage } from '../../utils/storage.js';
+import { parseStanzaForBookmarks } from './parsers.js';
+import '../../plugins/muc/index.js';
 
-const { Strophe, $iq, sizzle } = converse.env;
-
+const { Strophe, stx } = converse.env;
 
 class Bookmarks extends Collection {
+    get idAttribute() {
+        return 'jid';
+    }
 
-    async initialize () {
-        this.on('add', bm => this.openBookmarkedRoom(bm)
-            .then(bm => this.markRoomAsBookmarked(bm))
-            .catch(e => log.fatal(e))
+    async initialize() {
+        this.on('add', (bm) =>
+            this.openBookmarkedRoom(bm)
+                .then((bm) => this.markRoomAsBookmarked(bm))
+                .catch((e) => log.fatal(e))
         );
-
-        this.on('remove', this.markRoomAsUnbookmarked, this);
+        this.on('remove', this.leaveRoom, this);
+        this.on('change:autojoin', this.onAutoJoinChanged, this);
         this.on('remove', this.sendBookmarkStanza, this);
 
         const { session } = _converse;
         const cache_key = `converse.room-bookmarks${session.get('bare_jid')}`;
-        this.fetched_flag = cache_key+'fetched';
+        this.fetched_flag = cache_key + 'fetched';
         initStorage(this, cache_key);
 
         await this.fetchBookmarks();
 
         /**
-         * Triggered once the _converse.Bookmarks collection
+         * Triggered once the {@link Bookmarks} collection
          * has been created and cached bookmarks have been fetched.
          * @event _converse#bookmarksInitialized
          * @type {Bookmarks}
@@ -42,7 +48,7 @@ class Bookmarks extends Collection {
         api.trigger('bookmarksInitialized', this);
     }
 
-    static async checkBookmarksSupport () {
+    static async checkBookmarksSupport() {
         const bare_jid = _converse.session.get('bare_jid');
         if (!bare_jid) return false;
 
@@ -54,32 +60,31 @@ class Bookmarks extends Collection {
         }
     }
 
-    constructor () {
-        super([], { comparator: (/** @type {Bookmark} */b) => b.get('name').toLowerCase() });
+    constructor() {
+        super([], { comparator: (/** @type {Bookmark} */ b) => b.get('name').toLowerCase() });
         this.model = Bookmark;
     }
 
-
     /**
      * @param {Bookmark} bookmark
      */
-    async openBookmarkedRoom (bookmark) {
-        if ( api.settings.get('muc_respect_autojoin') && bookmark.get('autojoin')) {
-            const groupchat = await api.rooms.create(
-                bookmark.get('jid'),
-                {'nick': bookmark.get('nick')}
-            );
+    async openBookmarkedRoom(bookmark) {
+        if (api.settings.get('muc_respect_autojoin') && bookmark.get('autojoin')) {
+            const groupchat = await api.rooms.create(bookmark.get('jid'), {
+                nick: bookmark.get('nick'),
+                password: bookmark.get('password'),
+            });
             groupchat.maybeShow();
         }
         return bookmark;
     }
 
-    fetchBookmarks () {
+    fetchBookmarks() {
         const deferred = getOpenPromise();
         if (window.sessionStorage.getItem(this.fetched_flag)) {
             this.fetch({
-                'success': () => deferred.resolve(),
-                'error': () => deferred.resolve()
+                success: () => deferred.resolve(),
+                error: () => deferred.resolve(),
             });
         } else {
             this.fetchBookmarksFromServer(deferred);
@@ -87,72 +92,123 @@ class Bookmarks extends Collection {
         return deferred;
     }
 
-    createBookmark (options) {
-        this.create(options);
-        this.sendBookmarkStanza().catch(iq => this.onBookmarkError(iq, options));
-    }
+    /**
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    setBookmark(attrs, create=true) {
+        if (!attrs.jid) return log.warn('No JID provided for setBookmark');
 
-    sendBookmarkStanza () {
-        const stanza = $iq({
-                'type': 'set',
-                'from': api.connection.get().jid,
-            })
-            .c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                .c('publish', {'node': Strophe.NS.BOOKMARKS})
-                    .c('item', {'id': 'current'})
-                        .c('storage', {'xmlns': Strophe.NS.BOOKMARKS});
+        let send_stanza = false;
 
-        this.forEach(/** @param {MUC} model */(model) => {
-            stanza.c('conference', {
-                'name': model.get('name'),
-                'autojoin': model.get('autojoin'),
-                'jid': model.get('jid'),
-            });
-            const nick = model.get('nick');
-            if (nick) {
-                stanza.c('nick').t(nick).up().up();
-            } else {
-                stanza.up();
+        const existing = this.get(attrs.jid);
+        if (existing) {
+            // Check if any attrs changed
+            const has_changed = Object.keys(attrs).reduce((result, k) => {
+                return result || (attrs[k] ?? '') !== (existing.attributes[k] ?? '');
+            }, false);
+            if (has_changed) {
+                existing.save(attrs);
+                send_stanza = true;
             }
+        } else if (create) {
+            this.create(attrs);
+            send_stanza = true;
+        }
+        if (send_stanza) {
+            this.sendBookmarkStanza().catch((iq) => this.onBookmarkError(iq, attrs));
+        }
+    }
+
+    /**
+     * @param {'urn:xmpp:bookmarks:1'|'storage:bookmarks'} node
+     * @returns {Stanza|Stanza[]}
+     */
+    getPublishedItems(node) {
+        if (node === Strophe.NS.BOOKMARKS2) {
+            return this.map(
+                /** @param {MUC} model */ (model) => {
+                    const extensions = model.get('extensions') ?? [];
+                    return stx`<item id="${model.get('jid')}">
+                    <conference xmlns="${Strophe.NS.BOOKMARKS2}"
+                                name="${model.get('name')}"
+                                autojoin="${model.get('autojoin')}">
+                            ${model.get('nick') ? stx`<nick>${model.get('nick')}</nick>` : ''}
+                            ${model.get('password') ? stx`<password>${model.get('password')}</password>` : ''}
+                        ${
+                            extensions.length
+                                ? stx`<extensions>${extensions.map((e) => Stanza.unsafeXML(e))}</extensions>`
+                                : ''
+                        };
+                        </conference>
+                    </item>`;
+                }
+            );
+        } else {
+            return stx`<item id="current">
+                <storage xmlns="${Strophe.NS.BOOKMARKS}">
+                ${this.map(
+                    /** @param {MUC} model */ (model) =>
+                        stx`<conference name="${model.get('name')}" autojoin="${model.get('autojoin')}"
+                        jid="${model.get('jid')}">
+                        ${model.get('nick') ? stx`<nick>${model.get('nick')}</nick>` : ''}
+                        ${model.get('password') ? stx`<password>${model.get('password')}</password>` : ''}
+                    </conference>`
+                )}
+                </storage>
+            </item>`;
+        }
+    }
+
+    /**
+     * @returns {Promise<void|Element>}
+     */
+    async sendBookmarkStanza() {
+        const bare_jid = _converse.session.get('bare_jid');
+        const node = (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid))
+            ? Strophe.NS.BOOKMARKS2
+            : Strophe.NS.BOOKMARKS;
+        return api.pubsub.publish(null, node, this.getPublishedItems(node), {
+            persist_items: true,
+            max_items: 'max',
+            send_last_published_item: 'never',
+            access_model: 'whitelist',
         });
-        stanza.up().up().up();
-        stanza.c('publish-options')
-            .c('x', {'xmlns': Strophe.NS.XFORM, 'type':'submit'})
-                .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
-                    .c('value').t('http://jabber.org/protocol/pubsub#publish-options').up().up()
-                .c('field', {'var':'pubsub#persist_items'})
-                    .c('value').t('true').up().up()
-                .c('field', {'var':'pubsub#access_model'})
-                    .c('value').t('whitelist');
-        return api.sendIQ(stanza);
-    }
-
-    onBookmarkError (iq, options) {
-        const { __ } = _converse;
-        log.error("Error while trying to add bookmark");
+    }
+
+    /**
+     * @param {Element} iq
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    onBookmarkError(iq, attrs) {
+        log.error('Error while trying to add bookmark');
         log.error(iq);
-        api.alert(
-            'error', __('Error'), [__("Sorry, something went wrong while trying to save your bookmark.")]
-        );
-        this.get(options.jid)?.destroy();
+        this.get(attrs.jid)?.destroy();
     }
 
-    fetchBookmarksFromServer (deferred) {
-        const stanza = $iq({
-            'from': api.connection.get().jid,
-            'type': 'get',
-        }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-            .c('items', {'node': Strophe.NS.BOOKMARKS});
+    /**
+     * @param {Promise} deferred
+     */
+    async fetchBookmarksFromServer(deferred) {
+        const bare_jid = _converse.session.get('bare_jid');
+        const ns = (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid))
+            ? Strophe.NS.BOOKMARKS2
+            : Strophe.NS.BOOKMARKS;
+
+        const stanza = stx`
+            <iq type="get" from="${api.connection.get().jid}" xmlns="jabber:client">
+                <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                    <items node="${ns}"/>
+                </pubsub>
+            </iq>`;
         api.sendIQ(stanza)
-            .then(iq => this.onBookmarksReceived(deferred, iq))
-            .catch(iq => this.onBookmarksReceivedError(deferred, iq)
-        );
+            .then(/** @param {Element} iq */ (iq) => this.onBookmarksReceived(deferred, iq))
+            .catch(/** @param {Element} iq */ (iq) => this.onBookmarksReceivedError(deferred, iq));
     }
 
     /**
      * @param {Bookmark} bookmark
      */
-    markRoomAsBookmarked (bookmark) {
+    markRoomAsBookmarked(bookmark) {
         const { chatboxes } = _converse.state;
         const groupchat = chatboxes.get(bookmark.get('jid'));
         groupchat?.save('bookmarked', true);
@@ -161,68 +217,84 @@ class Bookmarks extends Collection {
     /**
      * @param {Bookmark} bookmark
      */
-    markRoomAsUnbookmarked (bookmark) {
-        const { chatboxes } = _converse.state;
-        const groupchat = chatboxes.get(bookmark.get('jid'));
-        groupchat?.save('bookmarked', false);
+    onAutoJoinChanged(bookmark) {
+        if (bookmark.get('autojoin')) {
+            this.openBookmarkedRoom(bookmark);
+        } else {
+            this.leaveRoom(bookmark);
+        }
+    }
+
+    /**
+     * @param {Bookmark} bookmark
+     */
+    async leaveRoom(bookmark) {
+        const groupchat = await api.rooms.get(bookmark.get('jid'));
+        groupchat?.close();
     }
 
     /**
      * @param {Element} stanza
      */
-    createBookmarksFromStanza (stanza) {
-        const xmlns = Strophe.NS.BOOKMARKS;
-        const sel = `items[node="${xmlns}"] item storage[xmlns="${xmlns}"] conference`;
-        sizzle(sel, stanza).forEach(/** @type {Element} */(el) => {
-            const jid = el.getAttribute('jid');
-            const bookmark = this.get(jid);
-            const attrs = {
-                'jid': jid,
-                'name': el.getAttribute('name') || jid,
-                'autojoin': el.getAttribute('autojoin') === 'true',
-                'nick': el.querySelector('nick')?.textContent || ''
+    async setBookmarksFromStanza(stanza) {
+        const bookmarks = await parseStanzaForBookmarks(stanza);
+        bookmarks.forEach(
+            /** @param {import('./types.js').BookmarkAttrs} attrs */
+            (attrs) => {
+                const bookmark = this.get(attrs.jid);
+                bookmark ? bookmark.save(attrs) : this.create(attrs);
             }
-            bookmark ? bookmark.save(attrs) : this.create(attrs);
-        });
+        );
     }
 
-    onBookmarksReceived (deferred, iq) {
-        this.createBookmarksFromStanza(iq);
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    async onBookmarksReceived(deferred, iq) {
+        await this.setBookmarksFromStanza(iq);
         window.sessionStorage.setItem(this.fetched_flag, 'true');
         if (deferred !== undefined) {
             return deferred.resolve();
         }
     }
 
-    onBookmarksReceivedError (deferred, iq) {
-        const { __ } = _converse;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    async onBookmarksReceivedError(deferred, iq) {
         if (iq === null) {
+            const { __ } = _converse;
             log.error('Error: timeout while fetching bookmarks');
-            api.alert('error', __('Timeout Error'),
-                [__("The server did not return your bookmarks within the allowed time. "+
-                    "You can reload the page to request them again.")]
-            );
-        } else if (deferred) {
-            if (iq.querySelector('error[type="cancel"] item-not-found')) {
+            api.alert('error', __('Timeout Error'), [
+                __(
+                    'The server did not return your bookmarks within the allowed time. ' +
+                        'You can reload the page to request them again.'
+                ),
+            ]);
+            deferred?.reject(new Error('Could not fetch bookmarks'));
+
+        } else {
+            const { errors } = converse.env;
+            const e = await parseErrorStanza(iq);
+            if (e instanceof errors.ItemNotFoundError) {
                 // Not an exception, the user simply doesn't have any bookmarks.
                 window.sessionStorage.setItem(this.fetched_flag, 'true');
-                return deferred.resolve();
+                deferred?.resolve();
             } else {
                 log.error('Error while fetching bookmarks');
-                log.error(iq);
-                return deferred.reject(new Error("Could not fetch bookmarks"));
+                if (iq) log.error(iq);
+                deferred?.reject(new Error('Could not fetch bookmarks'));
             }
-        } else {
-            log.error('Error while fetching bookmarks');
-            log.error(iq);
         }
     }
 
-    async getUnopenedBookmarks () {
-        await api.waitUntil('bookmarksInitialized')
-        await api.waitUntil('chatBoxesFetched')
+    async getUnopenedBookmarks() {
+        await api.waitUntil('bookmarksInitialized');
+        await api.waitUntil('chatBoxesFetched');
         const { chatboxes } = _converse.state;
-        return this.filter(b => !chatboxes.get(b.get('jid')));
+        return this.filter((b) => !chatboxes.get(b.get('jid')));
     }
 }
 

+ 2 - 2
src/headless/plugins/bookmarks/model.js

@@ -1,10 +1,10 @@
-import converse from '../../shared/api/public.js';
 import { Model } from '@converse/skeletor';
+import converse from '../../shared/api/public.js';
 
 const { Strophe } = converse.env;
 
 class Bookmark extends Model {
-    get idAttribute () { // eslint-disable-line class-methods-use-this
+    get idAttribute () {
         return 'jid';
     }
 

+ 35 - 0
src/headless/plugins/bookmarks/parsers.js

@@ -0,0 +1,35 @@
+import converse from '../../shared/api/public.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+
+const { Strophe, sizzle } = converse.env;
+
+/**
+ * @param {Element} stanza
+ * @returns {Promise<Array<import('./types.js').BookmarkAttrs>>}
+ */
+export async function parseStanzaForBookmarks(stanza) {
+    let ns;
+    let sel;
+    const bare_jid = _converse.session.get('bare_jid');
+    if (await api.disco.supports(`${Strophe.NS.BOOKMARKS2}#compat`, bare_jid)) {
+        ns = Strophe.NS.BOOKMARKS2;
+        sel = `items[node="${ns}"] item conference`;
+    } else {
+        ns = Strophe.NS.BOOKMARKS;
+        sel = `items[node="${ns}"] item storage[xmlns="${ns}"] conference`;
+    }
+    return sizzle(sel, stanza).map(
+        /** @param {Element} el */ (el) => {
+            const jid = ns === Strophe.NS.BOOKMARKS2 ? el.parentElement.getAttribute('id') : el.getAttribute('jid');
+            return {
+                jid,
+                name: el.getAttribute('name') || jid,
+                autojoin: ['1', 'true'].includes(el.getAttribute('autojoin')),
+                nick: el.querySelector('nick')?.textContent ?? '',
+                password: el.querySelector('password')?.textContent ?? '',
+                extensions: Array.from(el.querySelector('extensions')?.children ?? []).map(c => c.outerHTML),
+            };
+        }
+    );
+}

+ 81 - 24
src/headless/plugins/bookmarks/plugin.js

@@ -1,23 +1,24 @@
 /**
- * @copyright 2022, the Converse.js contributors
+ * @copyright 2025, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "../../plugins/muc/index.js";
 import Bookmark from './model.js';
 import Bookmarks from './collection.js';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
 import { initBookmarks, getNicknameFromBookmark, handleBookmarksPush } from './utils.js';
+import '../../plugins/muc/index.js';
+import log from '../../log';
+import bookmarks_api from './api.js';
 
 const { Strophe } = converse.env;
 
 Strophe.addNamespace('BOOKMARKS', 'storage:bookmarks');
-
+Strophe.addNamespace('BOOKMARKS2', 'urn:xmpp:bookmarks:1');
 
 converse.plugins.add('converse-bookmarks', {
-
-    dependencies: ["converse-chatboxes", "converse-muc"],
+    dependencies: ['converse-chatboxes', 'converse-muc'],
 
     overrides: {
         // Overrides mentioned here will be picked up by converse.js's
@@ -26,21 +27,15 @@ converse.plugins.add('converse-bookmarks', {
         // New functions which don't exist yet can also be added.
 
         ChatRoom: {
-            getDisplayName () {
-                const { _converse, getDisplayName } = this.__super__;
-                const { bookmarks } = _converse.state;
-                const bookmark = this.get('bookmarked') ? bookmarks?.get(this.get('jid')) : null;
-                return bookmark?.get('name') || getDisplayName.apply(this, arguments);
-            },
-
-            getAndPersistNickname (nick) {
+            /** @param {string} nick */
+            getAndPersistNickname(nick) {
                 nick = nick || getNicknameFromBookmark(this.get('jid'));
                 return this.__super__.getAndPersistNickname.call(this, nick);
-            }
-        }
+            },
+        },
     },
 
-    initialize () {
+    initialize() {
         // Configuration values for this plugin
         // ====================================
         // Refer to docs/source/configuration.rst for explanations of these
@@ -48,36 +43,98 @@ converse.plugins.add('converse-bookmarks', {
         api.settings.extend({
             allow_bookmarks: true,
             allow_public_bookmarks: false,
-            muc_respect_autojoin: true
+            muc_respect_autojoin: true,
         });
 
         api.promises.add('bookmarksInitialized');
 
-        const exports  = { Bookmark, Bookmarks };
+        Object.assign(api, bookmarks_api);
+
+        const exports = { Bookmark, Bookmarks };
         Object.assign(_converse, exports); // TODO: DEPRECATED
         Object.assign(_converse.exports, exports);
 
+        api.listen.on(
+            'parseMUCPresence',
+            /**
+             * @param {Element} _stanza
+             * @param {import('../muc/types').MUCPresenceAttributes} attrs
+             */
+            (_stanza, attrs) => {
+                if (attrs.is_self && attrs.codes.includes('303')) {
+                    api.bookmarks.get(attrs.muc_jid).then(
+                        /** @param {Bookmark} bookmark */ (bookmark) => {
+                            if (!bookmark) log.warn('parseMUCPresence: no bookmark returned');
+
+                            const { nick, muc_jid: jid } = attrs;
+                            api.bookmarks.set({
+                                jid,
+                                nick,
+                                autojoin: bookmark?.get('autojoin') ?? true,
+                                password: bookmark?.get('password') ?? '',
+                                name: bookmark?.get('name') ?? '',
+                                extensions: bookmark?.get('extensions') ?? [],
+                            });
+                        }
+                    );
+                }
+                return attrs;
+            }
+        );
+
+        api.listen.on(
+            'enteredNewRoom',
+            /** @param {import('../muc/muc').default} muc */
+            async ({ attributes }) => {
+                const { jid, nick, password, name } = /** @type {import("../muc/types").MUCAttributes} */ (attributes);
+                await api.bookmarks.set({
+                    jid,
+                    autojoin: true,
+                    nick,
+                    ...(password ? { password } : {}),
+                    ...(name ? { name } : {}),
+                });
+            }
+        );
+
+        api.listen.on(
+            'leaveRoom',
+            /** @param {import('../muc/muc').default} muc */
+            async ({ attributes }) => {
+                const { jid } = /** @type {import("../muc/types").MUCAttributes} */ (attributes);
+                await api.bookmarks.set(
+                    {
+                        jid,
+                        autojoin: false,
+                    },
+                    false
+                );
+            }
+        );
+
         api.listen.on('addClientFeatures', () => {
             if (api.settings.get('allow_bookmarks')) {
-                api.disco.own.features.add(Strophe.NS.BOOKMARKS + '+notify')
+                api.disco.own.features.add(Strophe.NS.BOOKMARKS + '+notify');
             }
-        })
+        });
 
         api.listen.on('clearSession', () => {
             const { state } = _converse;
             if (state.bookmarks) {
-                state.bookmarks.clearStore({'silent': true});
+                state.bookmarks.clearStore({ 'silent': true });
                 window.sessionStorage.removeItem(state.bookmarks.fetched_flag);
                 delete state.bookmarks;
             }
         });
 
-        api.listen.on('connected', async () =>  {
+        api.listen.on('connected', async () => {
             // Add a handler for bookmarks pushed from other connected clients
             const bare_jid = _converse.session.get('bare_jid');
-            api.connection.get().addHandler(handleBookmarksPush, null, 'message', 'headline', null, bare_jid);
+            const connection = api.connection.get();
+            connection.addHandler(handleBookmarksPush, Strophe.NS.BOOKMARKS, 'message', 'headline', null, bare_jid);
+            connection.addHandler(handleBookmarksPush, Strophe.NS.BOOKMARKS2, 'message', 'headline', null, bare_jid);
             await Promise.all([api.waitUntil('chatBoxesFetched')]);
             initBookmarks();
         });
-    }
+    },
 });

+ 430 - 67
src/headless/plugins/bookmarks/tests/bookmarks.js

@@ -1,38 +1,287 @@
 /* global mock, converse */
+const { Strophe, sizzle, stx, u } = converse.env;
 
-describe("A chat room", function () {
+
+describe("A bookmark", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("is automatically created when a MUC is entered", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(_converse);
+
+        const nick = 'JC';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        const settings = { name: "Play's the thing", password: 'secret' };
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="${_converse.bare_jid}"
+                    to="${_converse.bare_jid}"
+                    id="${sent_stanza.getAttribute('id')}"
+                    type="set"
+                    xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc.get('jid')}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="${settings.name}">
+                                <nick>${nick}</nick>
+                                <password>${settings.password}</password>
+                            </conference>
+                        </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`
+        );
+
+        const stanza = stx`<iq
+            xmlns="jabber:client"
+            to="${_converse.api.connection.get().jid}"
+            type="result"
+            id="${sent_stanza.getAttribute('id')}"/>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        expect(muc.get('bookmarked')).toBeTruthy();
+    }));
+
+    it("will be updated when a user changes their nickname in a MUC", mock.initConverse(
+        [], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(_converse);
+
+        const nick = 'JC';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        const settings = { name: "Play's the thing", password: 'secret' };
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        let sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        const stanza = stx`<iq
+            xmlns="jabber:client"
+            to="${_converse.api.connection.get().jid}"
+            type="result"
+            id="${sent_stanza.getAttribute('id')}"/>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        const newnick = 'BAP';
+        muc.setNickname(newnick);
+
+        const sent_IQs = _converse.api.connection.get().IQ_stanzas;
+        while (sent_IQs.length) { sent_IQs.pop(); }
+
+        _converse.api.connection.get()._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>`
+        ));
+
+        await u.waitUntil(() => muc.get('nick') === newnick);
+
+        _converse.api.connection.get()._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>`
+        ));
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="${_converse.bare_jid}"
+                    to="${_converse.bare_jid}"
+                    id="${sent_stanza.getAttribute('id')}"
+                    type="set"
+                    xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" name="${settings.name}" autojoin="true">
+                                <nick>${newnick}</nick>
+                                <password>${settings.password}</password>
+                            </conference>
+                        </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`
+        );
+    }));
 
     describe("when autojoin is set", function () {
 
-        it("will be be opened and joined automatically upon login", mock.initConverse(
+        it("will cause a MUC to be opened and joined automatically upon login", mock.initConverse(
                 [], {}, async function (_converse) {
 
+            const { api, state } = _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 { bookmarks } = _converse.state;
+
+            let jid = 'theplay@conference.shakespeare.lit';
             const model = bookmarks.create({
-                'jid': jid,
-                'autojoin': false,
-                'name':  'The Play',
-                'nick': ''
+                jid,
+                autojoin: false,
+                name:  'The Play',
+                nick: ''
             });
             expect(_converse.api.rooms.create).not.toHaveBeenCalled();
+
+            // Check that we don't auto-join if muc_respect_autojoin is false
+            api.settings.set('muc_respect_autojoin', false);
+            bookmarks.create({
+                jid,
+                autojoin: true,
+                name:  'The Play',
+                nick: ''
+            });
+            expect(_converse.api.rooms.create).not.toHaveBeenCalled();
+
+            api.settings.set('muc_respect_autojoin', true);
             bookmarks.remove(model);
+
             bookmarks.create({
-                'jid': jid,
-                'autojoin': true,
-                'name':  'Hamlet',
-                'nick': ''
+                jid,
+                autojoin: true,
+                name:  'Hamlet',
+                nick: ''
             });
             expect(_converse.api.rooms.create).toHaveBeenCalled();
+            await u.waitUntil(() => state.chatboxes.length === 2);
+
+            bookmarks.remove(model);
+            await u.waitUntil(() => state.chatboxes.length === 1);
         }));
-    });
-});
 
+        it("has autojoin set to false upon leaving", mock.initConverse([], {}, async function (_converse) {
+            const { u } = converse.env;
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.waitUntilBookmarksReturned(_converse);
 
-describe("A bookmark", function () {
+            const nick = 'romeo';
+            const muc_jid = 'theplay@conference.shakespeare.lit';
+            const settings = { name:  'The Play' };
+            const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+            const { bookmarks } = _converse.state;
+            await u.waitUntil(() => bookmarks.length);
+            await u.waitUntil(() => muc.get('bookmarked'));
+            spyOn(bookmarks, 'sendBookmarkStanza').and.callThrough();
+
+            const sent_IQs = _converse.api.connection.get().IQ_stanzas;
+            while (sent_IQs.length) { sent_IQs.pop(); }
+
+            await muc.close();
+            await u.waitUntil(() => sent_IQs.length);
+
+            // Check that an IQ stanza is sent out, containing no
+            // conferences to bookmark (since we removed the one and
+            // only bookmark).
+            const sent_stanza = sent_IQs.pop();
+            expect(sent_stanza).toEqualStanza(
+                stx`<iq from="${_converse.bare_jid}"
+                        to="${_converse.bare_jid}"
+                        id="${sent_stanza.getAttribute('id')}"
+                        type="set"
+                        xmlns="jabber:client">
+                    <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                        <publish node="urn:xmpp:bookmarks:1">
+                            <item id="${muc_jid}">
+                                <conference xmlns="urn:xmpp:bookmarks:1" name="${settings.name}" autojoin="false">
+                                    <nick>${nick}</nick>
+                                </conference>
+                            </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#max_items'>
+                                    <value>max</value>
+                                </field>
+                                <field var='pubsub#send_last_published_item'>
+                                    <value>never</value>
+                                </field>
+                                <field var='pubsub#access_model'>
+                                    <value>whitelist</value>
+                                </field>
+                            </x>
+                        </publish-options>
+                    </pubsub>
+                </iq>`
+            );
+        }));
+    });
 
     it("can be created and sends out a stanza", mock.initConverse(
             ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
@@ -40,12 +289,12 @@ describe("A bookmark", function () {
         await mock.waitForRoster(_converse, 'current', 0);
         await mock.waitUntilBookmarksReturned(_converse);
 
-        const jid = _converse.session.get('jid');
+        const bare_jid = _converse.session.get('bare_jid');
         const muc1_jid = 'theplay@conference.shakespeare.lit';
-        const { Strophe, sizzle, u } = converse.env;
         const { bookmarks } = _converse.state;
+        const { api } = _converse;
 
-        bookmarks.createBookmark({
+        await api.bookmarks.set({
             jid: muc1_jid,
             autojoin: true,
             name:  'Hamlet',
@@ -54,33 +303,41 @@ describe("A bookmark", function () {
 
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         let sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('item[id="current"]', s).length).pop());
-
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="${jid}" 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="${muc1_jid}" name="Hamlet"/>`+
-                            '</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>');
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${bare_jid}" to="${bare_jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
 
 
         const muc2_jid = 'balcony@conference.shakespeare.lit';
-        bookmarks.createBookmark({
+        await api.bookmarks.set({
             jid: muc2_jid,
             autojoin: true,
             name:  'Balcony',
@@ -88,31 +345,137 @@ describe("A bookmark", function () {
         });
 
         sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle('item[id="current"] conference[name="Balcony"]', s).length).pop());
-
-        expect(Strophe.serialize(sent_stanza)).toBe(
-            `<iq from="${jid}" 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="${muc2_jid}" name="Balcony">`+
-                                    '<nick>romeo</nick>'+
-                                '</conference>'+
-                                `<conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>`+
-                            '</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>');
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Balcony"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${bare_jid}" to="${bare_jid}" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc2_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Balcony">
+                                <nick>romeo</nick>
+                            </conference>
+                        </item>
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+
+        const muc3_jid = 'garden@conference.shakespeare.lit';
+        await api.bookmarks.set({
+            jid: muc3_jid,
+            autojoin: false,
+            name:  'Garden',
+            nick: 'r0meo',
+            password: 'secret',
+            extensions: [
+                '<state xmlns="http://myclient.example/bookmark/state" minimized="true"/>',
+                '<levels xmlns="http://myclient.example/bookmark/levels" amount="9000"/>',
+            ],
+        });
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('publish[node="urn:xmpp:bookmarks:1"] conference[name="Garden"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq xmlns="jabber:client" type="set" from="${bare_jid}" to="${bare_jid}" id="${sent_stanza.getAttribute('id')}">
+                <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                    <publish node="urn:xmpp:bookmarks:1">
+                        <item id="${muc2_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Balcony">
+                                <nick>romeo</nick>
+                            </conference>
+                        </item>
+                        <item id="${muc3_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="false" name="Garden">
+                                <nick>r0meo</nick>
+                                <password>secret</password>
+                                <extensions>
+                                    <state xmlns="http://myclient.example/bookmark/state" minimized="true"/>
+                                    <levels xmlns="http://myclient.example/bookmark/levels" amount="9000"/>
+                                </extensions>
+                            </conference>
+                        </item>
+                        <item id="${muc1_jid}">
+                            <conference xmlns="urn:xmpp:bookmarks:1" autojoin="true" name="Hamlet"/>
+                        </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
     }));
+
+    it("handles missing bookmarks gracefully when server responds with item-not-found", 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', 'urn:xmpp:bookmarks:1#compat']
+            );
+
+            const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+            const sent_stanza = await u.waitUntil(
+                () => IQ_stanzas.filter(s => sizzle(`items[node="urn:xmpp:bookmarks:1"]`, s).length).pop());
+
+            // Simulate server response with item-not-found error
+            const error_stanza = stx`
+                <iq xmlns="jabber:client" type="error"
+                        id="${sent_stanza.getAttribute('id')}"
+                        from="${sent_stanza.getAttribute('to')}"
+                        to="${sent_stanza.getAttribute('from')}">
+                    <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                        <items node="urn:xmpp:bookmarks:1"/>
+                    </pubsub>
+                    <error code="404" type="cancel">
+                        <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(error_stanza));
+
+            const cache_key = `converse.room-bookmarksromeo@montague.litfetched`;
+            const result = await u.waitUntil(() => window.sessionStorage.getItem(cache_key));
+            expect(result).toBe('true');
+        })
+    );
 });

+ 258 - 0
src/headless/plugins/bookmarks/tests/deprecated.js

@@ -0,0 +1,258 @@
+const { sizzle, stx, u } = converse.env;
+
+describe("A chat room", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("is automatically bookmarked when opened", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
+        );
+
+        const nick = 'JC';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        const settings = { name: "Play's the thing", password: 'secret' };
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="${_converse.bare_jid}"
+                    to="${_converse.bare_jid}"
+                    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="${muc_jid}" name="${settings.name}">
+                                    <nick>${nick}</nick>
+                                    <password>${settings.password}</password>
+                                </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</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 = stx`<iq
+            xmlns="jabber:client"
+            to="${_converse.api.connection.get().jid}"
+            type="result"
+            id="${sent_stanza.getAttribute('id')}"/>`;
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+        expect(muc.get('bookmarked')).toBeTruthy();
+    }));
+});
+
+describe("A bookmark", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("has autojoin set to false upon leaving", mock.initConverse([], {}, async function (_converse) {
+        const { u } = converse.env;
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
+        );
+
+        const nick = 'romeo';
+        const muc_jid = 'theplay@conference.shakespeare.lit';
+        const settings = { name:  'The Play' };
+        const muc = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, settings);
+
+        const { bookmarks } = _converse.state;
+        await u.waitUntil(() => bookmarks.length);
+        await u.waitUntil(() => muc.get('bookmarked'));
+        spyOn(bookmarks, 'sendBookmarkStanza').and.callThrough();
+
+        const sent_IQs = _converse.api.connection.get().IQ_stanzas;
+        while (sent_IQs.length) { sent_IQs.pop(); }
+
+        await muc.close();
+        await u.waitUntil(() => sent_IQs.length);
+
+        // Check that an IQ stanza is sent out, containing no
+        // conferences to bookmark (since we removed the one and
+        // only bookmark).
+        const sent_stanza = sent_IQs.pop();
+        expect(sent_stanza).toEqualStanza(
+            stx`<iq from="${_converse.bare_jid}"
+                    to="${_converse.bare_jid}"
+                    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 jid="${muc_jid}" name="${settings.name}" autojoin="false">
+                                <nick>${nick}</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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`
+        );
+    }));
+
+    it("can be created and sends out a stanza", mock.initConverse(
+            ['connected', 'chatBoxesFetched'], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.waitUntilBookmarksReturned(
+            _converse,
+            [],
+            ['http://jabber.org/protocol/pubsub#publish-options'],
+            'storage:bookmarks'
+        );
+
+        const bare_jid = _converse.session.get('bare_jid');
+        const muc1_jid = 'theplay@conference.shakespeare.lit';
+        const { bookmarks } = _converse.state;
+
+        bookmarks.setBookmark({
+            jid: muc1_jid,
+            autojoin: true,
+            name:  'Hamlet',
+            nick: ''
+        });
+
+        const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+        let sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('item[id="current"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${bare_jid}" to="${bare_jid}" 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="${muc1_jid}" name="Hamlet"/>
+                            </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+
+
+        const muc2_jid = 'balcony@conference.shakespeare.lit';
+        bookmarks.setBookmark({
+            jid: muc2_jid,
+            autojoin: true,
+            name:  'Balcony',
+            nick: 'romeo'
+        });
+
+        sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle('item[id="current"] conference[name="Balcony"]', s).length).pop());
+
+        expect(sent_stanza).toEqualStanza(stx`
+            <iq from="${bare_jid}" to="${bare_jid}" 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="${muc2_jid}" name="Balcony">
+                                    <nick>romeo</nick>
+                                </conference>
+                                <conference autojoin="true" jid="${muc1_jid}" name="Hamlet"/>
+                            </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#max_items'>
+                                <value>max</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item'>
+                                <value>never</value>
+                            </field>
+                            <field var='pubsub#access_model'>
+                                <value>whitelist</value>
+                            </field>
+                        </x>
+                    </publish-options>
+                </pubsub>
+            </iq>`);
+    }));
+});

+ 8 - 0
src/headless/plugins/bookmarks/types.ts

@@ -0,0 +1,8 @@
+export type BookmarkAttrs = {
+    jid: string;
+    name?: string;
+    autojoin?: boolean;
+    nick?: string;
+    password?: string;
+    extensions: string[];
+}

+ 15 - 12
src/headless/plugins/bookmarks/utils.js

@@ -1,12 +1,9 @@
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
-import converse from '../../shared/api/public.js';
-import log from "../../log.js";
+import log from '../../log.js';
 import Bookmarks from './collection.js';
 
-const { Strophe, sizzle } = converse.env;
-
-export async function initBookmarks () {
+export async function initBookmarks() {
     if (!api.settings.get('allow_bookmarks')) {
         return;
     }
@@ -16,18 +13,24 @@ export async function initBookmarks () {
     }
 }
 
-export function getNicknameFromBookmark (jid) {
+/**
+ * @param {string} jid - The JID of the bookmark.
+ * @returns {string|null} The nickname if found, otherwise null.
+ */
+export function getNicknameFromBookmark(jid) {
     if (!api.settings.get('allow_bookmarks')) {
         return null;
     }
     return _converse.state.bookmarks?.get(jid)?.get('nick');
 }
 
-export function handleBookmarksPush (message) {
-    if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"] items[node="${Strophe.NS.BOOKMARKS}"]`, message).length) {
-        api.waitUntil('bookmarksInitialized')
-            .then(() => _converse.state.bookmarks.createBookmarksFromStanza(message))
-            .catch(e => log.fatal(e));
-    }
+/**
+ * @param {import('../chat/message')} message
+ * @returns {true}
+ */
+export function handleBookmarksPush(message) {
+    api.waitUntil('bookmarksInitialized')
+        .then(() => _converse.state.bookmarks.setBookmarksFromStanza(message))
+        .catch(/** @param {Error} e */(e) => log.fatal(e));
     return true;
 }

+ 3 - 3
src/headless/plugins/chat/api.js

@@ -108,9 +108,9 @@ export default {
          *
          * @method api.chats.get
          * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
-         * @param { Object } [attrs] - Attributes to be set on the _converse.ChatBox model.
-         * @param { Boolean } [create=false] - Whether the chat should be created if it's not found.
-         * @returns { Promise<ChatBox[]> }
+         * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+         * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+         * @returns {Promise<ChatBox[]>}
          *
          * @example
          * // To return a single chat, provide the JID of the contact you're chatting with in that chat:

+ 14 - 0
src/headless/plugins/chat/message.js

@@ -35,6 +35,9 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
     constructor (models, options) {
         super(models, options);
         this.file = null;
+
+        /** @type {import('./types').MessageAttributes} */
+        this.attributes;
     }
 
     async initialize () {
@@ -137,6 +140,13 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
         return text.startsWith('/me ');
     }
 
+    /**
+     * @returns {boolean}
+     */
+    isRetracted () {
+        return this.get('retracted') || this.get('moderated') === 'retracted';
+    }
+
     /**
      * Returns a boolean indicating whether this message is considered a followup
      * message from the previous one. Followup messages are shown grouped together
@@ -158,6 +168,7 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
         }
         const date = dayjs(this.get('time'));
         return this.get('from') === prev_model.get('from') &&
+            !this.isRetracted() && !prev_model.isRetracted() &&
             !this.isMeCommand() && !prev_model.isMeCommand() &&
             !!this.get('is_encrypted') === !!prev_model.get('is_encrypted') &&
             this.get('type') === prev_model.get('type') && this.get('type') !== 'info' &&
@@ -209,6 +220,9 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
         return api.sendIQ(iq);
     }
 
+    /**
+     * @param {Element} stanza
+     */
     getUploadRequestMetadata (stanza) { // eslint-disable-line class-methods-use-this
         const headers = sizzle(`slot[xmlns="${Strophe.NS.HTTPUPLOAD}"] put header`, stanza);
         // https://xmpp.org/extensions/xep-0363.html#request

+ 11 - 3
src/headless/plugins/chat/model.js

@@ -22,7 +22,7 @@ class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBox
      * @typedef {import('./message.js').default} Message
      * @typedef {import('../muc/muc.js').default} MUC
      * @typedef {import('./types').MessageAttributes} MessageAttributes
-     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
+     * @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError
      */
 
     defaults () {
@@ -98,6 +98,9 @@ class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBox
         }
     }
 
+    /**
+     * @param {import('../roster/presence').default} item
+     */
     onPresenceChanged (item) {
         const { __ } = _converse;
         const show = item.get('show');
@@ -112,7 +115,7 @@ class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBox
         } else if (show === 'online') {
             text = __('%1$s is online', fullname);
         }
-        text && this.createMessage({ 'message': text, 'type': 'info' });
+        text && this.createMessage({ message: text, type: 'info', is_ephemeral: true });
     }
 
     async close () {
@@ -154,11 +157,16 @@ class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBox
         if (to_bare_jid !== _converse.session.get('bare_jid')) {
             return false;
         }
+
         if (attrs.is_markable) {
-            if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
+            if (this.contact &&
+                    !['none', 'to'].includes(this.contact.get('subscription')) &&
+                    !attrs.is_archived &&
+                    !attrs.is_carbon) {
                 sendMarker(attrs.from, attrs.msgid, 'received');
             }
             return false;
+
         } else if (attrs.marker_id) {
             const message = this.messages.findWhere({'msgid': attrs.marker_id});
             const field_name = `marker_${attrs.marker}`;

+ 8 - 10
src/headless/plugins/chat/parsers.js

@@ -7,10 +7,9 @@ import converse from '../../shared/api/public.js';
 import dayjs from 'dayjs';
 import log from '../../log.js';
 import u from '../../utils/index.js';
-import { rejectMessage } from '../../shared/actions';
-
+import { rejectMessage } from '../../shared/actions.js';
+import { StanzaParseError } from '../../shared/errors.js';
 import {
-    StanzaParseError,
     getChatMarker,
     getChatState,
     getCorrectionAttributes,
@@ -45,8 +44,8 @@ export async function parseMessage (stanza) {
     const resource = _converse.session.get('resource');
     if (api.settings.get('filter_by_resource') && to_resource && to_resource !== resource) {
         return new StanzaParseError(
+            stanza,
             `Ignoring incoming message intended for a different resource: ${to_jid}`,
-            stanza
         );
     }
 
@@ -62,7 +61,7 @@ export async function parseMessage (stanza) {
         } else {
             // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
             rejectMessage(stanza, 'Rejecting carbon from invalid JID');
-            return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza);
+            return new StanzaParseError(stanza, `Rejecting carbon from invalid JID ${to_jid}`);
         }
     }
 
@@ -75,8 +74,8 @@ export async function parseMessage (stanza) {
             from_jid = stanza.getAttribute('from');
         } else {
             return new StanzaParseError(
+                stanza,
                 `Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`,
-                stanza
             );
         }
     }
@@ -85,8 +84,8 @@ export async function parseMessage (stanza) {
     const is_me = from_bare_jid === bare_jid;
     if (is_me && to_jid === null) {
         return new StanzaParseError(
+            stanza,
             `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
-            stanza
         );
     }
 
@@ -99,8 +98,8 @@ export async function parseMessage (stanza) {
         if (contact === undefined && !api.settings.get('allow_non_roster_messaging')) {
             log.error(stanza);
             return new StanzaParseError(
+                stanza,
                 `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`,
-                stanza
             );
         }
     }
@@ -122,7 +121,6 @@ export async function parseMessage (stanza) {
             'is_marker': !!marker,
             'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
             'marker_id': marker && marker.getAttribute('id'),
-            'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
             'nick': contact?.attributes?.nickname,
             'receipt_id': getReceiptId(stanza),
             'received': new Date().toISOString(),
@@ -146,7 +144,7 @@ export async function parseMessage (stanza) {
     if (attrs.is_archived) {
         const from = original_stanza.getAttribute('from');
         if (from && from !== bare_jid) {
-            return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza);
+            return new StanzaParseError(stanza, `Invalid Stanza: Forged MAM message from ${from}`);
         }
     }
     attrs = Object.assign(

+ 19 - 7
src/headless/plugins/chat/types.ts

@@ -1,15 +1,28 @@
 import {EncryptionAttrs} from "../../shared/types";
 
-export type MessageAttributes = EncryptionAttrs & {
+// Represents a XEP-0372 reference
+export type Reference = {
+    begin: number;
+    end: number;
+    type: string;
+    uri: string;
+}
+
+export type MessageErrorAttributes = {
+    is_error: boolean; // Whether an error was received for this message
+    error: string; // The error name
+    errors: { name: string; xmlns: string }[];
+    error_condition: string; // The defined error condition
+    error_text: string; // The error text received from the server
+    error_type: string; // The type of error received from the server
+}
+
+export type MessageAttributes = EncryptionAttrs & MessageErrorAttributes & {
     body: string; // The contents of the <body> tag of the message stanza
     chat_state: string; // The XEP-0085 chat state notification contained in this message
     contact_jid: string; // The JID of the other person or entity
     editable: boolean; // Is this message editable via XEP-0308?
     edited: string; // An ISO8601 string recording the time that the message was edited per XEP-0308
-    error: string; // The error name
-    error_condition: string; // The defined error condition
-    error_text: string; // The error text received from the server
-    error_type: string; // The type of error received from the server
     from: string; // The sender JID
     message?: string; // Used with info and error messages
     fullname: string; // The full name of the sender
@@ -17,7 +30,6 @@ export type MessageAttributes = EncryptionAttrs & {
     is_carbon: boolean; // Is this message a XEP-0280 Carbon?
     is_delayed: boolean; // Was delivery of this message was delayed as per XEP-0203?
     is_encrypted: boolean; //  Is this message XEP-0384  encrypted?
-    is_error: boolean; // Whether an error was received for this message
     is_headline: boolean; // Is this a "headline" message?
     is_markable: boolean; // Can this message be marked with a XEP-0333 chat marker?
     is_marker: boolean; // Is this message a XEP-0333 Chat Marker?
@@ -37,7 +49,7 @@ export type MessageAttributes = EncryptionAttrs & {
     plaintext: string; // The decrypted text of this message, in case it was encrypted.
     receipt_id: string; // The `id` attribute of a XEP-0184 <receipt> element
     received: string; // An ISO8601 string recording the time that the message was received
-    references: Array<Object>; // A list of objects representing XEP-0372 references
+    references: Array<Reference>; // A list of objects representing XEP-0372 references
     replace_id: string; // The `id` attribute of a XEP-0308 <replace> element
     retracted: string; // An ISO8601 string recording the time that the message was retracted
     retracted_id: string; // The `id` attribute of a XEP-424 <retracted> element

+ 1 - 3
src/headless/plugins/chat/utils.js

@@ -2,7 +2,7 @@
  * @module:headless-plugins-chat-utils
  * @typedef {import('./model.js').default} ChatBox
  * @typedef {import('./types.ts').MessageAttributes} MessageAttributes
- * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
+ * @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError
  * @typedef {import('strophe.js').Builder} Builder
  */
 import sizzle from "sizzle";
@@ -42,7 +42,6 @@ export async function onClearSession () {
     }
 }
 
-
 /**
  * Given a stanza, determine whether it's a new
  * message, i.e. not a MAM archived one.
@@ -60,7 +59,6 @@ export function isNewMessage (message) {
     return !(message['is_delayed'] && message['is_archived']);
 }
 
-
 /**
  * @param {Element} stanza
  */

+ 8 - 9
src/headless/plugins/chatboxes/chatboxes.js

@@ -5,24 +5,23 @@
  */
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
-import { Collection } from "@converse/skeletor";
+import { Collection } from '@converse/skeletor';
 import { initStorage } from '../../utils/storage.js';
 
 class ChatBoxes extends Collection {
-
     /**
      * @param {Model[]} models
      * @param {object} options
      */
-    constructor (models, options) {
+    constructor(models, options) {
         super(models, Object.assign({ comparator: 'time_opened' }, options));
     }
 
     /**
      * @param {Collection} collection
      */
-    onChatBoxesFetched (collection) {
-        collection.filter(c => !c.isValid()).forEach(c => c.destroy());
+    onChatBoxesFetched(collection) {
+        collection.filter((c) => !c.isValid()).forEach((c) => c.destroy());
         /**
          * Triggered once all chat boxes have been recreated from the browser cache
          * @event _converse#chatBoxesFetched
@@ -38,14 +37,14 @@ class ChatBoxes extends Collection {
     /**
      * @param {boolean} reconnecting
      */
-    onConnected (reconnecting) {
+    onConnected(reconnecting) {
         if (reconnecting) return;
 
         const bare_jid = _converse.session.get('bare_jid');
         initStorage(this, `converse.chatboxes-${bare_jid}`);
         this.fetch({
             'add': true,
-            'success': c => this.onChatBoxesFetched(c)
+            'success': (c) => this.onChatBoxesFetched(c),
         });
     }
 
@@ -53,9 +52,9 @@ class ChatBoxes extends Collection {
      * @param {object} attrs
      * @param {object} options
      */
-    createModel (attrs, options) {
+    createModel(attrs, options) {
         if (!attrs.type) {
-            throw new Error("You need to specify a type of chatbox to be created");
+            throw new Error('You need to specify a type of chatbox to be created');
         }
         const ChatBox = api.chatboxes.registry.get(attrs.type);
         return new ChatBox(attrs, options);

+ 131 - 121
src/headless/plugins/disco/api.js

@@ -1,18 +1,19 @@
-/**
- * @typedef {import('./index').DiscoState} DiscoState
- * @typedef {import('./entities').default} DiscoEntities
- * @typedef {import('@converse/skeletor').Collection} Collection
- */
+import { getOpenPromise } from '@converse/openpromise';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
-import log from "../../log.js";
-import { getOpenPromise } from '@converse/openpromise';
+import log from '../../log.js';
 
 const { Strophe, $iq } = converse.env;
 
-
 export default {
+    /**
+     * @typedef {import('./entities').default} DiscoEntities
+     * @typedef {import('./entity').default} DiscoEntity
+     * @typedef {import('./index').DiscoState} DiscoState
+     * @typedef {import('@converse/skeletor').Collection} Collection
+     */
+
     /**
      * The XEP-0030 service discovery API
      *
@@ -34,12 +35,12 @@ export default {
              * @param { String } xmlns The XML namespace
              * @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver')
              */
-            async getFeature (name, xmlns) {
+            async getFeature(name, xmlns) {
                 await api.waitUntil('streamFeaturesAdded');
 
-                const stream_features = /** @type {Collection} */(_converse.state.stream_features);
+                const stream_features = /** @type {Collection} */ (_converse.state.stream_features);
                 if (!name || !xmlns) {
-                    throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature");
+                    throw new Error('name and xmlns need to be provided when calling disco.stream.getFeature');
                 }
                 if (stream_features === undefined && !api.connection.connected()) {
                     // Happens during tests when disco lookups happen asynchronously after teardown.
@@ -47,8 +48,8 @@ export default {
                     log.warn(msg);
                     return;
                 }
-                return stream_features.findWhere({'name': name, 'xmlns': xmlns});
-            }
+                return stream_features.findWhere({ 'name': name, 'xmlns': xmlns });
+            },
         },
 
         /**
@@ -65,32 +66,34 @@ export default {
                  * Lets you add new identities for this client (i.e. instance of Converse)
                  * @method api.disco.own.identities.add
                  *
-                 * @param { String } category - server, client, gateway, directory, etc.
-                 * @param { String } type - phone, pc, web, etc.
-                 * @param { String } name - "Converse"
-                 * @param { String } lang - en, el, de, etc.
+                 * @param {String} category - server, client, gateway, directory, etc.
+                 * @param {String} type - phone, pc, web, etc.
+                 * @param {String} name - "Converse"
+                 * @param {String} lang - en, el, de, etc.
                  *
                  * @example _converse.api.disco.own.identities.clear();
                  */
-                add (category, type, name, lang) {
-                    const disco = /** @type {DiscoState} */(_converse.state.disco);
-                    for (var i=0; i<disco._identities.length; i++) {
-                        if (disco._identities[i].category == category &&
-                                disco._identities[i].type == type &&
-                                disco._identities[i].name == name &&
-                                disco._identities[i].lang == lang) {
+                add(category, type, name, lang) {
+                    const disco = /** @type {DiscoState} */ (_converse.state.disco);
+                    for (var i = 0; i < disco._identities.length; i++) {
+                        if (
+                            disco._identities[i].category == category &&
+                            disco._identities[i].type == type &&
+                            disco._identities[i].name == name &&
+                            disco._identities[i].lang == lang
+                        ) {
                             return false;
                         }
                     }
-                    disco._identities.push({category: category, type: type, name: name, lang: lang});
+                    disco._identities.push({ category: category, type: type, name: name, lang: lang });
                 },
                 /**
                  * Clears all previously registered identities.
                  * @method api.disco.own.identities.clear
                  * @example _converse.api.disco.own.identities.clear();
                  */
-                clear () {
-                    /** @type {DiscoState} */(_converse.state.disco)._identities = []
+                clear() {
+                    /** @type {DiscoState} */ (_converse.state.disco)._identities = [];
                 },
                 /**
                  * Returns all of the identities registered for this client
@@ -98,9 +101,9 @@ export default {
                  * @method api.disco.identities.get
                  * @example const identities = api.disco.own.identities.get();
                  */
-                get () {
-                    return /** @type {DiscoState} */(_converse.state.disco)._identities;
-                }
+                get() {
+                    return /** @type {DiscoState} */ (_converse.state.disco)._identities;
+                },
             },
 
             /**
@@ -114,10 +117,12 @@ export default {
                  * @param { String } name - e.g. http://jabber.org/protocol/caps
                  * @example _converse.api.disco.own.features.add("http://jabber.org/protocol/caps");
                  */
-                add (name) {
-                    const disco = /** @type {DiscoState} */(_converse.state.disco);
-                    for (let i=0; i<disco._features.length; i++) {
-                        if (disco._features[i] == name) { return false; }
+                add(name) {
+                    const disco = /** @type {DiscoState} */ (_converse.state.disco);
+                    for (let i = 0; i < disco._features.length; i++) {
+                        if (disco._features[i] == name) {
+                            return false;
+                        }
                     }
                     disco._features.push(name);
                 },
@@ -126,38 +131,38 @@ export default {
                  * @method api.disco.own.features.clear
                  * @example _converse.api.disco.own.features.clear();
                  */
-                clear () {
-                    const disco = /** @type {DiscoState} */(_converse.state.disco);
-                    disco._features = []
+                clear() {
+                    const disco = /** @type {DiscoState} */ (_converse.state.disco);
+                    disco._features = [];
                 },
                 /**
                  * Returns all of the features registered for this client (i.e. instance of Converse).
                  * @method api.disco.own.features.get
                  * @example const features = api.disco.own.features.get();
                  */
-                get () {
-                    return /** @type {DiscoState} */(_converse.state.disco)._features;
-                }
-            }
+                get() {
+                    return /** @type {DiscoState} */ (_converse.state.disco)._features;
+                },
+            },
         },
 
         /**
          * Query for information about an XMPP entity
          *
          * @method api.disco.info
-         * @param { string } jid The Jabber ID of the entity to query
-         * @param { string } [node] A specific node identifier associated with the JID
+         * @param {string} jid The Jabber ID of the entity to query
+         * @param {string} [node] A specific node identifier associated with the JID
          * @returns {promise} Promise which resolves once we have a result from the server.
          */
-        info (jid, node) {
-            const attrs = {xmlns: Strophe.NS.DISCO_INFO};
+        info(jid, node) {
+            const attrs = { xmlns: Strophe.NS.DISCO_INFO };
             if (node) {
                 attrs.node = node;
             }
             const info = $iq({
                 'from': api.connection.get().jid,
-                'to':jid,
-                'type':'get'
+                'to': jid,
+                'type': 'get',
             }).c('query', attrs);
             return api.sendIQ(info);
         },
@@ -166,20 +171,20 @@ export default {
          * Query for items associated with an XMPP entity
          *
          * @method api.disco.items
-         * @param { string } jid The Jabber ID of the entity to query for items
-         * @param { string } [node] A specific node identifier associated with the JID
+         * @param {string} jid The Jabber ID of the entity to query for items
+         * @param {string} [node] A specific node identifier associated with the JID
          * @returns {promise} Promise which resolves once we have a result from the server.
          */
-        items (jid, node) {
-            const attrs = {'xmlns': Strophe.NS.DISCO_ITEMS};
+        items(jid, node) {
+            const attrs = { xmlns: Strophe.NS.DISCO_ITEMS };
             if (node) {
                 attrs.node = node;
             }
             return api.sendIQ(
                 $iq({
                     'from': api.connection.get().jid,
-                    'to':jid,
-                    'type':'get'
+                    'to': jid,
+                    'type': 'get',
                 }).c('query', attrs)
             );
         },
@@ -195,13 +200,14 @@ export default {
              * Get the corresponding `DiscoEntity` instance.
              *
              * @method api.disco.entities.get
-             * @param { string } jid The Jabber ID of the entity
-             * @param { boolean } [create] Whether the entity should be created if it doesn't exist.
+             * @param {string} jid The Jabber ID of the entity
+             * @param {boolean} [create] Whether the entity should be created if it doesn't exist.
+             * @return {Promise<DiscoEntity|DiscoEntities|undefined>}
              * @example _converse.api.disco.entities.get(jid);
              */
-            async get (jid, create=false) {
+            async get(jid, create = false) {
                 await api.waitUntil('discoInitialized');
-                const disco_entities = /** @type {DiscoEntities} */(_converse.state.disco_entities);
+                const disco_entities = /** @type {DiscoEntities} */ (_converse.state.disco_entities);
                 if (!jid) {
                     return disco_entities;
                 }
@@ -221,12 +227,15 @@ export default {
              * Return any disco items advertised on this entity
              *
              * @method api.disco.entities.items
-             * @param { string } jid - The Jabber ID of the entity for which we want to fetch items
+             * @param {string} jid - The Jabber ID of the entity for which we want to fetch items
              * @example api.disco.entities.items(jid);
              */
-            items (jid) {
-                const disco_entities = /** @type {DiscoEntities} */(_converse.state.disco_entities);
-                return disco_entities.filter(e => e.get('parent_jids')?.includes(jid));
+            async items(jid) {
+                const entity = await api.disco.entities.get(jid);
+                await entity.waitUntilItemsFetched;
+
+                const disco_entities = /** @type {DiscoEntities} */ (_converse.state.disco_entities);
+                return disco_entities.filter((e) => e.get('parent_jids')?.includes(jid));
             },
 
             /**
@@ -238,19 +247,19 @@ export default {
              * `ignore_cache: true` in the options parameter.
              *
              * @method api.disco.entities.create
-             * @param { object } data
-             * @param { string } data.jid - The Jabber ID of the entity
-             * @param { string } data.parent_jid - The Jabber ID of the parent entity
-             * @param { string } data.name
-             * @param { object } [options] - Additional options
-             * @param { boolean } [options.ignore_cache]
+             * @param {object} data
+             * @param {string} data.jid - The Jabber ID of the entity
+             * @param {string} data.parent_jid - The Jabber ID of the parent entity
+             * @param {string} data.name
+             * @param {object} [options] - Additional options
+             * @param {boolean} [options.ignore_cache]
              *     If true, fetch all features from the XMPP server instead of restoring them from cache
              * @example _converse.api.disco.entities.create({ jid }, {'ignore_cache': true});
              */
-            create (data, options) {
-                const disco_entities = /** @type {DiscoEntities} */(_converse.state.disco_entities);
+            create(data, options) {
+                const disco_entities = /** @type {DiscoEntities} */ (_converse.state.disco_entities);
                 return disco_entities.create(data, options);
-            }
+            },
         },
 
         /**
@@ -262,37 +271,42 @@ export default {
              * Return a given feature of a disco entity
              *
              * @method api.disco.features.get
-             * @param { string } feature The feature that might be
+             * @param {string} feature The feature that might be
              *     supported. In the XML stanza, this is the `var`
              *     attribute of the `<feature>` element. For
              *     example: `http://jabber.org/protocol/muc`
-             * @param { string } jid The JID of the entity
+             * @param {string} jid The JID of the entity
              *     (and its associated items) which should be queried
-             * @returns {promise} A promise which resolves with a list containing
+             * @returns {Promise<import('@converse/skeletor').Model|import('@converse/skeletor').Model[]>}
+             *     A promise which resolves with a list containing
              *     _converse.Entity instances representing the entity
              *     itself or those items associated with the entity if
              *     they support the given feature.
              * @example
              * api.disco.features.get(Strophe.NS.MAM, _converse.bare_jid);
              */
-            async get (feature, jid) {
-                if (!jid) throw new TypeError('You need to provide an entity JID');
+            async get(feature, jid) {
+                if (!jid) throw new TypeError('api.disco.features.get: You need to provide an entity JID');
 
                 const entity = await api.disco.entities.get(jid, true);
 
                 if (_converse.state.disco_entities === undefined && !api.connection.connected()) {
                     // Happens during tests when disco lookups happen asynchronously after teardown.
-                    log.warn(`Tried to get feature ${feature} for ${jid} but `+
-                        `_converse.disco_entities has been torn down`);
+                    log.warn(
+                        `Tried to get feature ${feature} for ${jid} but ` +
+                            `_converse.disco_entities has been torn down`
+                    );
                     return [];
                 }
 
+                const items = await api.disco.entities.items(jid);
+
                 const promises = [
                     entity.getFeature(feature),
-                    ...api.disco.entities.items(jid).map(i => i.getFeature(feature))
+                    ...items.map((i) => i.getFeature(feature)),
                 ];
                 const result = await Promise.all(promises);
-                return result.filter(f => (f instanceof Object));
+                return result.filter((f) => f instanceof Object);
             },
 
             /**
@@ -300,18 +314,18 @@ export default {
              * associated items, supports a given feature.
              *
              * @method api.disco.features.has
-             * @param { string } feature The feature that might be
+             * @param {string} feature The feature that might be
              *     supported. In the XML stanza, this is the `var`
              *     attribute of the `<feature>` element. For
              *     example: `http://jabber.org/protocol/muc`
-             * @param { string } jid The JID of the entity
+             * @param {string} jid The JID of the entity
              *     (and its associated items) which should be queried
-             * @returns {Promise} A promise which resolves with a boolean
+             * @returns {Promise<boolean>} A promise which resolves with a boolean
              * @example
              *      api.disco.features.has(Strophe.NS.MAM, _converse.bare_jid);
              */
-            async has (feature, jid) {
-                if (!jid) throw new TypeError('You need to provide an entity JID');
+            async has(feature, jid) {
+                if (!jid) throw new TypeError('api.disco.feature.has: You need to provide an entity JID');
 
                 const entity = await api.disco.entities.get(jid, true);
 
@@ -325,22 +339,23 @@ export default {
                     return true;
                 }
 
-                const result = await Promise.all(api.disco.entities.items(jid).map(i => i.getFeature(feature)));
-                return result.map(f => (f instanceof Object)).includes(true);
-            }
+                const items = await api.disco.entities.items(jid);
+                const result = await Promise.all(items.map((i) => i.getFeature(feature)));
+                return result.map((f) => f instanceof Object).includes(true);
+            },
         },
 
         /**
          * Used to determine whether an entity supports a given feature.
          *
          * @method api.disco.supports
-         * @param { string } feature The feature that might be
+         * @param {string} feature The feature that might be
          *     supported. In the XML stanza, this is the `var`
          *     attribute of the `<feature>` element. For
          *     example: `http://jabber.org/protocol/muc`
-         * @param { string } jid The JID of the entity
+         * @param {string} jid The JID of the entity
          *     (and its associated items) which should be queried
-         * @returns {promise} A promise which resolves with `true` or `false`.
+         * @returns {Promise<boolean>|boolean} A promise which resolves with `true` or `false`.
          * @example
          * if (await api.disco.supports(Strophe.NS.MAM, _converse.bare_jid)) {
          *     // The feature is supported
@@ -348,23 +363,27 @@ export default {
          *     // The feature is not supported
          * }
          */
-        supports (feature, jid) {
-            return api.disco.features.has(feature, jid);
+        supports(feature, jid) {
+            try {
+                return api.disco.features.has(feature, jid);
+            } catch (e) {
+                log.error(e);
+                return false;
+            }
         },
 
         /**
          * Refresh the features, fields and identities associated with a
          * disco entity by refetching them from the server
          * @method api.disco.refresh
-         * @param { string } jid The JID of the entity whose features are refreshed.
-         * @returns {promise} A promise which resolves once the features have been refreshed
+         * @param {string} jid The JID of the entity whose features are refreshed.
+         * @returns {Promise} A promise which resolves once the features have been refreshed
          * @example
          * await api.disco.refresh('room@conference.example.org');
          */
-        async refresh (jid) {
-            if (!jid) {
-                throw new TypeError('api.disco.refresh: You need to provide an entity JID');
-            }
+        async refresh(jid) {
+            if (!jid) throw new TypeError('api.disco.refresh: You need to provide an entity JID');
+
             await api.waitUntil('discoInitialized');
             let entity = await api.disco.entities.get(jid);
             if (entity) {
@@ -372,24 +391,16 @@ export default {
                 entity.fields.reset();
                 entity.identities.reset();
                 if (!entity.waitUntilFeaturesDiscovered.isPending) {
-                    entity.waitUntilFeaturesDiscovered = getOpenPromise()
+                    entity.waitUntilFeaturesDiscovered = getOpenPromise();
                 }
                 entity.queryInfo();
             } else {
                 // Create it if it doesn't exist
-                entity = await api.disco.entities.create({ jid }, {'ignore_cache': true});
+                entity = await api.disco.entities.create({ jid }, { 'ignore_cache': true });
             }
             return entity.waitUntilFeaturesDiscovered;
         },
 
-        /**
-         * @deprecated Use {@link api.disco.refresh} instead.
-         * @method api.disco.refreshFeatures
-         */
-        refreshFeatures (jid) {
-            return api.refresh(jid);
-        },
-
         /**
          * Return all the features associated with a disco entity
          *
@@ -399,10 +410,9 @@ export default {
          * @example
          * const features = await api.disco.getFeatures('room@conference.example.org');
          */
-        async getFeatures (jid) {
-            if (!jid) {
-                throw new TypeError('api.disco.getFeatures: You need to provide an entity JID');
-            }
+        async getFeatures(jid) {
+            if (!jid) throw new TypeError('api.disco.getFeatures: You need to provide an entity JID');
+
             await api.waitUntil('discoInitialized');
             let entity = await api.disco.entities.get(jid, true);
             entity = await entity.waitUntilFeaturesDiscovered;
@@ -420,10 +430,9 @@ export default {
          * @example
          * const fields = await api.disco.getFields('room@conference.example.org');
          */
-        async getFields (jid) {
-            if (!jid) {
-                throw new TypeError('api.disco.getFields: You need to provide an entity JID');
-            }
+        async getFields(jid) {
+            if (!jid) throw new TypeError('api.disco.getFields: You need to provide an entity JID');
+
             await api.waitUntil('discoInitialized');
             let entity = await api.disco.entities.get(jid, true);
             entity = await entity.waitUntilFeaturesDiscovered;
@@ -461,16 +470,17 @@ export default {
          *     }
          * ).catch(e => log.error(e));
          */
-        async getIdentity (category, type, jid) {
+        async getIdentity(category, type, jid) {
             const e = await api.disco.entities.get(jid, true);
             if (e === undefined && !api.connection.connected()) {
                 // Happens during tests when disco lookups happen asynchronously after teardown.
-                const msg = `Tried to look up category ${category} for ${jid} `+
+                const msg =
+                    `Tried to look up category ${category} for ${jid} ` +
                     `but _converse.disco_entities has been torn down`;
                 log.warn(msg);
                 return;
             }
             return e.getIdentity(category, type);
-        }
-    }
-}
+        },
+    },
+};

+ 7 - 3
src/headless/plugins/disco/entity.js

@@ -19,13 +19,14 @@ const { Strophe } = converse.env;
  * See XEP-0030: https://xmpp.org/extensions/xep-0030.html
  */
 class DiscoEntity extends Model {
-    get idAttribute () { // eslint-disable-line class-methods-use-this
+    get idAttribute () {
         return 'jid';
     }
 
     initialize (_, options) {
         super.initialize();
         this.waitUntilFeaturesDiscovered = getOpenPromise();
+        this.waitUntilItemsFetched = getOpenPromise();
 
         this.dataforms = new Collection();
         let id = `converse.dataforms-${this.get('jid')}`;
@@ -70,7 +71,7 @@ class DiscoEntity extends Model {
      */
     async getFeature (feature) {
         await this.waitUntilFeaturesDiscovered;
-        if (this.features.findWhere({ 'var': feature })) {
+        if (this.features.findWhere({ var: feature })) {
             return this;
         }
     }
@@ -144,7 +145,8 @@ class DiscoEntity extends Model {
             const jid = item.getAttribute('jid');
             const entity = _converse.state.disco_entities.get(jid);
             if (entity) {
-                entity.set({ parent_jids: [this.get('jid')] });
+                const parent_jids = entity.get('parent_jids');
+                entity.set({ parent_jids: [...parent_jids, this.get('jid')] });
             } else {
                 api.disco.entities.create({
                     jid,
@@ -191,6 +193,8 @@ class DiscoEntity extends Model {
         if (stanza.querySelector(`feature[var="${Strophe.NS.DISCO_ITEMS}"]`)) {
             await this.queryForItems();
         }
+        this.waitUntilItemsFetched.resolve();
+
         Array.from(stanza.querySelectorAll('feature')).forEach(feature => {
             this.features.create({
                 'var': feature.getAttribute('var'),

+ 37 - 85
src/headless/plugins/disco/tests/disco.js

@@ -1,5 +1,7 @@
 /*global mock, converse */
 
+const { u, $iq, stx } = converse.env;
+
 describe("Service Discovery", function () {
 
     describe("Whenever a server is queried for its features", function () {
@@ -9,7 +11,6 @@ describe("Service Discovery", function () {
                 ['discoInitialized'], {},
                 async function (_converse) {
 
-            const { u, $iq } = converse.env;
             const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
             const IQ_ids =  _converse.api.connection.get().IQ_ids;
             await u.waitUntil(function () {
@@ -17,63 +18,27 @@ describe("Service Discovery", function () {
                     return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]');
                 }).length > 0;
             });
-            /* <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'/>
-             *      <identity
-             *          category='conference'
-             *          type='text'
-             *          name='Play-Specific Chatrooms'/>
-             *      <identity
-             *          category='directory'
-             *          type='chatroom'
-             *          name='Play-Specific Chatrooms'/>
-             *      <feature var='http://jabber.org/protocol/disco#info'/>
-             *      <feature var='http://jabber.org/protocol/disco#items'/>
-             *      <feature var='http://jabber.org/protocol/muc'/>
-             *      <feature var='jabber:iq:register'/>
-             *      <feature var='jabber:iq:search'/>
-             *      <feature var='jabber:iq:time'/>
-             *      <feature var='jabber:iq:version'/>
-             *  </query>
-             *  </iq>
-             */
             let stanza = IQ_stanzas.find(function (iq) {
                 return 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',
-                    'type': 'text',
-                    'name': 'Play-Specific Chatrooms'}).up()
-                .c('identity', {
-                    'category': 'directory',
-                    'type': 'chatroom',
-                    '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()
-                .c('feature', {
-                    'var': 'jabber:iq:register'}).up()
-                .c('feature', {
-                    'var': 'jabber:iq:time'}).up()
-                .c('feature', {
-                    'var': 'jabber:iq:version'});
+            stanza = stx`
+                <iq xmlns="jabber:client"
+                    type='result'
+                    from='montague.lit'
+                    to='romeo@montague.lit/orchard'
+                    id='${info_IQ_id}'>
+                    <query xmlns='http://jabber.org/protocol/disco#info'>
+                        <identity category='server' type='im'/>
+                        <identity category='conference' type='text' name='Play-Specific Chatrooms'/>
+                        <identity category='directory' type='chatroom' name='Play-Specific Chatrooms'/>
+                        <feature var='http://jabber.org/protocol/disco#info'/>
+                        <feature var='http://jabber.org/protocol/disco#items'/>
+                        <feature var='jabber:iq:register'/>
+                        <feature var='jabber:iq:time'/>
+                        <feature var='jabber:iq:version'/>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
             await u.waitUntil(function () {
@@ -111,35 +76,22 @@ describe("Service Discovery", function () {
             stanza = IQ_stanzas.find(iq => 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': 'people.shakespeare.lit',
-                    'name': 'Directory of Characters'}).up()
-                .c('item', {
-                    'jid': 'plays.shakespeare.lit',
-                    'name': 'Play-Specific Chatrooms'}).up()
-                .c('item', {
-                    'jid': 'words.shakespeare.lit',
-                    'name': 'Gateway to Marlowe IM'}).up()
-                .c('item', {
-                    'jid': 'montague.lit',
-                    'node': 'books',
-                    'name': 'Books by and about Shakespeare'}).up()
-                .c('item', {
-                    'node': 'montague.lit',
-                    'name': 'Wear your literary taste with pride'}).up()
-                .c('item', {
-                    'jid': 'montague.lit',
-                    'node': 'music',
-                    'name': 'Music from the time of Shakespeare'
-                });
 
-            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+                <iq xmlns="jabber:client"
+                    type='result'
+                    from='montague.lit'
+                    to='romeo@montague.lit/orchard'
+                    id='${items_IQ_id}'>
+                    <query xmlns='http://jabber.org/protocol/disco#items'>
+                        <item jid='people.shakespeare.lit' name='Directory of Characters'/>
+                        <item jid='plays.shakespeare.lit' name='Play-Specific Chatrooms'/>
+                        <item jid='words.shakespeare.lit' name='Gateway to Marlowe IM'/>
+                        <item jid='montague.lit' node='books' name='Books by and about Shakespeare'/>
+                        <item node='montague.lit' name='Wear your literary taste with pride'/>
+                        <item jid='montague.lit' node='music' name='Music from the time of Shakespeare'/>
+                    </query>
+                </iq>`));
 
             const entities = await _converse.api.disco.entities.get()
             expect(entities.length).toBe(5); // We have an extra entity, which is the user's JID
@@ -162,9 +114,9 @@ describe("Service Discovery", function () {
             ]);
             const { api, domain } = _converse;
             let entity = entities.get(_converse.domain);
-            expect(api.disco.entities.items(domain).length).toBe(3);
-
-            expect(api.disco.entities.items(domain).map(e => e.get('jid'))).toEqual(
+            const domain_items = await api.disco.entities.items(domain);
+            expect(domain_items.length).toBe(3);
+            expect(domain_items.map(e => e.get('jid'))).toEqual(
                 ['people.shakespeare.lit', 'plays.shakespeare.lit', 'words.shakespeare.lit']
             )
 

+ 3 - 0
src/headless/plugins/disco/utils.js

@@ -7,6 +7,9 @@ import { createStore } from '../../utils/storage.js';
 const { Strophe, $iq } = converse.env;
 
 
+/**
+ * @param {Element} stanza
+ */
 function onDiscoInfoRequest (stanza) {
     const node = stanza.getElementsByTagName('query')[0].getAttribute('node');
     const attrs = {xmlns: Strophe.NS.DISCO_INFO};

+ 2 - 2
src/headless/plugins/emoji/plugin.js

@@ -12,8 +12,8 @@ import emojis from './api.js';
 import { isOnlyEmojis } from './utils.js';
 
 converse.emojis = {
-    'initialized': false,
-    'initialized_promise': getOpenPromise(),
+    initialized: false,
+    initialized_promise: getOpenPromise(),
 };
 
 converse.plugins.add('converse-emoji', {

+ 2 - 2
src/headless/plugins/mam/placeholder.js

@@ -5,8 +5,8 @@ export default class MAMPlaceholderMessage extends Model {
 
     defaults () { // eslint-disable-line class-methods-use-this
         return {
-            'msgid': getUniqueId(),
-            'is_ephemeral': false
+            msgid: getUniqueId(),
+            is_ephemeral: false
         };
     }
 }

+ 4 - 2
src/headless/plugins/mam/utils.js

@@ -14,6 +14,7 @@ import { parseMessage } from '../../plugins/chat/parsers.js';
 import { CHATROOMS_TYPE } from '../../shared/constants.js';
 import { TimeoutError } from '../../shared/errors.js';
 import MAMPlaceholderMessage from './placeholder.js';
+import { parseErrorStanza } from '../../shared/parsers.js';
 
 const { NS } = Strophe;
 const u = converse.env.utils;
@@ -21,8 +22,9 @@ const u = converse.env.utils;
 /**
  * @param {Element} iq
  */
-export function onMAMError(iq) {
-    if (iq?.querySelectorAll('feature-not-implemented').length) {
+export async function onMAMError(iq) {
+    const err = await parseErrorStanza(iq);
+    if (err?.name === 'feature-not-implemented') {
         log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`);
     } else {
         log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`);

+ 7 - 6
src/headless/plugins/muc/affiliations/utils.js

@@ -91,17 +91,18 @@ export function setAffiliation (affiliation, muc_jids, members) {
 
 /**
  * Send an IQ stanza specifying an affiliation change.
- * @param {AFFILIATIONS[number]} affiliation: affiliation (could also be stored on the member object).
- * @param {string} muc_jid: The JID of the MUC in which the affiliation should be set.
- * @param {object} member: Map containing the member's jid and optionally a reason and affiliation.
+ * @param {AFFILIATIONS[number]} affiliation - Affiliation (could also be stored on the member object).
+ * @param {string} muc_jid - The JID of the MUC in which the affiliation should be set.
+ * @param {object} member - Map containing the member's jid and optionally a reason and affiliation.
  */
 function sendAffiliationIQ (affiliation, muc_jid, member) {
+    affiliation = member.affiliation || affiliation;
     const iq = $iq({ to: muc_jid, type: 'set' })
         .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
         .c('item', {
-            'affiliation': member.affiliation || affiliation,
-            'nick': member.nick,
-            'jid': member.jid
+            affiliation,
+            ...(affiliation === 'outcast' ? {} : {nick: member.nick }),
+            jid: member.jid
         });
     if (member.reason !== undefined) {
         iq.c('reason', member.reason);

+ 0 - 3
src/headless/plugins/muc/api.js

@@ -67,9 +67,6 @@ const rooms = {
      *     in the [XEP-0045 MUC specification](https://xmpp.org/extensions/xep-0045.html#registrar-formtype-owner).
      *     The values should be named without the `muc#roomconfig_` prefix.
      * @param {boolean} [attrs.minimized] A boolean, indicating whether the room should be opened minimized or not.
-     * @param {boolean} [attrs.bring_to_foreground] A boolean indicating whether the room should be
-     *     brought to the foreground and therefore replace the currently shown chat.
-     *     If there is no chat currently open, then this option is ineffective.
      * @param {boolean} [force=false] - By default, a minimized
      *   room won't be maximized (in `overlayed` view mode) and in
      *   `fullscreen` view mode a newly opened room won't replace

+ 28 - 2
src/headless/plugins/muc/constants.js

@@ -1,11 +1,37 @@
-export const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
+export const ACTION_INFO_CODES = ['301', '333', '307', '321', '322'];
+export const NEW_NICK_CODES = ['210', '303'];
 export const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
 export const AFFILIATIONS = ['owner', 'admin', 'member', 'outcast', 'none'];
+export const DISCONNECT_CODES = ['301', '333', '307', '321', '322', '332'];
 export const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];
 export const OWNER_COMMANDS = ['owner'];
 export const ROLES = ['moderator', 'participant', 'visitor'];
 export const VISITOR_COMMANDS = ['nick'];
 
+// Which types of stanzas a status code might appear in.
+export const STATUS_CODE_STANZAS = {
+    '100': ['message', 'presence'],
+    '101': ['message'],
+    '102': ['message'],
+    '103': ['message'],
+    '104': ['message'],
+    '110': ['presence'],
+    '170': ['message', 'presence'],
+    '171': ['message'],
+    '172': ['message'],
+    '173': ['message'],
+    '174': ['message'],
+    '201': ['presence'],
+    '210': ['presence'],
+    '301': ['presence'],
+    '303': ['presence'],
+    '307': ['presence'],
+    '321': ['presence'],
+    '322': ['presence'],
+    '332': ['presence'],
+    '333': ['presence'],
+};
+
 export const MUC_ROLE_WEIGHTS = {
     'moderator': 1,
     'participant': 2,
@@ -67,7 +93,7 @@ export const ROOM_FEATURES = [
     'moderated',
     'unmoderated',
     'mam_enabled',
-    'vcard-temp'
+    'vcard-temp',
 ];
 
 export const MUC_NICK_CHANGED_CODE = '303';

+ 2 - 2
src/headless/plugins/muc/index.js

@@ -6,8 +6,8 @@ import MUCOccupant from './occupant.js';
 import MUCOccupants from './occupants.js';
 import './plugin.js';
 
-import { isChatRoom } from './utils.js';
+import { getDefaultMUCService, isChatRoom } from './utils.js';
 import { setAffiliation } from './affiliations/utils.js';
-Object.assign(u, { muc: { isChatRoom, setAffiliation }});
+Object.assign(u, { muc: { isChatRoom, setAffiliation, getDefaultMUCService }});
 
 export { MUCMessage, MUCMessages, MUC, MUCOccupant, MUCOccupants };

+ 11 - 10
src/headless/plugins/muc/message.js

@@ -30,6 +30,10 @@ class MUCMessage extends Message {
         api.trigger('chatRoomMessageInitialized', this);
     }
 
+    get occupants () {
+        return (this.get('type') === 'chat') ? this.chatbox.collection : this.chatbox.occupants;
+    }
+
     getDisplayName () {
         return this.occupant?.getDisplayName() || this.get('nick');
     }
@@ -37,11 +41,10 @@ class MUCMessage extends Message {
     /**
      * Determines whether this messsage may be moderated,
      * based on configuration settings and server support.
-     * @async
      * @method _converse.ChatRoomMessages#mayBeModerated
-     * @returns {boolean}
+     * @returns {Promise<boolean>}
      */
-    mayBeModerated () {
+    async mayBeModerated () {
         if (typeof this.get('from_muc')  === 'undefined') {
             // If from_muc is not defined, then this message hasn't been
             // reflected yet, which means we won't have a XEP-0359 stanza id.
@@ -49,8 +52,7 @@ class MUCMessage extends Message {
         }
         return (
             ['all', 'moderator'].includes(api.settings.get('allow_message_retraction')) &&
-            this.get(`stanza_id ${this.get('from_muc')}`) &&
-            this.chatbox.canModerateMessages()
+            this.get(`stanza_id ${this.get('from_muc')}`) && await this.chatbox.canModerateMessages()
         );
     }
 
@@ -63,7 +65,7 @@ class MUCMessage extends Message {
     onOccupantRemoved () {
         this.stopListening(this.occupant);
         delete this.occupant;
-        this.listenTo(this.chatbox.occupants, 'add', this.onOccupantAdded);
+        this.listenTo(this.occupants, 'add', this.onOccupantAdded);
     }
 
     /**
@@ -102,10 +104,9 @@ class MUCMessage extends Message {
         } else {
             if (this.occupant) return;
 
-            const occupants = (this.get('type') === 'chat') ? this.chatbox.collection : this.chatbox.occupants;
             const nick = Strophe.getResourceFromJid(this.get('from'));
             const occupant_id = this.get('occupant_id');
-            this.occupant = nick || occupant_id ? occupants.findOccupant({ nick, occupant_id }) : null;
+            this.occupant = nick || occupant_id ? this.occupants.findOccupant({ nick, occupant_id }) : null;
 
             if (!this.occupant) {
                 const jid = this.get('from_real_jid');
@@ -114,7 +115,7 @@ class MUCMessage extends Message {
                     return;
                 }
 
-                this.occupant = this.chatbox.occupants.create({ nick, occupant_id, jid });
+                this.occupant = this.occupants.create({ nick, occupant_id, jid });
 
                 if (api.settings.get('muc_send_probes')) {
                     const jid = `${this.chatbox.get('jid')}/${nick}`;
@@ -130,7 +131,7 @@ class MUCMessage extends Message {
         this.trigger('occupant:add');
         this.listenTo(this.occupant, 'change', (changed) => this.trigger('occupant:change', changed));
         this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
-        this.stopListening(this.chatbox.occupants, 'add', this.onOccupantAdded);
+        this.stopListening(this.occupants, 'add', this.onOccupantAdded);
 
         return this.occupant;
     }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 225 - 218
src/headless/plugins/muc/muc.js


+ 14 - 1
src/headless/plugins/muc/occupant.js

@@ -2,6 +2,7 @@ import { Model } from '@converse/skeletor';
 import log from '../../log';
 import api from '../../shared/api/index.js';
 import _converse from '../../shared/_converse.js';
+import converse from '../../shared/api/public.js';
 import ColorAwareModel from '../../shared/color.js';
 import ModelWithMessages from '../../shared/model-with-messages.js';
 import { AFFILIATIONS, ROLES } from './constants.js';
@@ -10,13 +11,15 @@ import u from '../../utils/index.js';
 import { shouldCreateGroupchatMessage } from './utils';
 import { sendChatState } from '../../shared/actions';
 
+const { Strophe, stx } = converse.env;
+
 /**
  * Represents a participant in a MUC
  */
 class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
     /**
      * @typedef {import('../chat/types').MessageAttributes} MessageAttributes
-     * @typedef {import('../../shared/parsers').StanzaParseError} StanzaParseError
+     * @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError
      */
 
     constructor(attributes, options) {
@@ -39,6 +42,7 @@ class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
             states: [],
             hidden: true,
             num_unread: 0,
+            message_type: 'chat',
         };
     }
 
@@ -188,6 +192,15 @@ class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
         attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
         return attrs;
     }
+
+    /**
+     * @param {import('../chat/message').default} message - The message object
+     */
+    async createMessageStanza(message) {
+        const stanza = await super.createMessageStanza(message);
+        stanza.cnode(stx`<x xmlns="${Strophe.NS.MUC}#user"/>`).root();
+        return stanza;
+    }
 }
 
 export default MUCOccupant;

+ 1 - 1
src/headless/plugins/muc/occupants.js

@@ -143,7 +143,7 @@ class MUCOccupants extends Collection {
      * Lookup by occupant_id is done first, then jid, and then nick.
      *
      * @method _converse.MUCOccupants#findOccupant
-     * @param { OccupantData } data
+     * @param {OccupantData} data
      */
     findOccupant (data) {
         if (data.occupant_id) {

+ 245 - 135
src/headless/plugins/muc/parsers.js

@@ -7,8 +7,8 @@ import dayjs from 'dayjs';
 import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import converse from '../../shared/api/public.js';
+import { StanzaParseError } from '../../shared/errors.js';
 import {
-    StanzaParseError,
     getChatMarker,
     getChatState,
     getCorrectionAttributes,
@@ -27,6 +27,7 @@ import {
     isValidReceiptRequest,
     throwErrorIfInvalidForward,
 } from '../../shared/parsers';
+import { STATUS_CODE_STANZAS } from './constants.js';
 
 const { Strophe, sizzle, u } = converse.env;
 const { NS } = Strophe;
@@ -36,25 +37,26 @@ const { NS } = Strophe;
  * @param {Element} stanza - The message stanza
  * @returns {Array} Returns an array of objects representing <activity> elements.
  */
-export function getMEPActivities (stanza) {
+export function getMEPActivities(stanza) {
     const items_el = sizzle(`items[node="${Strophe.NS.CONFINFO}"]`, stanza).pop();
     if (!items_el) {
         return null;
     }
     const from = stanza.getAttribute('from');
     const msgid = stanza.getAttribute('id');
-    const selector = `item `+
-        `conference-info[xmlns="${Strophe.NS.CONFINFO}"] `+
-        `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
-    return sizzle(selector, items_el).map(/** @param {Element} el */(el) => {
-        const message = el.querySelector('text')?.textContent;
-        if (message) {
-            const references = getReferences(stanza);
-            const reason = el.querySelector('reason')?.textContent;
-            return { from, msgid, message, reason,  references, 'type': 'mep' };
+    const selector =
+        `item ` + `conference-info[xmlns="${Strophe.NS.CONFINFO}"] ` + `activity[xmlns="${Strophe.NS.ACTIVITY}"]`;
+    return sizzle(selector, items_el).map(
+        /** @param {Element} el */ (el) => {
+            const message = el.querySelector('text')?.textContent;
+            if (message) {
+                const references = getReferences(stanza);
+                const reason = el.querySelector('reason')?.textContent;
+                return { from, msgid, message, reason, references, 'type': 'mep' };
+            }
+            return {};
         }
-        return {};
-    });
+    );
 }
 
 /**
@@ -69,8 +71,8 @@ export function getMEPActivities (stanza) {
  * @param {Element} stanza - The message stanza
  * @returns {Object}
  */
-function getJIDFromMUCUserData (stanza) {
-    const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
+function getJIDFromMUCUserData(stanza) {
+    const item = sizzle(`message > x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
     return item?.getAttribute('jid');
 }
 
@@ -79,34 +81,34 @@ function getJIDFromMUCUserData (stanza) {
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
  * @returns {Object}
  */
-function getModerationAttributes (stanza) {
+function getDeprecatedModerationAttributes(stanza) {
     const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
     if (fastening) {
         const applies_to_id = fastening.getAttribute('id');
-        const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
+        const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE0}"]`, fastening).pop();
         if (moderated) {
-            const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
+            const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT0}"]`, moderated).pop();
             if (retracted) {
                 return {
-                    'editable': false,
-                    'moderated': 'retracted',
-                    'moderated_by': moderated.getAttribute('by'),
-                    'moderated_id': applies_to_id,
-                    'moderation_reason': moderated.querySelector('reason')?.textContent
+                    editable: false,
+                    moderated: 'retracted',
+                    moderated_by: moderated.getAttribute('by'),
+                    moderated_id: applies_to_id,
+                    moderation_reason: moderated.querySelector('reason')?.textContent,
                 };
             }
         }
     } else {
-        const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
+        const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE0}"]`, stanza).pop();
         if (tombstone) {
-            const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
+            const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT0}"]`, tombstone).pop();
             if (retracted) {
                 return {
-                    'editable': false,
-                    'is_tombstone': true,
-                    'moderated_by': tombstone.getAttribute('by'),
-                    'retracted': tombstone.getAttribute('stamp'),
-                    'moderation_reason': tombstone.querySelector('reason')?.textContent
+                    editable: false,
+                    is_tombstone: true,
+                    moderated_by: tombstone.getAttribute('by'),
+                    retracted: tombstone.getAttribute('stamp'),
+                    moderation_reason: tombstone.querySelector('reason')?.textContent,
                 };
             }
         }
@@ -114,11 +116,73 @@ function getModerationAttributes (stanza) {
     return {};
 }
 
+/**
+ * @param {Element} stanza - The message stanza
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns {Object}
+ */
+function getModerationAttributes(stanza) {
+    const retract = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+    if (retract) {
+        const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, retract).pop();
+        if (moderated) {
+            return {
+                editable: false,
+                moderated: 'retracted',
+                moderated_by: moderated.getAttribute('by'),
+                moderated_by_id: moderated.querySelector('occupant-id')?.getAttribute('id'),
+                moderated_id: retract.getAttribute('id'),
+                moderation_reason: retract.querySelector('reason')?.textContent,
+            };
+        }
+    } else {
+        const tombstone = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+        if (tombstone) {
+            return {
+                editable: false,
+                is_tombstone: true,
+                moderated_by: tombstone.getAttribute('by'),
+                moderated_by_id: tombstone.querySelector('occupant-id')?.getAttribute('id'),
+                retracted: tombstone.getAttribute('stamp'),
+                moderation_reason: tombstone.querySelector('reason')?.textContent,
+            };
+        }
+    }
+    return getDeprecatedModerationAttributes(stanza);
+}
+
+/**
+ * @param {Element} stanza
+ * @param {'presence'|'message'} type
+ * @returns {{codes: Array<import('./types').MUCStatusCode>, is_self: boolean}}
+ */
+function getStatusCodes(stanza, type) {
+    /**
+     * @typedef {import('./types').MUCStatusCode} MUCStatusCode
+     */
+    const codes = sizzle(`${type} > x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza)
+        .map(/** @param {Element} s */ (s) => s.getAttribute('code'))
+        .filter(
+            /** @param {MUCStatusCode} c */
+            (c) => STATUS_CODE_STANZAS[c]?.includes(type)
+        );
+
+    if (type === 'presence' && codes.includes('333') && codes.includes('307')) {
+        // See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
+        codes.splice(codes.indexOf('307'), 1);
+    }
+
+    return {
+        codes,
+        is_self: codes.includes('110'),
+    };
+}
+
 /**
  * @param {Element} stanza
  * @param {MUC} chatbox
  */
-function getOccupantID (stanza, chatbox) {
+function getOccupantID(stanza, chatbox) {
     if (chatbox.features.get(Strophe.NS.OCCUPANTID)) {
         return sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id');
     }
@@ -131,7 +195,7 @@ function getOccupantID (stanza, chatbox) {
  * @param {MUC} chatbox
  * @returns {'me'|'them'}
  */
-function getSender (attrs, chatbox) {
+function getSender(attrs, chatbox) {
     let is_me;
     const own_occupant_id = chatbox.get('occupant_id');
 
@@ -141,94 +205,121 @@ function getSender (attrs, chatbox) {
         const bare_jid = _converse.session.get('bare_jid');
         is_me = Strophe.getBareJidFromJid(attrs.from_real_jid) === bare_jid;
     } else {
-        is_me = attrs.nick === chatbox.get('nick')
+        is_me = attrs.nick === chatbox.get('nick');
     }
     return is_me ? 'me' : 'them';
 }
 
 /**
  * Parses a passed in message stanza and returns an object of attributes.
- * @param {Element} stanza - The message stanza
+ * @param {Element} original_stanza - The message stanza
  * @param {MUC} chatbox
  * @returns {Promise<MUCMessageAttributes|StanzaParseError>}
  */
-export async function parseMUCMessage (stanza, chatbox) {
-    throwErrorIfInvalidForward(stanza);
+export async function parseMUCMessage(original_stanza, chatbox) {
+    throwErrorIfInvalidForward(original_stanza);
 
-    const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`;
-    const original_stanza = stanza;
-    stanza = sizzle(selector, stanza).pop() || stanza;
+    const forwarded_stanza = sizzle(
+        `result[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`,
+        original_stanza
+    ).pop();
 
+    const stanza = forwarded_stanza || original_stanza;
     if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
         return new StanzaParseError(
-            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
-            stanza
+            stanza,
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
         );
     }
-    const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+
+    let delay;
+    let body;
+
+    if (forwarded_stanza) {
+        if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, forwarded_stanza).length) {
+            return new StanzaParseError(
+                original_stanza,
+                `Invalid Stanza: Forged MAM groupchat message from ${original_stanza.getAttribute('from')}`
+            );
+        }
+        delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, forwarded_stanza.parentElement).pop();
+        body = forwarded_stanza.querySelector(':scope > body')?.textContent?.trim();
+    } else {
+        delay = sizzle(`message > delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+        body = original_stanza.querySelector(':scope > body')?.textContent?.trim();
+    }
+
     const from = stanza.getAttribute('from');
     const marker = getChatMarker(stanza);
 
-    let attrs = /** @type {MUCMessageAttributes} */(Object.assign(
-        {
-            from,
-            'activities': getMEPActivities(stanza),
-            'body': stanza.querySelector(':scope > body')?.textContent?.trim(),
-            'chat_state': getChatState(stanza),
-            'from_muc': Strophe.getBareJidFromJid(from),
-            'is_archived': isArchived(original_stanza),
-            'is_carbon': isCarbon(original_stanza),
-            'is_delayed': !!delay,
-            'is_forwarded': !!stanza.querySelector('forwarded'),
-            'is_headline': isHeadline(stanza),
-            'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
-            'is_marker': !!marker,
-            'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
-            'marker_id': marker && marker.getAttribute('id'),
-            'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
-            'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
-            'occupant_id': getOccupantID(stanza, chatbox),
-            'receipt_id': getReceiptId(stanza),
-            'received': new Date().toISOString(),
-            'references': getReferences(stanza),
-            'subject': stanza.querySelector('subject')?.textContent,
-            'thread': stanza.querySelector('thread')?.textContent,
-            'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
-            'to': stanza.getAttribute('to'),
-            'type': stanza.getAttribute('type')
-        },
-        getErrorAttributes(stanza),
-        getOutOfBandAttributes(stanza),
-        getSpoilerAttributes(stanza),
-        getCorrectionAttributes(stanza, original_stanza),
-        getStanzaIDs(stanza, original_stanza),
-        getOpenGraphMetadata(stanza),
-        getRetractionAttributes(stanza, original_stanza),
-        getModerationAttributes(stanza),
-        getEncryptionAttributes(stanza),
-    ));
+    let attrs = /** @type {MUCMessageAttributes} */ (
+        Object.assign(
+            {
+                from,
+                body,
+                'activities': getMEPActivities(stanza),
+                'chat_state': getChatState(stanza),
+                'from_muc': Strophe.getBareJidFromJid(from),
+                'is_archived': isArchived(original_stanza),
+                'is_carbon': isCarbon(original_stanza),
+                'is_delayed': !!delay,
+                'is_forwarded': !!sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length,
+                'is_headline': isHeadline(stanza),
+                'is_markable': !!sizzle(`message > markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
+                'is_marker': !!marker,
+                'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
+                'marker_id': marker && marker.getAttribute('id'),
+                'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
+                'occupant_id': getOccupantID(stanza, chatbox),
+                'receipt_id': getReceiptId(stanza),
+                'received': new Date().toISOString(),
+                'references': getReferences(stanza),
+                'subject': stanza.querySelector(':scope > subject')?.textContent,
+                'thread': stanza.querySelector(':scope > thread')?.textContent,
+                'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString(),
+                'to': stanza.getAttribute('to'),
+                'type': stanza.getAttribute('type'),
+            },
+            getErrorAttributes(stanza),
+            getOutOfBandAttributes(stanza),
+            getSpoilerAttributes(stanza),
+            getCorrectionAttributes(stanza, original_stanza),
+            getStanzaIDs(stanza, original_stanza),
+            getOpenGraphMetadata(stanza),
+            getRetractionAttributes(stanza, original_stanza),
+            getModerationAttributes(stanza),
+            getEncryptionAttributes(stanza),
+            getStatusCodes(stanza, 'message')
+        )
+    );
 
-    attrs.from_real_jid = attrs.is_archived && getJIDFromMUCUserData(stanza) ||
-        chatbox.occupants.findOccupant(attrs)?.get('jid');
+    attrs.from_real_jid =
+        (attrs.is_archived && getJIDFromMUCUserData(stanza)) || chatbox.occupants.findOccupant(attrs)?.get('jid');
 
-    attrs = Object.assign({
-        'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
-        'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
-        'sender': getSender(attrs, chatbox),
-    }, attrs);
+    attrs = Object.assign(
+        {
+            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
+            'message': attrs.body || attrs.error, // TODO: Should only be used for error and info messages
+            'sender': getSender(attrs, chatbox),
+        },
+        attrs
+    );
 
     if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
         return new StanzaParseError(
-            `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`,
-            stanza
+            original_stanza,
+            `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`
         );
     } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
         return new StanzaParseError(
-            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
-            stanza
+            original_stanza,
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`
         );
     } else if (attrs.is_carbon) {
-        return new StanzaParseError('Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied', stanza);
+        return new StanzaParseError(
+            original_stanza,
+            'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'
+        );
     }
 
     // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
@@ -238,7 +329,7 @@ export async function parseMUCMessage (stanza, chatbox) {
      * *Hook* which allows plugins to add additional parsing
      * @event _converse#parseMUCMessage
      */
-    attrs = await api.hook('parseMUCMessage', stanza, attrs);
+    attrs = await api.hook('parseMUCMessage', original_stanza, attrs);
 
     // We call this after the hook, to allow plugins to decrypt encrypted
     // messages, since we need to parse the message text to determine whether
@@ -253,7 +344,7 @@ export async function parseMUCMessage (stanza, chatbox) {
  * @param {Element} iq
  * @returns {import('./types').MemberListItem[]}
  */
-export function parseMemberListIQ (iq) {
+export function parseMemberListIQ(iq) {
     return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(
         /** @param {Element} item */ (item) => {
             const data = {
@@ -280,53 +371,72 @@ export function parseMemberListIQ (iq) {
     );
 }
 
+/**
+ * @param {Element} stanza - The presence stanza
+ * @param {string} nick
+ * @returns {import('./types').MUCPresenceItemAttributes}
+ */
+function parsePresenceUserItem(stanza, nick) {
+    /**
+     * @typedef {import('./types').MUCAffiliation} MUCAffiliation
+     * @typedef {import('./types').MUCRole} MUCRole
+     */
+    const item = sizzle(`presence > x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
+    if (item) {
+        const actor = item.querySelector('actor');
+        return {
+            affiliation: /** @type {MUCAffiliation} */ (item.getAttribute('affiliation')),
+            role: /** @type {MUCRole} */ (item.getAttribute('role')),
+            jid: item.getAttribute('jid'),
+            nick: item.getAttribute('nick') || nick,
+            ...(actor
+                ? {
+                      actor: {
+                          nick: actor?.getAttribute('nick') ?? null,
+                          jid: actor?.getAttribute('jid') ?? null,
+                      },
+                  }
+                : {}),
+            reason: item.querySelector('reason')?.textContent ?? null,
+        };
+    }
+}
+
 /**
  * Parses a passed in MUC presence stanza and returns an object of attributes.
  * @param {Element} stanza - The presence stanza
  * @param {MUC} chatbox
- * @returns {import('./types').MUCPresenceAttributes}
+ * @returns {Promise<import('./types').MUCPresenceAttributes>}
  */
-export function parseMUCPresence (stanza, chatbox) {
+export async function parseMUCPresence(stanza, chatbox) {
+    /**
+     * @typedef {import('./types').MUCPresenceAttributes} MUCPresenceAttributes
+     */
     const from = stanza.getAttribute('from');
     const type = stanza.getAttribute('type');
-    const data = {
-        'is_me': !!stanza.querySelector("status[code='110']"),
-        'from': from,
-        'occupant_id': getOccupantID(stanza, chatbox),
-        'nick': Strophe.getResourceFromJid(from),
-        'type': type,
-        'states': [],
-        'hats': [],
-        'show': type !== 'unavailable' ? 'online' : 'offline'
-    };
-
-    Array.from(stanza.children).forEach(child => {
-        if (child.matches('status')) {
-            data.status = child.textContent || null;
-        } else if (child.matches('show')) {
-            data.show = child.textContent || 'online';
-        } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) {
-            Array.from(child.children).forEach(item => {
-                if (item.nodeName === 'item') {
-                    data.affiliation = item.getAttribute('affiliation');
-                    data.role = item.getAttribute('role');
-                    data.jid = item.getAttribute('jid');
-                    data.nick = item.getAttribute('nick') || data.nick;
-                } else if (item.nodeName == 'status' && item.getAttribute('code')) {
-                    data.states.push(item.getAttribute('code'));
-                }
-            });
-        } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) {
-            data.image_hash = child.querySelector('photo')?.textContent;
-        } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) {
-            data['hats'] = Array.from(child.children).map(
-                c =>
-                    c.matches('hat') && {
-                        'title': c.getAttribute('title'),
-                        'uri': c.getAttribute('uri')
-                    }
-            );
-        }
+    const nick = Strophe.getResourceFromJid(from);
+    const attrs = /** @type {MUCPresenceAttributes} */ ({
+        from,
+        nick,
+        type,
+        muc_jid: Strophe.getBareJidFromJid(from),
+        occupant_id: getOccupantID(stanza, chatbox),
+        status: stanza.querySelector(':scope > status')?.textContent ?? undefined,
+        show: stanza.querySelector(':scope > show')?.textContent ?? (type !== 'unavailable' ? 'online' : 'offline'),
+        image_hash: sizzle(`presence > x[xmlns="${Strophe.NS.VCARDUPDATE}"] photo`, stanza).pop()?.textContent,
+        hats: sizzle(`presence > hats[xmlns="${Strophe.NS.MUC_HATS}"] hat`, stanza).map(
+            /** @param {Element} h */ (h) => ({
+                title: h.getAttribute('title'),
+                uri: h.getAttribute('uri'),
+            })
+        ),
+        ...getStatusCodes(stanza, 'presence'),
+        ...parsePresenceUserItem(stanza, nick),
     });
-    return data;
+
+    /**
+     * *Hook* which allows plugins to add additional parsing
+     * @event _converse#parseMUCPresence
+     */
+    return /** @type {import('./types').MUCPresenceAttributes}*/ (await api.hook('parseMUCPresence', stanza, attrs));
 }

+ 43 - 72
src/headless/plugins/muc/plugin.js

@@ -83,22 +83,22 @@ converse.plugins.add('converse-muc', {
         // Refer to docs/source/configuration.rst for explanations of these
         // configuration settings.
         api.settings.extend({
-            'allow_muc_invitations': true,
-            'auto_join_on_invite': false,
-            'auto_join_rooms': [],
-            'auto_register_muc_nickname': false,
-            'colorize_username': false,
-            'hide_muc_participants': false,
-            'locked_muc_domain': false,
-            'modtools_disable_assign': false,
-            'muc_clear_messages_on_leave': true,
-            'muc_domain': undefined,
-            'muc_fetch_members': true,
-            'muc_history_max_stanzas': undefined,
-            'muc_instant_rooms': true,
-            'muc_nickname_from_jid': false,
-            'muc_send_probes': false,
-            'muc_show_info_messages': [
+            allow_muc_invitations: true,
+            auto_join_on_invite: false,
+            auto_join_rooms: [],
+            auto_register_muc_nickname: true,
+            colorize_username: false,
+            hide_muc_participants: false,
+            locked_muc_domain: false,
+            modtools_disable_assign: false,
+            muc_clear_messages_on_leave: true,
+            muc_domain: undefined,
+            muc_fetch_members: true,
+            muc_history_max_stanzas: undefined,
+            muc_instant_rooms: true,
+            muc_nickname_from_jid: false,
+            muc_send_probes: false,
+            muc_show_info_messages: [
                 ...converse.MUC.INFO_CODES.visibility_changes,
                 ...converse.MUC.INFO_CODES.self,
                 ...converse.MUC.INFO_CODES.non_privacy_changes,
@@ -109,8 +109,8 @@ converse.plugins.add('converse-muc', {
                 ...converse.MUC.INFO_CODES.join_leave_events,
                 ...converse.MUC.INFO_CODES.role_changes,
             ],
-            'muc_show_logs_before_join': false,
-            'muc_subscribe_to_rai': false,
+            muc_show_logs_before_join: false,
+            muc_subscribe_to_rai: false,
         });
         api.promises.add(['roomsAutoJoined']);
 
@@ -125,61 +125,33 @@ converse.plugins.add('converse-muc', {
         Object.assign(api, muc_api);
         Object.assign(api.rooms, affiliations_api);
 
-        /* https://xmpp.org/extensions/xep-0045.html
-         * ----------------------------------------
-         * 100 message      Entering a groupchat         Inform user that any occupant is allowed to see the user's full JID
-         * 101 message (out of band)                     Affiliation change  Inform user that his or her affiliation changed while not in the groupchat
-         * 102 message      Configuration change         Inform occupants that groupchat now shows unavailable members
-         * 103 message      Configuration change         Inform occupants that groupchat now does not show unavailable members
-         * 104 message      Configuration change         Inform occupants that a non-privacy-related groupchat configuration change has occurred
-         * 110 presence     Any groupchat presence       Inform user that presence refers to one of its own groupchat occupants
-         * 170 message or initial presence               Configuration change    Inform occupants that groupchat logging is now enabled
-         * 171 message      Configuration change         Inform occupants that groupchat logging is now disabled
-         * 172 message      Configuration change         Inform occupants that the groupchat is now non-anonymous
-         * 173 message      Configuration change         Inform occupants that the groupchat is now semi-anonymous
-         * 174 message      Configuration change         Inform occupants that the groupchat is now fully-anonymous
-         * 201 presence     Entering a groupchat         Inform user that a new groupchat has been created
-         * 210 presence     Entering a groupchat         Inform user that the service has assigned or modified the occupant's roomnick
-         * 301 presence     Removal from groupchat       Inform user that he or she has been banned from the groupchat
-         * 303 presence     Exiting a groupchat          Inform all occupants of new groupchat nickname
-         * 307 presence     Removal from groupchat       Inform user that he or she has been kicked from the groupchat
-         * 321 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of an affiliation change
-         * 322 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
-         * 332 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of a system shutdown
+        /**
+         * https://xmpp.org/extensions/xep-0045.html
+         * -----------------------------------------
          */
-        const MUC_FEEDBACK_MESSAGES = {
-            info_messages: {
-                100: __('This groupchat is not anonymous'),
-                102: __('This groupchat now shows unavailable members'),
-                103: __('This groupchat does not show unavailable members'),
-                104: __('The groupchat configuration has changed'),
-                170: __('Groupchat logging is now enabled'),
-                171: __('Groupchat logging is now disabled'),
-                172: __('This groupchat is now no longer anonymous'),
-                173: __('This groupchat is now semi-anonymous'),
-                174: __('This groupchat is now fully-anonymous'),
-                201: __('A new groupchat has been created'),
-            },
-
-            new_nickname_messages: {
-                // XXX: Note the triple underscore function and not double underscore.
-                210: ___('Your nickname has been automatically set to %1$s'),
-                303: ___('Your nickname has been changed to %1$s'),
-            },
-
-            disconnect_messages: {
-                301: __('You have been banned from this groupchat'),
-                333: __('You have exited this groupchat due to a technical problem'),
-                307: __('You have been kicked from this groupchat'),
-                321: __('You have been removed from this groupchat because of an affiliation change'),
-                322: __(
-                    "You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"
-                ),
-                332: __('You have been removed from this groupchat because the service hosting it is being shut down'),
-            },
+        const STATUS_CODE_MESSAGES = {
+            '100': __('This groupchat is not anonymous'),
+            '102': __('This groupchat now shows unavailable members'),
+            '103': __('This groupchat does not show unavailable members'),
+            '104': __('The groupchat configuration has changed'),
+            '170': __('Groupchat logging is now enabled'),
+            '171': __('Groupchat logging is now disabled'),
+            '172': __('This groupchat is now no longer anonymous'),
+            '173': __('This groupchat is now semi-anonymous'),
+            '174': __('This groupchat is now fully-anonymous'),
+            '201': __('A new groupchat has been created'),
+            // XXX: Note the triple underscore function and not double underscore.
+            '210': ___('Your nickname has been automatically set to %1$s'),
+            '301': __('You have been banned from this groupchat'),
+            // XXX: Note the triple underscore function and not double underscore.
+            '303': ___('Your nickname has been changed to %1$s'),
+            '307': __('You have been kicked from this groupchat'),
+            '321': __('You have been removed from this groupchat because of an affiliation change'),
+            '322': __("You have been removed from this groupchat because it has changed to members-only and you're not a member"),
+            '332': __('You have been removed from this groupchat because the service hosting it is being shut down'),
+            '333': __('You have exited this groupchat due to a technical problem'),
         };
-
-        const labels = { muc: MUC_FEEDBACK_MESSAGES };
+        const labels = { muc: { STATUS_CODE_MESSAGES }};
         Object.assign(_converse.labels, labels);
         Object.assign(_converse, labels); // XXX DEPRECATED
 
@@ -211,7 +183,6 @@ converse.plugins.add('converse-muc', {
 
         /** @type {module:shared-api.APIEndpoint} */(api.chatboxes.registry).add(CHATROOMS_TYPE, MUC);
 
-
         if (api.settings.get('allow_muc_invitations')) {
             api.listen.on('connected', registerDirectInvitationHandler);
             api.listen.on('reconnected', registerDirectInvitationHandler);

+ 20 - 24
src/headless/plugins/muc/tests/affiliations.js

@@ -1,41 +1,37 @@
 /*global mock, converse */
-
-const $pres = converse.env.$pres;
-const Strophe = converse.env.Strophe;
+const { stx, Strophe } = converse.env;
 
 describe('The MUC Affiliations API', function () {
 
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
     it('can be used to set affiliations in MUCs without having to join them first',
         mock.initConverse([], {}, async function (_converse) {
             const { api } = _converse;
             const user_jid = 'annoyingguy@montague.lit';
             const muc_jid = 'lounge@montague.lit';
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const 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': user_jid,
-                    'affiliation': 'member',
-                    'role': 'participant'
-                });
+            const presence = stx`
+                <presence from="lounge@montague.lit/annoyingGuy"
+                        id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user"/>
+                    <item jid="${user_jid}" affiliation="member" role="participant"/>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
-            api.rooms.affiliations.set(muc_jid, { 'jid': user_jid, 'affiliation': 'outcast', 'reason': 'Ban hammer!' });
+            api.rooms.affiliations.set(muc_jid, { jid: user_jid, affiliation: 'outcast', reason: 'Ban hammer!' });
 
             const iq = _converse.api.connection.get().IQ_stanzas.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#admin">` +
-                        `<item affiliation="outcast" jid="${user_jid}">` +
-                            `<reason>Ban hammer!</reason>` +
-                        `</item>` +
-                    `</query>` +
-                `</iq>`);
-
+            expect(iq).toEqualStanza(stx`
+                <iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">
+                    <query xmlns="http://jabber.org/protocol/muc#admin">
+                        <item affiliation="outcast" jid="${user_jid}">
+                            <reason>Ban hammer!</reason>
+                        </item>
+                    </query>
+                </iq>`);
         })
     );
 });

+ 34 - 29
src/headless/plugins/muc/tests/messages.js

@@ -1,39 +1,41 @@
 /*global mock, converse */
-
-const { Strophe, u, $msg } = converse.env;
+const { Strophe, u, $msg, stx } = converse.env;
 
 describe("A MUC message", function () {
 
     it("saves the user's real JID as looked up via the XEP-0421 occupant id",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitUntilBookmarksReturned(_converse);
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
         const occupant_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        const presence = u.toStanza(`
-            <presence
-                from="${muc_jid}/thirdwitch"
-                id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
-            <x xmlns="http://jabber.org/protocol/muc#user">
-                <item jid="${occupant_jid}" />
-            </x>
-            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </presence>`);
+        const presence = stx`
+            <presence from="${muc_jid}/thirdwitch"
+                    id="${u.getUniqueId()}"
+                    to="${_converse.bare_jid}"
+                    xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item jid="${occupant_jid}" />
+                </x>
+                <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-        expect(model.getOccupantByNickname('thirdwitch').get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
 
-        const stanza = u.toStanza(`
-            <message
-                from='${muc_jid}/thirdwitch'
-                id='hysf1v37'
-                to='${_converse.bare_jid}'
-                type='groupchat'>
-            <body>Harpier cries: 'tis time, 'tis time.</body>
-            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </message>`);
+        const occupant = await u.waitUntil(() => model.getOccupantByNickname('thirdwitch'));
+        expect(occupant.get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
+
+        const stanza = stx`
+            <message from='${muc_jid}/thirdwitch'
+                    id='hysf1v37'
+                    to='${_converse.bare_jid}'
+                    type='groupchat'
+                    xmlns="jabber:client">
+                <body>Harpier cries: 'tis time, 'tis time.</body>
+                <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => model.messages.length);
@@ -94,8 +96,12 @@ describe("A MUC message", function () {
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const impersonated_jid = `${muc_jid}/alice`;
-        const received_stanza = u.toStanza(`
-            <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+        const received_stanza = stx`
+            <message to='${_converse.jid}'
+                    xmlns="jabber:client"
+                    from='${muc_jid}/mallory'
+                    type='groupchat'
+                    id='${_converse.api.connection.get().getUniqueId()}'>
                 <forwarded xmlns='urn:xmpp:forward:0'>
                     <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
                     <message from='${impersonated_jid}'
@@ -106,8 +112,7 @@ describe("A MUC message", function () {
                         <body>Yet I should kill thee with much cherishing.</body>
                     </message>
                 </forwarded>
-            </message>
-        `);
+            </message>`;
         spyOn(converse.env.log, 'error').and.callThrough();
         _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
         await u.waitUntil(() => converse.env.log.error.calls.count() === 1);
@@ -123,8 +128,8 @@ describe("A MUC message", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const received_stanza = u.toStanza(`
-            <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}' >
+        const received_stanza = stx`
+            <message xmlns="jabber:client" to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}' >
                 <reply xmlns='urn:xmpp:reply:0' id='${_converse.api.connection.get().getUniqueId()}' to='${_converse.jid}'/>
                 <fallback xmlns='urn:xmpp:feature-fallback:0' for='urn:xmpp:reply:0'>
                     <body start='0' end='10'/>
@@ -134,7 +139,7 @@ describe("A MUC message", function () {
     pong</body>
                 <request xmlns='urn:xmpp:receipts'/>
             </message>
-        `);
+        `;
         await model.handleMessageStanza(received_stanza);
         await u.waitUntil(() => model.messages.last());
         expect(model.messages.last().get('body')).toBe('> ping\n    pong');

+ 27 - 16
src/headless/plugins/muc/tests/occupants.js

@@ -1,11 +1,13 @@
 /*global mock, converse */
-
-const { Strophe, u } = converse.env;
+const { Strophe, u, stx } = converse.env;
 
 describe("A MUC occupant", function () {
 
     it("does not stores the XEP-0421 occupant id if the feature isn't advertised",
             mock.initConverse([], {}, async function (_converse) {
+
+        await mock.waitUntilBookmarksReturned(_converse);
+
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
@@ -13,21 +15,26 @@ describe("A MUC occupant", function () {
         // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
         const id = u.getUniqueId();
         const name = mock.chatroom_names[0];
-        const presence = u.toStanza(`
+        const presence = stx`
             <presence
                 from="${muc_jid}/${name}"
                 id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
+                to="${_converse.bare_jid}"
+                xmlns="jabber:client">
             <x xmlns="http://jabber.org/protocol/muc#user" />
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${id}" />
-            </presence>`);
+            </presence>`;
+
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-        expect(model.getOccupantByNickname(name).get('occupant_id')).toBe(undefined);
+        const occupant = await u.waitUntil(() => model.getOccupantByNickname(name));
+        expect(occupant.get('occupant_id')).toBe(undefined);
     }));
 
     it("stores the XEP-0421 occupant id received from a presence stanza",
             mock.initConverse([], {}, async function (_converse) {
 
+        await mock.waitUntilBookmarksReturned(_converse);
+
         const muc_jid = 'lounge@montague.lit';
         const nick = 'romeo';
         const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
@@ -41,16 +48,18 @@ describe("A MUC occupant", function () {
             // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
             const id = u.getUniqueId();
             const name = mock.chatroom_names[i];
-            const presence = u.toStanza(`
+            const presence = stx`
                 <presence
                     from="${muc_jid}/${name}"
                     id="${u.getUniqueId()}"
-                    to="${_converse.bare_jid}">
+                    to="${_converse.bare_jid}"
+                    xmlns="jabber:client">
                 <x xmlns="http://jabber.org/protocol/muc#user" />
                 <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${id}" />
-                </presence>`);
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
-            expect(model.getOccupantByNickname(name).get('occupant_id')).toBe(id);
+            const occupant = await u.waitUntil(() => model.getOccupantByNickname(name));
+            expect(occupant.get('occupant_id')).toBe(id);
         }
         expect(model.occupants.length).toBe(mock.chatroom_names.length + 1);
     }));
@@ -69,15 +78,16 @@ describe("A MUC occupant", function () {
 
         const occupant_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
 
-        const stanza = u.toStanza(`
+        const stanza = stx`
             <message
                 from='${muc_jid}/3rdwitch'
                 id='hysf1v37'
                 to='${_converse.bare_jid}'
-                type='groupchat'>
+                type='groupchat'
+                xmlns="jabber:client">
             <body>Harpier cries: 'tis time, 'tis time.</body>
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => model.messages.length);
@@ -93,16 +103,17 @@ describe("A MUC occupant", function () {
 
         expect(message.getDisplayName()).toBe('3rdwitch');
 
-        const presence = u.toStanza(`
+        const presence = stx`
             <presence
                 from="${muc_jid}/thirdwitch"
                 id="${u.getUniqueId()}"
-                to="${_converse.bare_jid}">
+                to="${_converse.bare_jid}"
+                xmlns="jabber:client">
             <x xmlns="http://jabber.org/protocol/muc#user">
                 <item jid="${occupant_jid}" />
             </x>
             <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
-            </presence>`);
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
         occupant = await u.waitUntil(() => model.getOccupantByNickname('thirdwitch'));

+ 74 - 57
src/headless/plugins/muc/tests/registration.js

@@ -2,7 +2,8 @@
 
 const { $iq, Strophe, sizzle, u } = converse.env;
 
-describe("Chatrooms", function () {
+describe("Groupchats", function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
     describe("The auto_register_muc_nickname option", function () {
 
@@ -10,43 +11,55 @@ describe("Chatrooms", function () {
                 mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
                 async function (_converse) {
 
+            const nick = 'romeo';
             const muc_jid = 'coven@chat.shakespeare.lit';
-            const room = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
 
-            let stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
-                iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
-            ).pop());
+            const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
+            let stanza = await u.waitUntil(() => IQ_stanzas.find(
+                iq => sizzle(`iq[type="get"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length));
 
-            expect(Strophe.serialize(stanza))
-            .toBe(`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" `+
-                        `type="get" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:register"/></iq>`);
-            const result = $iq({
-                'from': room.get('jid'),
-                'id': stanza.getAttribute('id'),
-                'to': _converse.bare_jid,
-                'type': 'result',
-            }).c('query', {'xmlns': '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.api.connection.get()._dataRecv(mock.createRequest(result));
-            stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
-                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
-            ).pop());
+            expect(stanza).toEqualStanza(
+                stx`<iq to="${muc_jid}"
+                        type="get"
+                        xmlns="jabber:client"
+                        id="${stanza.getAttribute('id')}"><query xmlns="jabber:iq:register"/></iq>`);
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<iq from="${muc_jid}"
+                        id="${stanza.getAttribute('id')}"
+                        to="${_converse.session.get('jid')}"
+                        xmlns="jabber:client"
+                        type="result">
+                    <query xmlns='jabber:iq:register'>
+                        <x xmlns='jabber:x:data' type='form'>
+                            <field
+                                type='hidden'
+                                var='FORM_TYPE'>
+                                <value>http://jabber.org/protocol/muc#register</value>
+                            </field>
+                            <field
+                                label='Desired Nickname'
+                                type='text-single'
+                                var='muc#register_roomnick'>
+                                <required/>
+                            </field>
+                        </x>
+                    </query>
+                </iq>`));
 
-            expect(Strophe.serialize(stanza)).toBe(
-                `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" 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>`);
+            stanza = await u.waitUntil(() => IQ_stanzas.find(
+                iq => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length));
+
+            expect(stanza).toEqualStanza(
+                stx`<iq xmlns="jabber:client" to="${muc_jid}" type="set" id="${stanza.getAttribute('id')}">
+                    <query xmlns="jabber:iq:register">
+                        <x xmlns="jabber:x:data" type="submit">
+                            <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>`);
         }));
 
         it("allows you to automatically deregister your nickname when closing a room",
@@ -59,18 +72,21 @@ describe("Chatrooms", function () {
             let stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
                 iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
             ).pop());
-            let result = $iq({
-                'from': room.get('jid'),
-                'id': stanza.getAttribute('id'),
-                'to': _converse.bare_jid,
-                'type': 'result',
-            }).c('query', {'xmlns': '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');
+            let result = stx`<iq from="${room.get('jid')}"
+                        id="${stanza.getAttribute('id')}"
+                        to="${_converse.bare_jid}"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query xmlns="jabber:iq:register">
+                        <x xmlns="jabber:x:data" type="form">
+                            <field label="Desired Nickname"
+                                   type="text-single"
+                                   var="muc#register_roomnick">
+                                <required/>
+                            </field>
+                        </x>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result));
             await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
                 iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
@@ -82,17 +98,18 @@ describe("Chatrooms", function () {
             stanza = await u.waitUntil(() => _converse.api.connection.get().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="${muc_jid}" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:register"><remove/></query>`+
-                `</iq>`);
-
-            result = $iq({
-                'from': room.get('jid'),
-                'id': stanza.getAttribute('id'),
-                'to': _converse.bare_jid,
-                'type': 'result',
-            }).c('query', {'xmlns': 'jabber:iq:register'});
+            expect(stanza).toEqualStanza(
+                stx`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">
+                    <query xmlns="jabber:iq:register"><remove/></query>
+                </iq>`);
+
+            result = stx`<iq from="${room.get('jid')}"
+                        id="${stanza.getAttribute('id')}"
+                        to="${_converse.bare_jid}"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query xmlns="jabber:iq:register"></query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result));
 
         }));

+ 80 - 8
src/headless/plugins/muc/types.ts

@@ -1,4 +1,57 @@
+import { CHAT_STATES } from '../../shared/constants';
 import { MessageAttributes } from '../chat/types';
+import MUC from './muc';
+
+export type MUCStatusCode =
+    | '100'
+    | '101'
+    | '102'
+    | '103'
+    | '104'
+    | '110'
+    | '170'
+    | '171'
+    | '172'
+    | '173'
+    | '174'
+    | '201'
+    | '210'
+    | '301'
+    | '303'
+    | '307'
+    | '321'
+    | '322'
+    | '332'
+    | '333';
+
+export type DefaultMUCAttributes = {
+    bookmarked: boolean;
+    chat_state: typeof CHAT_STATES;
+    has_activity: boolean; // XEP-437
+    hidden: boolean;
+    hidden_occupants: boolean;
+    message_type: 'groupchat';
+    name: string;
+    num_unread: number;
+    num_unread_general: number;
+    roomconfig: Object;
+    time_opened: number;
+    time_sent: string;
+    type: 'chatroom';
+};
+
+// An object containing the parsed {@link MUCMessageAttributes} and current {@link MUC}.
+export type MUCMessageEventData = {
+    stanza: Element;
+    attrs: MUCMessageAttributes;
+    chatbox: MUC;
+}
+
+export type MUCAttributes = DefaultMUCAttributes & {
+    jid: string;
+    nick: string;
+    password: string;
+};
 
 type ExtraMUCAttributes = {
     activities: Array<Object>; // A list of objects representing XEP-0316 MEP notification data
@@ -9,16 +62,20 @@ type ExtraMUCAttributes = {
     moderated_id: string; // The  XEP-0359 Stanza ID of the message that this one moderates
     moderation_reason: string; // The reason provided why this message moderates another
     occupant_id: string; // The XEP-0421 occupant ID
+    codes: MUCStatusCode[];
 };
 
 export type MUCMessageAttributes = MessageAttributes & ExtraMUCAttributes;
 
+export type MUCAffiliation = 'owner'|'admin'|'member'|'outcast'|'none';
+export type MUCRole = 'moderator'|'participant'|'visitor'|'none';
+
 /**
  * Either the JID or the nickname (or both) will be available.
  */
 export type MemberListItem = {
-    affiliation: string;
-    role?: string;
+    affiliation: MUCAffiliation;
+    role?: MUCRole;
     jid?: string;
     nick?: string;
 };
@@ -31,14 +88,29 @@ export type MUCHat = {
     uri: string;
 };
 
-export type MUCPresenceAttributes = {
-    show: string;
-    hats: Array<MUCHat>; // An array of XEP-0317 hats
-    states: Array<string>;
+export type MUCPresenceItemAttributes = {
+    actor?: {
+        nick?: string;
+        jid?: string;
+    };
+    affiliation?: MUCAffiliation;
+    jid?: string;
+    nick: string;
+    reason?: string;
+    role?: MUCRole;
+}
+
+export type MUCPresenceAttributes = MUCPresenceItemAttributes & {
+    codes: MUCStatusCode[];
     from: string; // The sender JID (${muc_jid}/${nick})
+    hats: Array<MUCHat>; // An array of XEP-0317 hats
+    image_hash?: string;
+    is_self: boolean;
+    muc_jid: string; // The JID of the MUC in which the presence was received
     nick: string; // The nickname of the sender
     occupant_id: string; // The XEP-0421 occupant ID
+    show: string;
+    states: Array<string>;
+    status?: string;
     type: string; // The type of presence
-    jid?: string;
-    is_me?: boolean;
 };

+ 20 - 1
src/headless/plugins/muc/utils.js

@@ -9,6 +9,25 @@ import { getUnloadEvent } from '../../utils/session.js';
 
 const { Strophe, sizzle, u } = converse.env;
 
+/**
+ * @returns {Promise<string|undefined>}
+ */
+export async function getDefaultMUCService () {
+    let muc_service = api.settings.get('muc_domain') || _converse.session.get('default_muc_service');
+    if (!muc_service) {
+        const domain = _converse.session.get('domain');
+        const items = await api.disco.entities.items(domain);
+        for (const item of items) {
+            if (await api.disco.features.has(Strophe.NS.MUC, item.get('jid'))) {
+                muc_service = item.get('jid');
+                _converse.session.save({ default_muc_service: muc_service });
+                break;
+            }
+        }
+    }
+    return muc_service;
+}
+
 /**
  * @param {import('@converse/skeletor').Model} model
  */
@@ -159,7 +178,7 @@ export function getDefaultMUCNickname () {
 /**
  * Determines info message visibility based on
  * muc_show_info_messages configuration setting
- * @param {*} code
+ * @param {import('./types').MUCStatusCode} code
  * @memberOf _converse
  */
 export function isInfoVisible (code) {

+ 1 - 1
src/headless/plugins/ping/api.js

@@ -20,7 +20,7 @@ export default {
      */
     async ping (jid, timeout) {
         if (!api.connection.authenticated()) {
-            log.warn("Not pinging when we know we're not authenticated");
+            log.debug("Not pinging when we know we're not authenticated");
             return null;
         }
 

+ 0 - 93
src/headless/plugins/pubsub.js

@@ -1,93 +0,0 @@
-/**
- * @module converse-pubsub
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- * @typedef {import('strophe.js/src/builder.js').Builder} Strophe.Builder
- */
-import "./disco/index.js";
-import _converse from '../shared/_converse.js';
-import api from '../shared/api/index.js';
-import converse from '../shared/api/public.js';
-import log from "../log.js";
-
-const { Strophe, $iq } = converse.env;
-
-Strophe.addNamespace('PUBSUB_ERROR', Strophe.NS.PUBSUB+"#errors");
-
-
-converse.plugins.add('converse-pubsub', {
-
-    dependencies: ["converse-disco"],
-
-    initialize () {
-
-        /************************ BEGIN API ************************/
-        // We extend the default converse.js API to add methods specific to MUC groupchats.
-        Object.assign(_converse.api, {
-            /**
-             * The "pubsub" namespace groups methods relevant to PubSub
-             *
-             * @namespace _converse.api.pubsub
-             * @memberOf _converse.api
-             */
-            'pubsub': {
-                /**
-                 * Publshes an item to a PubSub node
-                 *
-                 * @method _converse.api.pubsub.publish
-                 * @param { string } jid The JID of the pubsub service where the node resides.
-                 * @param { string } node The node being published to
-                 * @param {Strophe.Builder} item The Strophe.Builder representation of the XML element being published
-                 * @param { object } options An object representing the publisher options
-                 *      (see https://xmpp.org/extensions/xep-0060.html#publisher-publish-options)
-                 * @param { boolean } strict_options Indicates whether the publisher
-                 *      options are a strict requirement or not. If they're NOT
-                 *      strict, then Converse will publish to the node even if
-                 *      the publish options precondication cannot be met.
-                 */
-                async 'publish' (jid, node, item, options, strict_options=true) {
-                    const bare_jid = _converse.session.get('bare_jid');
-                    const stanza = $iq({
-                        'from': bare_jid,
-                        'type': 'set',
-                        'to': jid
-                    }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB})
-                        .c('publish', {'node': node})
-                            .cnode(item.tree()).up().up();
-
-                    if (options) {
-                        jid = jid || bare_jid;
-                        if (await api.disco.supports(Strophe.NS.PUBSUB + '#publish-options', jid)) {
-                            stanza.c('publish-options')
-                                .c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
-                                    .c('field', {'var': 'FORM_TYPE', 'type': 'hidden'})
-                                        .c('value').t(`${Strophe.NS.PUBSUB}#publish-options`).up().up()
-
-                            Object.keys(options).forEach(k => stanza.c('field', {'var': k}).c('value').t(options[k]).up().up());
-                        } else {
-                            log.warn(`_converse.api.publish: ${jid} does not support #publish-options, `+
-                                     `so we didn't set them even though they were provided.`)
-                        }
-                    }
-                    try {
-                        await api.sendIQ(stanza);
-                    } catch (iq) {
-                        if (iq instanceof Element &&
-                                strict_options &&
-                                iq.querySelector(`precondition-not-met[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`)) {
-
-                            // The publish-options precondition couldn't be
-                            // met. We re-publish but without publish-options.
-                            const el = stanza.tree();
-                            el.querySelector('publish-options').outerHTML = '';
-                            log.warn(`PubSub: Republishing without publish options. ${el.outerHTML}`);
-                            await api.sendIQ(el);
-                        } else {
-                            throw iq;
-                        }
-                    }
-                }
-            }
-        });
-    }
-});

+ 202 - 0
src/headless/plugins/pubsub/api.js

@@ -0,0 +1,202 @@
+/**
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import converse from '../../shared/api/public.js';
+import _converse from '../../shared/_converse.js';
+import api from '../../shared/api/index.js';
+import log from '../../log.js';
+import { parseErrorStanza } from '../../shared/parsers.js';
+import { parseStanzaForPubSubConfig } from './parsers.js';
+
+const { Strophe, stx } = converse.env;
+
+export default {
+    /**
+     * @typedef {import('strophe.js').Builder} Builder
+     * @typedef {import('strophe.js').Stanza} Stanza
+     * @typedef {import('./types').PubSubConfigOptions} PubSubConfigOptions
+     *
+     * The "pubsub" namespace groups methods relevant to PubSub
+     * @namespace _converse.api.pubsub
+     * @memberOf _converse.api
+     */
+    pubsub: {
+        config: {
+            /**
+             * Fetches the configuration for a PubSub node
+             * @method _converse.api.pubsub.config.get
+             * @param {string} jid - The JID of the pubsub service where the node resides
+             * @param {string} node - The node to configure
+             * @returns {Promise<import('./types').PubSubConfigOptions>}
+             */
+            async get(jid, node) {
+                if (!node) throw new Error('api.pubsub.config.get: Node value required');
+
+                const bare_jid = _converse.session.get('bare_jid');
+                const full_jid = _converse.session.get('jid');
+                const entity_jid = jid || bare_jid;
+
+                const stanza = stx`
+                    <iq xmlns="jabber:client"
+                        from="${full_jid}"
+                        type="get"
+                        to="${entity_jid}">
+                    <pubsub xmlns="${Strophe.NS.PUBSUB}"><configure node="${node}"/></pubsub>
+                    </iq>`;
+
+                let response;
+                try {
+                    response = await api.sendIQ(stanza);
+                } catch (error) {
+                    throw await parseErrorStanza(error);
+                }
+                return parseStanzaForPubSubConfig(response);
+            },
+
+            /**
+             * Configures a PubSub node
+             * @method _converse.api.pubsub.config.set
+             * @param {string} jid The JID of the pubsub service where the node resides
+             * @param {string} node The node to configure
+             * @param {PubSubConfigOptions} config The configuration options
+             * @returns {Promise<import('./types').PubSubConfigOptions>}
+             */
+            async set(jid, node, config) {
+                if (!node) throw new Error('api.pubsub.config.set: Node value required');
+
+                const bare_jid = _converse.session.get('bare_jid');
+                const entity_jid = jid || bare_jid;
+                const new_config = {
+                    ...(await api.pubsub.config.get(entity_jid, node)),
+                    ...config,
+                };
+
+                const stanza = stx`
+                    <iq xmlns="jabber:client"
+                        from="${bare_jid}"
+                        type="set"
+                        to="${entity_jid}">
+                    <pubsub xmlns="${Strophe.NS.PUBSUB}#owner">
+                        <configure node="${node}">
+                            <x xmlns="${Strophe.NS.XFORM}" type="submit">
+                                <field var="FORM_TYPE" type="hidden">
+                                    <value>${Strophe.NS.PUBSUB}#nodeconfig</value>
+                                </field>
+                                ${Object.entries(new_config).map(([k, v]) => stx`<field var="${k}"><value>${v}</value></field>`)}
+                            </x>
+                        </configure>
+                    </pubsub>
+                    </iq>`;
+
+                try {
+                    await api.sendIQ(stanza);
+                } catch (error) {
+                    throw await parseErrorStanza(error);
+                }
+                return new_config;
+            },
+        },
+
+        /**
+         * Publishes an item to a PubSub node
+         * @method _converse.api.pubsub.publish
+         * @param {string} jid The JID of the pubsub service where the node resides.
+         * @param {string} node The node being published to
+         * @param {Builder|Stanza|(Builder|Stanza)[]} item The XML element(s) being published
+         * @param {PubSubConfigOptions} options The publisher options
+         *      (see https://xmpp.org/extensions/xep-0060.html#publisher-publish-options)
+         * @param {boolean} strict_options Indicates whether the publisher
+         *      options are a strict requirement or not. If they're NOT
+         *      strict, then Converse will publish to the node even if
+         *      the publish options precondition cannot be met.
+         * @returns {Promise<void|Element>}
+         */
+        async publish(jid, node, item, options, strict_options = true) {
+            if (!node) throw new Error('api.pubsub.publish: node value required');
+            if (!item) throw new Error('api.pubsub.publish: item value required');
+
+            const bare_jid = _converse.session.get('bare_jid');
+            const entity_jid = jid || bare_jid;
+
+            const stanza = stx`
+                <iq xmlns="jabber:client"
+                    from="${bare_jid}"
+                    type="set"
+                    to="${entity_jid}">
+                <pubsub xmlns="${Strophe.NS.PUBSUB}">
+                    <publish node="${node}">${item}</publish>
+                    ${
+                        options
+                            ? stx`<publish-options>
+                    <x xmlns="${Strophe.NS.XFORM}" type="submit">
+                        <field var="FORM_TYPE" type="hidden">
+                            <value>${Strophe.NS.PUBSUB}#publish-options</value>
+                        </field>
+                        ${Object.entries(options).map(([k, v]) => stx`<field var="pubsub#${k}"><value>${v}</value></field>`)}
+                    </x></publish-options>`
+                            : ''
+                    }
+                </pubsub>
+                </iq>`;
+
+            if (entity_jid === bare_jid) {
+                // This is PEP, check for support
+                const supports_pep =
+                    (await api.disco.getIdentity('pubsub', 'pep', bare_jid)) ||
+                    (await api.disco.getIdentity('pubsub', 'pep', Strophe.getDomainFromJid(bare_jid)));
+
+                if (!supports_pep) {
+                    log.warn(`api.pubsub.publish: Not publishing via PEP because it's not supported!`);
+                    log.warn(stanza);
+                    return;
+                }
+            }
+
+            // Check for #publish-options support.
+            const supports_publish_options =
+                (await api.disco.supports(Strophe.NS.PUBSUB + '#publish-options', entity_jid)) ||
+                (entity_jid === bare_jid &&
+                    // XEP-0223 says we need to check the server for support
+                    // (although Prosody returns it on the bare jid)
+                    (await api.disco.supports(
+                        Strophe.NS.PUBSUB + '#publish-options',
+                        Strophe.getDomainFromJid(entity_jid)
+                    )));
+
+            if (!supports_publish_options && strict_options) {
+                log.warn(`api.pubsub.publish: #publish-options not supported, refusing to publish item.`);
+                log.warn(stanza);
+                return;
+            }
+
+            try {
+                await api.sendIQ(stanza);
+            } catch (iq) {
+                const e = await parseErrorStanza(iq);
+                if (
+                    e.name === 'conflict' &&
+                    /** @type {import('shared/errors').StanzaError} */(e).extra[Strophe.NS.PUBSUB_ERROR] === 'precondition-not-met'
+                ) {
+                    // Manually configure the node if we can't set it via publish-options
+                    await api.pubsub.config.set(entity_jid, node, options);
+                    try {
+                        await api.sendIQ(stanza);
+                    } catch (e) {
+                        log.error(e);
+                        if (!strict_options) {
+                            // The publish-options precondition couldn't be met.
+                            // We re-publish but without publish-options.
+                            const el = stanza.tree();
+                            el.querySelector('publish-options').outerHTML = '';
+                            log.warn(`api.pubsub.publish: #publish-options precondition-not-met, publishing anyway.`);
+                            await api.sendIQ(el);
+                        }
+                    }
+                } else {
+                    throw iq;
+                }
+            }
+        },
+    },
+};

+ 40 - 0
src/headless/plugins/pubsub/index.js

@@ -0,0 +1,40 @@
+/**
+ * @module converse-pubsub
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import _converse from '../../shared/_converse.js';
+import converse from '../../shared/api/public.js';
+import pubsub_api from './api.js';
+import '../disco/index.js';
+
+const { Strophe, sizzle } = converse.env;
+
+Strophe.addNamespace('PUBSUB_ERROR', Strophe.NS.PUBSUB + '#errors');
+
+converse.plugins.add('converse-pubsub', {
+    dependencies: ['converse-disco'],
+
+    initialize() {
+        const { api } = _converse;
+        Object.assign(_converse.api, pubsub_api);
+
+        api.listen.on(
+            'parseErrorStanza',
+            /**
+             * @param {Element} stanza
+             * @param {import('shared/types.js').ErrorExtra} extra
+             */
+            (stanza, extra) => {
+                const pubsub_err = sizzle(`error [xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, stanza).pop();
+                if (pubsub_err) {
+                    return {
+                        ...extra,
+                        [Strophe.NS.PUBSUB_ERROR]: pubsub_err.nodeName,
+                    };
+                }
+                return extra;
+            }
+        );
+    },
+});

+ 16 - 0
src/headless/plugins/pubsub/parsers.js

@@ -0,0 +1,16 @@
+import { parseXForm } from '../../shared/parsers.js';
+
+/**
+ * @param {Element} iq - An IQ result stanza
+ * @returns {import('./types').PubSubConfigOptions}
+ */
+export function parseStanzaForPubSubConfig(iq) {
+    return parseXForm(iq).fields.reduce((acc, f) => {
+        if (f.var.startsWith('pubsub#')) {
+            const key = f.var.replace(/^pubsub#/, '');
+            const value = (f.type === 'boolean') ? f.checked : (f.value ?? null);
+            acc[key] = value;
+        }
+        return acc;
+    }, {});
+}

+ 622 - 0
src/headless/plugins/pubsub/tests/config.js

@@ -0,0 +1,622 @@
+/* global mock, converse */
+const { Strophe, sizzle, stx, u, errors } = converse.env;
+
+describe('The pubsub API', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    describe('fetching a nodes config settings', function () {
+        it(
+            "can be used to fetch a nodes's configuration settings",
+            mock.initConverse([], {}, async function (_converse) {
+                await mock.waitForRoster(_converse, 'current', 0);
+                const { api } = _converse;
+                const sent_stanzas = api.connection.get().sent_stanzas;
+                const own_jid = _converse.session.get('jid');
+
+                const node = 'princely_musings';
+                const pubsub_jid = 'pubsub.shakespeare.lit';
+                const promise = api.pubsub.config.get(pubsub_jid, node);
+                const sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+
+                const response = stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+                        <configure node='${node}'>
+                        <x xmlns='jabber:x:data' type='form'>
+                            <field var='FORM_TYPE' type='hidden'>
+                            <value>http://jabber.org/protocol/pubsub#node_config</value>
+                            </field>
+                            <field var='pubsub#title' type='text-single'
+                                label='A friendly name for the node'/>
+                            <field var='pubsub#deliver_notifications' type='boolean'
+                                label='Whether to deliver event notifications'>
+                            <value>true</value>
+                            </field>
+                            <field var='pubsub#deliver_payloads' type='boolean'
+                                label='Whether to deliver payloads with event notifications'>
+                            <value>true</value>
+                            </field>
+                            <field var='pubsub#notify_config' type='boolean'
+                                label='Notify subscribers when the node configuration changes'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#notify_delete' type='boolean'
+                                label='Notify subscribers when the node is deleted'>
+                            <value>false</value>
+                            </field>
+                            <field var='pubsub#notify_retract' type='boolean'
+                                label='Notify subscribers when items are removed from the node'>
+                            <value>false</value>
+                            </field>
+                            <field var='pubsub#notify_sub' type='boolean'
+                                label='Notify owners about new subscribers and unsubscribes'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#persist_items' type='boolean'
+                                label='Persist items to storage'>
+                            <value>1</value>
+                            </field>
+                            <field var='pubsub#max_items' type='text-single'
+                                label='Max # of items to persist. \`max\` for no specific limit other than a server imposed maximum.'>
+                            <value>10</value>
+                            </field>
+                            <field var='pubsub#item_expire' type='text-single'
+                                label='Time after which to automatically purge items. \`max\` for no specific limit other than a server imposed maximum.'>
+                            <value>604800</value>
+                            </field>
+                            <field var='pubsub#subscribe' type='boolean'
+                                label='Whether to allow subscriptions'>
+                            <value>1</value>
+                            </field>
+                            <field var='pubsub#access_model' type='list-single'
+                                label='Specify the subscriber model'>
+                            <option><value>authorize</value></option>
+                            <option><value>open</value></option>
+                            <option><value>presence</value></option>
+                            <option><value>roster</value></option>
+                            <option><value>whitelist</value></option>
+                            <value>open</value>
+                            </field>
+                            <field var='pubsub#roster_groups_allowed' type='list-multi'
+                                label='Roster groups allowed to subscribe'>
+                            <option><value>friends</value></option>
+                            <option><value>courtiers</value></option>
+                            <option><value>servants</value></option>
+                            <option><value>enemies</value></option>
+                            </field>
+                            <field var='pubsub#publish_model' type='list-single'
+                                label='Specify the publisher model'>
+                            <option><value>publishers</value></option>
+                            <option><value>subscribers</value></option>
+                            <option><value>open</value></option>
+                            <value>publishers</value>
+                            </field>
+                            <field var='pubsub#purge_offline' type='boolean'
+                                label='Purge all items when the relevant publisher goes offline?'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#max_payload_size' type='text-single'
+                                label='Max Payload size in bytes'>
+                            <value>1028</value>
+                            </field>
+                            <field var='pubsub#send_last_published_item' type='list-single'
+                                label='When to send the last published item'>
+                            <option label='Never'><value>never</value></option>
+                            <option label='When a new subscription is processed'><value>on_sub</value></option>
+                            <option label='When a new subscription is processed and whenever a subscriber comes online'>
+                                <value>on_sub_and_presence</value>
+                            </option>
+                            <value>never</value>
+                            </field>
+                            <field var='pubsub#presence_based_delivery' type='boolean'
+                                label='Deliver event notifications only to available users'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#notification_type' type='list-single'
+                                label='Specify the delivery style for event notifications'>
+                            <option><value>normal</value></option>
+                            <option><value>headline</value></option>
+                            <value>headline</value>
+                            </field>
+                            <field var='pubsub#type' type='text-single'
+                                label='Specify the semantic type of payload data to be provided at this node.'>
+                            <value>urn:example:e2ee:bundle</value>
+                            </field>
+                            <field var='pubsub#dataform_xslt' type='text-single' label='Payload XSLT'/>
+                        </x>
+                        </configure>
+                    </pubsub>
+                </iq>`;
+
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+
+                const result = await promise;
+                expect(result).toEqual({
+                    access_model: null,
+                    dataform_xslt: null,
+                    deliver_notifications: true,
+                    deliver_payloads: true,
+                    item_expire: '604800',
+                    max_items: '10',
+                    max_payload_size: '1028',
+                    notification_type: null,
+                    notify_config: false,
+                    notify_delete: false,
+                    notify_retract: false,
+                    notify_sub: false,
+                    persist_items: true,
+                    presence_based_delivery: false,
+                    publish_model: null,
+                    purge_offline: false,
+                    roster_groups_allowed: null,
+                    send_last_published_item: null,
+                    subscribe: true,
+                    title: null,
+                    type: 'urn:example:e2ee:bundle',
+                });
+            })
+        );
+
+        it(
+            'handles error cases',
+            mock.initConverse([], {}, async function (_converse) {
+                await mock.waitForRoster(_converse, 'current', 0);
+                const { api } = _converse;
+                const sent_stanzas = api.connection.get().sent_stanzas;
+                const own_jid = _converse.session.get('jid');
+
+                const node = 'princely_musings';
+                const pubsub_jid = 'pubsub.shakespeare.lit';
+
+                let promise = api.pubsub.config.get(pubsub_jid, node);
+                let sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+                let response = stx`<iq type='error'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <error type='cancel'>
+                        <feature-not-implemented xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                        <unsupported xmlns='http://jabber.org/protocol/pubsub#errors' feature='config-node'/>
+                    </error>
+                </iq>`;
+
+                let first_error_thrown = false;
+                promise
+                    .catch((e) => {
+                        expect(e instanceof errors.FeatureNotImplementedError).toBe(true);
+                        first_error_thrown = true;
+                    })
+                    .finally(() => {
+                        expect(first_error_thrown).toBe(true);
+                    });
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+
+                promise = api.pubsub.config.get(pubsub_jid, node);
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+                response = stx`<iq type='error'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <error type='auth'><forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error>
+                </iq>`;
+
+                let second_error_thrown = false;
+                promise
+                    .catch((e) => {
+                        expect(e instanceof errors.ForbiddenError).toBe(true);
+                        second_error_thrown = true;
+                    })
+                    .finally(() => {
+                        expect(second_error_thrown).toBe(true);
+                    });
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+
+                promise = api.pubsub.config.get(pubsub_jid, node);
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+                response = stx`<iq type='error'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <error type='cancel'><item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error>
+                </iq>`;
+
+                let third_error_thrown = false;
+                promise
+                    .catch((e) => {
+                        expect(e instanceof errors.ItemNotFoundError).toBe(true);
+                        third_error_thrown = true;
+                    })
+                    .finally(() => {
+                        expect(third_error_thrown).toBe(true);
+                    });
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+            })
+        );
+    });
+
+    describe('setting a nodes config settings', function () {
+        it(
+            'first fetches the config, and then changes the specified values',
+            mock.initConverse([], {}, async function (_converse) {
+                await mock.waitForRoster(_converse, 'current', 0);
+                const { api } = _converse;
+                const sent_stanzas = api.connection.get().sent_stanzas;
+                const own_jid = _converse.session.get('jid');
+
+                const node = 'princely_musings';
+                const pubsub_jid = 'pubsub.shakespeare.lit';
+                const promise = api.pubsub.config.set(pubsub_jid, node, { access_model: 'whitelist' });
+
+                let sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+                        <configure node='${node}'>
+                        <x xmlns='jabber:x:data' type='form'>
+                            <field var='FORM_TYPE' type='hidden'>
+                            <value>http://jabber.org/protocol/pubsub#node_config</value>
+                            </field>
+                            <field var='pubsub#title' type='text-single'
+                                label='A friendly name for the node'/>
+                            <field var='pubsub#deliver_notifications' type='boolean'
+                                label='Whether to deliver event notifications'>
+                            <value>true</value>
+                            </field>
+                            <field var='pubsub#deliver_payloads' type='boolean'
+                                label='Whether to deliver payloads with event notifications'>
+                            <value>true</value>
+                            </field>
+                            <field var='pubsub#notify_config' type='boolean'
+                                label='Notify subscribers when the node configuration changes'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#notify_delete' type='boolean'
+                                label='Notify subscribers when the node is deleted'>
+                            <value>false</value>
+                            </field>
+                            <field var='pubsub#notify_retract' type='boolean'
+                                label='Notify subscribers when items are removed from the node'>
+                            <value>false</value>
+                            </field>
+                            <field var='pubsub#notify_sub' type='boolean'
+                                label='Notify owners about new subscribers and unsubscribes'>
+                            <value>0</value>
+                            </field>
+                            <field var='pubsub#persist_items' type='boolean'
+                                label='Persist items to storage'>
+                            <value>1</value>
+                            </field>
+                            <field var='pubsub#max_items' type='text-single'
+                                label='Max # of items to persist. \`max\` for no specific limit other than a server imposed maximum.'>
+                            <value>10</value>
+                            </field>
+                            <field var='pubsub#item_expire' type='text-single'
+                                label='Time after which to automatically purge items. \`max\` for no specific limit other than a server imposed maximum.'>
+                            <value>604800</value>
+                            </field>
+                            <field var='pubsub#subscribe' type='boolean'
+                                label='Whether to allow subscriptions'>
+                            <value>1</value>
+                            </field>
+                            <field var='pubsub#publish_model' type='list-single'
+                                label='Specify the publisher model'>
+                            <option><value>publishers</value></option>
+                            <option><value>subscribers</value></option>
+                            <option><value>open</value></option>
+                            <value>publishers</value>
+                            </field>
+                            <field var='pubsub#purge_offline' type='boolean'
+                                label='Purge all items when the relevant publisher goes offline?'>
+                            <value>0</value>
+                            </field>
+                        </x>
+                        </configure>
+                    </pubsub>
+                </iq>`)
+                );
+
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas
+                        .filter((iq) => iq.getAttribute('type') === 'set' && sizzle('pubsub configure', iq))
+                        .pop()
+                );
+                expect(sent_stanza).toEqualStanza(stx`<iq xmlns="jabber:client"
+                    from="${_converse.bare_jid}"
+                    to="${pubsub_jid}"
+                    type="set"
+                    id="${sent_stanza.getAttribute('id')}">
+                        <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+                            <configure node="princely_musings">
+                            <x xmlns="jabber:x:data" type="submit">
+                                <field var="FORM_TYPE" type="hidden"><value>http://jabber.org/protocol/pubsub#nodeconfig</value></field>
+                                <field var="title"><value/></field>
+                                <field var="deliver_notifications"><value>true</value></field>
+                                <field var="deliver_payloads"><value>true</value></field>
+                                <field var="notify_config"><value>false</value></field>
+                                <field var="notify_delete"><value>false</value></field>
+                                <field var="notify_retract"><value>false</value></field>
+                                <field var="notify_sub"><value>false</value></field>
+                                <field var="persist_items"><value>true</value></field>
+                                <field var="max_items"><value>10</value></field>
+                                <field var="item_expire"><value>604800</value></field>
+                                <field var="subscribe"><value>true</value></field>
+                                <field var="publish_model"><value/></field>
+                                <field var="purge_offline"><value>false</value></field>
+                                <field var="access_model"><value>whitelist</value></field>
+                            </x>
+                            </configure>
+                        </pubsub>
+                    </iq>`);
+
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}"></iq>`)
+                );
+
+                const result = await promise;
+                expect(result).toEqual({
+                    access_model: 'whitelist',
+                    deliver_notifications: true,
+                    deliver_payloads: true,
+                    item_expire: '604800',
+                    max_items: '10',
+                    notify_config: false,
+                    notify_delete: false,
+                    notify_retract: false,
+                    notify_sub: false,
+                    persist_items: true,
+                    publish_model: null,
+                    purge_offline: false,
+                    subscribe: true,
+                    title: null,
+                });
+            })
+        );
+
+        it(
+            'handles error cases',
+            mock.initConverse([], {}, async function (_converse) {
+                await mock.waitForRoster(_converse, 'current', 0);
+                const { api } = _converse;
+                const sent_stanzas = api.connection.get().sent_stanzas;
+                const own_jid = _converse.session.get('jid');
+
+                const node = 'princely_musings';
+                const pubsub_jid = 'pubsub.shakespeare.lit';
+
+                const promise = api.pubsub.config.set(pubsub_jid, node, { access_model: 'whitelist' });
+                let sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => sizzle('pubsub configure', iq)).pop()
+                );
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+                        <configure node='${node}'>
+                        <x xmlns='jabber:x:data' type='form'>
+                            <field var='FORM_TYPE' type='hidden'>
+                            <value>http://jabber.org/protocol/pubsub#node_config</value>
+                            </field>
+                            <field var='pubsub#title' type='text-single'
+                                label='A friendly name for the node'/>
+                            <field var='pubsub#deliver_notifications' type='boolean'
+                                label='Whether to deliver event notifications'>
+                            <value>true</value>
+                            </field>
+                        </x>
+                        </configure>
+                    </pubsub>
+                </iq>`)
+                );
+
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas
+                        .filter((iq) => iq.getAttribute('type') === 'set' && sizzle('pubsub configure', iq))
+                        .pop()
+                );
+
+                const response = stx`
+                    <iq type='error'
+                            xmlns="jabber:client"
+                            from='${pubsub_jid}'
+                            to='${own_jid}'
+                            id="${sent_stanza.getAttribute('id')}">
+                        <error type='modify'><not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></error>
+                    </iq>`;
+
+                let first_error_thrown = false;
+                promise
+                    .catch((e) => {
+                        expect(e instanceof errors.NotAcceptableError).toBe(true);
+                        first_error_thrown = true;
+                    })
+                    .finally(() => {
+                        expect(first_error_thrown).toBe(true);
+                    });
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+            })
+        );
+    });
+
+    describe('publishing to a node', function () {
+        it(
+            "will try to manually configure the node if publish-options aren't supported",
+            mock.initConverse([], {}, async function (_converse) {
+                await mock.waitForRoster(_converse, 'current', 0);
+
+                const pubsub_jid = 'pubsub.shakespeare.lit';
+
+                const { api } = _converse;
+                const sent_stanzas = api.connection.get().sent_stanzas;
+                const own_jid = _converse.session.get('jid');
+
+                const node = 'princely_musings';
+                const promise = api.pubsub.publish(pubsub_jid, node, stx`<item></item>`, { access_model: 'whitelist' });
+
+                await mock.waitUntilDiscoConfirmed(
+                    _converse,
+                    pubsub_jid,
+                    [{ 'category': 'pubsub', 'type': 'pep' }],
+                    ['http://jabber.org/protocol/pubsub#publish-options']
+                );
+
+                let sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => iq.querySelector('pubsub publish')).pop()
+                );
+                expect(sent_stanza).toEqualStanza(stx`
+                    <iq type="set"
+                            from="${_converse.bare_jid}"
+                            to="${pubsub_jid}"
+                            xmlns="jabber:client"
+                            id="${sent_stanza.getAttribute('id')}">
+                        <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                            <publish node="princely_musings"><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#access_model"><value>whitelist</value></field>
+                                </x>
+                            </publish-options>
+                        </pubsub>
+                    </iq>`);
+
+                let response = stx`<iq type='error'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <error type='modify'>
+                        <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                        <precondition-not-met xmlns='http://jabber.org/protocol/pubsub#errors'/>
+                    </error>
+                </iq>`;
+                _converse.api.connection.get()._dataRecv(mock.createRequest(response));
+
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => iq.querySelector('pubsub configure')).pop()
+                );
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}">
+                    <pubsub xmlns='http://jabber.org/protocol/pubsub#owner'>
+                        <configure node='${node}'>
+                        <x xmlns='jabber:x:data' type='form'>
+                            <field var='pubsub#access_model' type='list-single' label='Specify the subscriber model'>
+                                <option><value>authorize</value></option>
+                                <option><value>open</value></option>
+                                <option><value>presence</value></option>
+                                <option><value>roster</value></option>
+                                <option><value>whitelist</value></option>
+                                <value>open</value>
+                            </field>
+                        </x>
+                        </configure>
+                    </pubsub>
+                </iq>`)
+                );
+
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas
+                        .filter((iq) => iq.getAttribute('type') === 'set' && iq.querySelector('pubsub configure'))
+                        .pop()
+                );
+
+                expect(sent_stanza).toEqualStanza(stx`<iq xmlns="jabber:client"
+                    from="${_converse.bare_jid}"
+                    to="${pubsub_jid}"
+                    type="set"
+                    id="${sent_stanza.getAttribute('id')}">
+                        <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+                            <configure node="princely_musings">
+                            <x xmlns="jabber:x:data" type="submit">
+                                <field var="FORM_TYPE" type="hidden"><value>http://jabber.org/protocol/pubsub#nodeconfig</value></field>
+                                <field var="access_model"><value>whitelist</value></field>
+                            </x>
+                            </configure>
+                        </pubsub>
+                    </iq>`);
+
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}"></iq>`)
+                );
+
+                // Clear old stanzas
+                while (sent_stanzas.length) sent_stanzas.pop();
+
+                sent_stanza = await u.waitUntil(() =>
+                    sent_stanzas.filter((iq) => iq.querySelector('pubsub publish')).pop()
+                );
+                expect(sent_stanza).toEqualStanza(stx`
+                    <iq type="set"
+                            from="${_converse.bare_jid}"
+                            to="${pubsub_jid}"
+                            xmlns="jabber:client"
+                            id="${sent_stanza.getAttribute('id')}">
+                        <pubsub xmlns="http://jabber.org/protocol/pubsub">
+                            <publish node="princely_musings"><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#access_model"><value>whitelist</value></field>
+                                </x>
+                            </publish-options>
+                        </pubsub>
+                    </iq>`);
+
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                    <iq type='result'
+                        xmlns="jabber:client"
+                        from='${pubsub_jid}'
+                        to='${own_jid}'
+                        id="${sent_stanza.getAttribute('id')}"></iq>`)
+                );
+
+                await promise;
+            })
+        );
+    });
+});

+ 42 - 0
src/headless/plugins/pubsub/types.ts

@@ -0,0 +1,42 @@
+export type PubSubConfigOptions = {
+    access_model?: 'authorize' | 'open' | 'presence' | 'roster' | 'whitelist';
+    // Payload XSLT
+    dataform_xslt?: string;
+    deliver_notifications?: boolean;
+    // Whether to deliver payloads with event notifications
+    deliver_payloads?: boolean;
+    // Time after which to automatically purge items. `max` for no specific limit other than a server imposed maximum.
+    item_expire?: string;
+    // Max # of items to persist. `max` for no specific limit other than a server imposed maximum.
+    max_items?: string;
+    // Max Payload size in bytes
+    max_payload_size?: string;
+    notification_type?: 'normal' | 'headline';
+    // Notify subscribers when the node configuration changes
+    notify_config?: boolean;
+    // Notify subscribers when the node is deleted
+    notify_delete?: boolean;
+    // Notify subscribers when items are removed from the node
+    notify_retract?: boolean;
+    // <field var='notify_sub' type='boolean'
+    notify_sub?: boolean;
+    // <field var='persist_items' type='boolean'
+    persist_items?: boolean;
+    // Deliver event notifications only to available users
+    presence_based_delivery?: boolean;
+    publish_model?: 'publishers' | 'subscribers' | 'open';
+    // Purge all items when the relevant publisher goes offline?
+    purge_offline?: boolean;
+    roster_groups_allowed?: string[];
+    // When to send the last published item
+    // - Never
+    // - When a new subscription is processed
+    // - When a new subscription is processed and whenever a subscriber comes online
+    send_last_published_item?: 'never' | 'on_sub' | 'on_sub_and_presence';
+    // Whether to allow subscriptions
+    subscribe?: boolean;
+    // A friendly name for the node'/>
+    title?: string;
+    // Specify the semantic type of payload data to be provided at this node.
+    type?: string;
+};

+ 12 - 0
src/headless/plugins/roster/api.js

@@ -56,6 +56,18 @@ export default {
             return /** @type {string[]} */(jids).map(_getter);
         },
 
+        /**
+         * Remove a contact from the roster
+         * @param {string} jid
+         * @param {boolean} [unsubscribe] - Whether we should unsubscribe
+         * from the contact's presence updates.
+         */
+        async remove (jid, unsubscribe) {
+            await api.waitUntil('rosterContactsFetched');
+            const contact = await api.contacts.get(jid);
+            contact.remove(unsubscribe);
+        },
+
         /**
          * Add a contact.
          * @param {import('./types').RosterContactAttributes} attributes

+ 29 - 20
src/headless/plugins/roster/contact.js

@@ -59,25 +59,12 @@ class RosterContact extends ColorAwareModel(Model) {
         this.presence = presences.findWhere(jid) || presences.create({ jid });
     }
 
-    openChat () {
-        api.chats.open(this.get('jid'), this.attributes, true);
+    getStatus () {
+        return this.presence.get('show') || 'offline';
     }
 
-    /**
-     * Return a string of tab-separated values that are to be used when
-     * matching against filter text.
-     *
-     * The goal is to be able to filter against the VCard fullname,
-     * roster nickname and JID.
-     * @returns {string} Lower-cased, tab-separated values
-     */
-    getFilterCriteria () {
-        const nick = this.get('nickname');
-        const jid = this.get('jid');
-        let criteria = this.getDisplayName();
-        criteria = !criteria.includes(jid) ? criteria.concat(`   ${jid}`) : criteria;
-        criteria = !criteria.includes(nick) ? criteria.concat(`   ${nick}`) : criteria;
-        return criteria.toLowerCase();
+    openChat () {
+        api.chats.open(this.get('jid'), {}, true);
     }
 
     getDisplayName () {
@@ -128,13 +115,13 @@ class RosterContact extends ColorAwareModel(Model) {
      */
     ackUnsubscribe () {
         api.send($pres({'type': 'unsubscribe', 'to': this.get('jid')}));
-        this.removeFromRoster();
+        this.sendRosterRemoveStanza();
         this.destroy();
     }
 
     /**
      * Unauthorize this contact's presence subscription
-     * @param {string} message - Optional message to send to the person being unauthorized
+     * @param {string} [message] - Optional message to send to the person being unauthorized
      */
     unauthorize (message) {
         rejectPresenceSubscription(this.get('jid'), message);
@@ -154,11 +141,33 @@ class RosterContact extends ColorAwareModel(Model) {
         return this;
     }
 
+    /**
+     * Remove this contact from the roster
+     * @async
+     * @param {boolean} [unauthorize] - Whether to also unauthorize the
+     * @returns {Promise<Error|Element>}
+     */
+    remove (unauthorize) {
+        const subscription = this.get('subscription');
+        if (subscription === 'none' && this.get('ask') !== 'subscribe') {
+            this.destroy();
+            return;
+        }
+        if (unauthorize && ['from', 'both'].includes(subscription)) {
+            this.unauthorize();
+        }
+        const promise = this.sendRosterRemoveStanza();
+        if (this.collection) this.destroy();
+
+        return promise;
+    }
+
     /**
      * Instruct the XMPP server to remove this contact from our roster
+     * @async
      * @returns {Promise}
      */
-    removeFromRoster () {
+    sendRosterRemoveStanza () {
         const iq = $iq({type: 'set'})
             .c('query', {xmlns: Strophe.NS.ROSTER})
             .c('item', {jid: this.get('jid'), subscription: "remove"});

+ 29 - 12
src/headless/plugins/roster/contacts.js

@@ -22,6 +22,20 @@ class RosterContacts extends Collection {
         this.state = new Model({ id, 'collapsed_groups': [] });
         initStorage(this.state, id);
         this.state.fetch();
+
+        api.listen.on('chatBoxClosed',
+            /** @param {import('../../shared/chatbox').default} model */
+            (model) => this.onChatBoxClosed(model));
+    }
+
+    /**
+     * @param {import('../../shared/chatbox').default} model
+     */
+    onChatBoxClosed(model) {
+        const contact = this.get(model.get('jid'));
+        if (contact?.get('subscription') === 'none') {
+            contact.destroy();
+        }
     }
 
     onConnected () {
@@ -110,10 +124,11 @@ class RosterContacts extends Collection {
         const { xmppstatus } = _converse.state;
         Array.from(msg.querySelectorAll('item')).forEach((item) => {
             if (item.getAttribute('action') === 'add') {
-                _converse.state.roster.addContact(
+                this.addContact(
                     {
                         jid: item.getAttribute('jid'),
                         name: xmppstatus.getNickname() || xmppstatus.getFullname(),
+                        subscription: 'to',
                     },
                 );
             }
@@ -130,7 +145,7 @@ class RosterContacts extends Collection {
 
     /**
      * Send an IQ stanza to the XMPP server to add a new roster contact.
-     * @param {import('./types.ts').RosterContactAttributes} attributes
+     * @param {import('./types').RosterContactAttributes} attributes
      */
     sendContactAddIQ (attributes) {
         const { jid, groups } = attributes;
@@ -144,7 +159,7 @@ class RosterContacts extends Collection {
      * Adds a {@link RosterContact} instance to {@link RosterContacts} and
      * optionally (if subscribe=true) subscribe to the contact's presence
      * updates which also adds the contact to the roster on the XMPP server.
-     * @param {import('./types.ts').RosterContactAttributes} attributes
+     * @param {import('./types').RosterContactAttributes} attributes
      * @param {boolean} [persist=true] - Whether the contact should be persisted to the user's roster.
      * @param {boolean} [subscribe=true] - Whether we should subscribe to the contacts presence updates.
      * @param {string} [message=''] - An optional message to include with the presence subscription
@@ -173,7 +188,7 @@ class RosterContacts extends Collection {
                     nickname: name,
                     groups: [],
                     requesting: false,
-                    subscription: 'none',
+                    subscription: subscribe ? 'to' : 'none',
                 },
                 ...attributes,
             },
@@ -265,14 +280,16 @@ class RosterContacts extends Collection {
     /**
      * Fetch the roster from the XMPP server
      * @emits _converse#roster
+     * @param {boolean} [full=false] - Whether to fetch the full roster or just the changes.
      * @returns {promise}
      */
-    async fetchFromServer () {
+    async fetchFromServer (full=false) {
         const stanza = $iq({
             'type': 'get',
             'id': u.getUniqueId('roster'),
         }).c('query', { xmlns: Strophe.NS.ROSTER });
-        if (this.rosterVersioningSupported()) {
+
+        if (this.rosterVersioningSupported() && !full) {
             stanza.attrs({ 'ver': this.data.get('version') });
         }
 
@@ -342,14 +359,14 @@ class RosterContacts extends Collection {
      * @param {Element} presence
      */
     createRequestingContact (presence) {
-        const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from'));
+        const jid = Strophe.getBareJidFromJid(presence.getAttribute('from'));
         const nickname = sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop()?.textContent || null;
         const user_data = {
-            'jid': bare_jid,
-            'subscription': 'none',
-            'ask': null,
-            'requesting': true,
-            'nickname': nickname,
+            jid,
+            subscription: 'none',
+            ask: null,
+            requesting: true,
+            nickname: nickname,
         };
         /**
          * Triggered when someone has requested to subscribe to your presence (i.e. to be your contact).

+ 4 - 3
src/headless/plugins/roster/plugin.js

@@ -26,9 +26,10 @@ converse.plugins.add('converse-roster', {
 
     initialize () {
         api.settings.extend({
-            'allow_contact_requests': true,
-            'auto_subscribe': false,
-            'synchronize_availability': true
+            show_self_in_roster: true,
+            allow_contact_requests: true,
+            auto_subscribe: false,
+            synchronize_availability: true
         });
 
         api.promises.add(['cachedRoster', 'roster', 'rosterContactsFetched', 'rosterInitialized']);

+ 5 - 5
src/headless/plugins/roster/utils.js

@@ -45,7 +45,7 @@ function initRoster () {
      * @example _converse.api.listen.on('rosterInitialized', () => { ... });
      * @example _converse.api.waitUntil('rosterInitialized').then(() => { ... });
      */
-    api.trigger('rosterInitialized');
+    api.trigger('rosterInitialized', roster);
 }
 
 
@@ -64,7 +64,7 @@ async function populateRoster (ignore_cache=false) {
     const roster = /** @type {RosterContacts} */(_converse.state.roster);
     try {
         await roster.fetchRosterContacts();
-        api.trigger('rosterContactsFetched');
+        api.trigger('rosterContactsFetched', roster);
     } catch (reason) {
         log.error(reason);
     } finally {
@@ -123,7 +123,7 @@ export async function onClearSession () {
 
 /**
  * Roster specific event handler for the presencesInitialized event
- * @param { Boolean } reconnecting
+ * @param {Boolean} reconnecting
  */
 export function onPresencesInitialized (reconnecting) {
     if (reconnecting) {
@@ -212,8 +212,8 @@ export function onRosterContactsFetched () {
 /**
  * Reject or cancel another user's subscription to our presence updates.
  * @function rejectPresenceSubscription
- * @param { String } jid - The Jabber ID of the user whose subscription is being canceled
- * @param { String } message - An optional message to the user
+ * @param {String} jid - The Jabber ID of the user whose subscription is being canceled
+ * @param {String} message - An optional message to the user
  */
 export function rejectPresenceSubscription (jid, message) {
     const pres = $pres({to: jid, type: "unsubscribed"});

+ 5 - 4
src/headless/plugins/smacks/tests/smacks.js

@@ -11,10 +11,11 @@ describe("XEP-0198 Stream Management", function () {
     it("gets enabled with an <enable> stanza and resumed with a <resume> stanza",
         mock.initConverse(
             ['chatBoxesInitialized'],
-            { 'auto_login': false,
-              'enable_smacks': true,
-              'show_controlbox_by_default': true,
-              'smacks_max_unacked_stanzas': 2
+            {   auto_login: false,
+                enable_smacks: true,
+                show_controlbox_by_default: true,
+                smacks_max_unacked_stanzas: 2,
+                blacklisted_plugins: ['converse-blocklist']
             },
             async function (_converse) {
 

+ 6 - 7
src/headless/plugins/smacks/utils.js

@@ -5,8 +5,7 @@ import log from '../../log.js';
 import { getOpenPromise } from '@converse/openpromise';
 import { isTestEnv } from '../../utils/session.js';
 
-const { Strophe } = converse.env;
-const u = converse.env.utils;
+const { Strophe, u, stx } = converse.env;
 
 function isStreamManagementSupported () {
     if (api.connection.isType('bosh') && !isTestEnv()) {
@@ -51,7 +50,7 @@ function handleAck (el) {
 function sendAck () {
     if (_converse.session.get('smacks_enabled')) {
         const h = _converse.session.get('num_stanzas_handled');
-        const stanza = u.toStanza(`<a xmlns="${Strophe.NS.SM}" h="${h}"/>`);
+        const stanza = stx`<a xmlns="${Strophe.NS.SM}" h="${h}"/>`;
         api.send(stanza);
     }
     return true;
@@ -155,7 +154,7 @@ function resendUnackedStanzas () {
     // service worker or handling IQ[type="result"] stanzas
     // differently, more like push stanzas, so that they don't need
     // explicit handlers.
-    stanzas.forEach(s => api.send(s));
+    stanzas.forEach((s) => api.send(u.toStanza(s)));
 }
 
 /**
@@ -180,7 +179,7 @@ async function sendResumeStanza () {
 
     const previous_id = _converse.session.get('smacks_stream_id');
     const h = _converse.session.get('num_stanzas_handled');
-    const stanza = u.toStanza(`<resume xmlns="${Strophe.NS.SM}" h="${h}" previd="${previous_id}"/>`);
+    const stanza = stx`<resume xmlns="${Strophe.NS.SM}" h="${h}" previd="${previous_id}"/>`;
     api.send(stanza);
     connection.flush();
     await promise;
@@ -197,7 +196,7 @@ export async function sendEnableStanza () {
         connection._addSysHandler(el => promise.resolve(onFailedStanza(el)), Strophe.NS.SM, 'failed');
 
         const resume = api.connection.isType('websocket') || isTestEnv();
-        const stanza = u.toStanza(`<enable xmlns="${Strophe.NS.SM}" resume="${resume}"/>`);
+        const stanza = stx`<enable xmlns="${Strophe.NS.SM}" resume="${resume}"/>`;
         api.send(stanza);
         connection.flush();
         await promise;
@@ -246,7 +245,7 @@ export function onStanzaSent (stanza) {
             const num = _converse.session.get('num_stanzas_since_last_ack') + 1;
             if (num % max_unacked === 0) {
                 // Request confirmation of sent stanzas
-                api.send(u.toStanza(`<r xmlns="${Strophe.NS.SM}"/>`));
+                api.send(stx`<r xmlns="${Strophe.NS.SM}"/>`);
             }
             _converse.session.save({ 'num_stanzas_since_last_ack': num });
         }

+ 34 - 30
src/headless/plugins/status/status.js

@@ -8,14 +8,17 @@ import { isIdle, getIdleSeconds } from './utils.js';
 const { Strophe, $pres } = converse.env;
 
 export default class XMPPStatus extends ColorAwareModel(Model) {
-
-  constructor(attributes, options) {
+    constructor(attributes, options) {
         super(attributes, options);
         this.vcard = null;
     }
 
-    defaults () {
-        return { "status":  api.settings.get("default_state") }
+    defaults() {
+        return { 'status': api.settings.get('default_state') };
+    }
+
+    getStatus() {
+        return this.get('status');
     }
 
     /**
@@ -30,20 +33,20 @@ export default class XMPPStatus extends ColorAwareModel(Model) {
         return super.get(attr);
     }
 
-  /**
-   * @param {string|Object} key
-   * @param {string|Object} [val]
-   * @param {Object} [options]
-   */
+    /**
+     * @param {string|Object} key
+     * @param {string|Object} [val]
+     * @param {Object} [options]
+     */
     set(key, val, options) {
         if (key === 'jid' || key === 'nickname') {
-            throw new Error('Readonly property')
+            throw new Error('Readonly property');
         }
         return super.set(key, val, options);
     }
 
-    initialize () {
-        this.on('change', item => {
+    initialize() {
+        this.on('change', (item) => {
             if (!(item.changed instanceof Object)) {
                 return;
             }
@@ -53,15 +56,15 @@ export default class XMPPStatus extends ColorAwareModel(Model) {
         });
     }
 
-    getDisplayName () {
+    getDisplayName() {
         return this.getFullname() || this.getNickname() || this.get('jid');
     }
 
-    getNickname () {
+    getNickname() {
         return api.settings.get('nickname');
     }
 
-    getFullname () {
+    getFullname() {
         return ''; // Gets overridden in converse-vcard
     }
 
@@ -70,8 +73,8 @@ export default class XMPPStatus extends ColorAwareModel(Model) {
      * @param {string} [to] - The JID to which this presence should be sent
      * @param {string} [status_message]
      */
-    async constructPresence (type, to=null, status_message) {
-        type = typeof type === 'string' ? type : (this.get('status') || api.settings.get("default_state"));
+    async constructPresence(type, to = null, status_message) {
+        type = typeof type === 'string' ? type : this.get('status') || api.settings.get('default_state');
         status_message = typeof status_message === 'string' ? status_message : this.get('status_message');
 
         let presence;
@@ -80,30 +83,31 @@ export default class XMPPStatus extends ColorAwareModel(Model) {
             presence = $pres({ to, type });
             const { xmppstatus } = _converse.state;
             const nick = xmppstatus.getNickname();
-            if (nick) presence.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up();
-
-        } else if ((type === 'unavailable') ||
-                (type === 'probe') ||
-                (type === 'error') ||
-                (type === 'unsubscribe') ||
-                (type === 'unsubscribed') ||
-                (type === 'subscribed')) {
+            if (nick) presence.c('nick', { 'xmlns': Strophe.NS.NICK }).t(nick).up();
+        } else if (
+            type === 'unavailable' ||
+            type === 'probe' ||
+            type === 'error' ||
+            type === 'unsubscribe' ||
+            type === 'unsubscribed' ||
+            type === 'subscribed'
+        ) {
             presence = $pres({ to, type });
-
         } else if (type === 'offline') {
             presence = $pres({ to, type: 'unavailable' });
-
         } else if (type === 'online') {
             presence = $pres({ to });
-
         } else {
             presence = $pres({ to }).c('show').t(type).up();
         }
 
         if (status_message) presence.c('status').t(status_message).up();
 
-        const priority = api.settings.get("priority");
-        presence.c('priority').t(Number.isNaN(Number(priority)) ? 0 : priority).up();
+        const priority = api.settings.get('priority');
+        presence
+            .c('priority')
+            .t(Number.isNaN(Number(priority)) ? 0 : priority)
+            .up();
 
         if (isIdle()) {
             const idle_since = new Date();

+ 6 - 4
src/headless/plugins/vcard/plugin.js

@@ -33,9 +33,10 @@ converse.plugins.add('converse-vcard', {
         XMPPStatus: {
             getNickname () {
                 const { _converse } = this.__super__;
+                const { xmppstatus } = _converse.state;
                 const nick = this.__super__.getNickname.apply(this);
-                if (!nick && _converse.state.xmppstatus.vcard) {
-                    return _converse.state.xmppstatus.vcard.get('nickname');
+                if (!nick && xmppstatus?.vcard) {
+                    return xmppstatus.vcard.get('nickname');
                 } else {
                     return nick;
                 }
@@ -43,9 +44,10 @@ converse.plugins.add('converse-vcard', {
 
             getFullname () {
                 const { _converse } = this.__super__;
+                const { xmppstatus } = _converse.state;
                 const fullname = this.__super__.getFullname.apply(this);
-                if (!fullname && _converse.xmppstatus.vcard) {
-                    return _converse.xmppstatus.vcard.get('fullname');
+                if (!fullname && xmppstatus?.vcard) {
+                    return xmppstatus.vcard.get('fullname');
                 } else {
                     return fullname;
                 }

+ 1 - 1
src/headless/shared/_converse.js

@@ -89,7 +89,7 @@ class ConversePrivateGlobal extends EventEmitter(Object) {
         this.storage = /** @type {Record<string, Storage.LocalForage>} */{};
 
         this.promises = {
-            'initialized': getOpenPromise(),
+            initialized: getOpenPromise(),
         };
 
         this.NUM_PREKEYS = 100; // DEPRECATED. Set here so that tests can override

+ 20 - 26
src/headless/shared/actions.js

@@ -3,7 +3,7 @@ import { Strophe, $msg } from 'strophe.js';
 import api from './api/index.js';
 import converse from './api/public.js';
 
-const u = converse.env.utils;
+const { u, stx } = converse.env;
 
 /**
  * Reject an incoming message by replying with an error message of type "cancel".
@@ -19,10 +19,8 @@ export function rejectMessage(stanza, text) {
             'id': stanza.getAttribute('id'),
         })
             .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(text)
+            .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }).up()
+            .c('text', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }).t(text)
     );
     log.warn(`Rejecting message stanza with the following reason: ${text}`);
     log.warn(stanza);
@@ -58,10 +56,8 @@ export function sendReceiptStanza(to_jid, id) {
         'to': to_jid,
         'type': 'chat',
     })
-        .c('received', { 'xmlns': Strophe.NS.RECEIPTS, 'id': id })
-        .up()
-        .c('store', { 'xmlns': Strophe.NS.HINTS })
-        .up();
+        .c('received', { 'xmlns': Strophe.NS.RECEIPTS, 'id': id }).up()
+        .c('store', { 'xmlns': Strophe.NS.HINTS }).up();
     api.send(receipt_stanza);
 }
 
@@ -82,10 +78,8 @@ export function sendChatState(jid, chat_state) {
                 'to': jid,
                 'type': 'chat',
             })
-                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES })
-                .up()
-                .c('no-store', { 'xmlns': Strophe.NS.HINTS })
-                .up()
+                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES }).up()
+                .c('no-store', { 'xmlns': Strophe.NS.HINTS }).up()
                 .c('no-permanent-store', { 'xmlns': Strophe.NS.HINTS })
         );
     }
@@ -95,22 +89,22 @@ export function sendChatState(jid, chat_state) {
  * Sends a message stanza to retract a message in this chat
  * @param {string} jid
  * @param {import('../plugins/chat/message').default} message - The message which we're retracting.
+ * @param {string} retraction_id - Unique ID for the retraction message
  */
-export function sendRetractionMessage(jid, message) {
+export function sendRetractionMessage(jid, message, retraction_id) {
     const origin_id = message.get('origin_id');
     if (!origin_id) {
         throw new Error("Can't retract message without a XEP-0359 Origin ID");
     }
-    const msg = $msg({
-        'id': u.getUniqueId(),
-        'to': jid,
-        'type': 'chat',
-    })
-        .c('store', { xmlns: Strophe.NS.HINTS }).up()
-        .c('apply-to', {
-            'id': origin_id,
-            'xmlns': Strophe.NS.FASTEN,
-        })
-        .c('retract', { xmlns: Strophe.NS.RETRACT });
-    return api.connection.get().send(msg);
+    const stanza = stx`
+        <message id="${retraction_id}"
+                 to="${jid}"
+                 type="chat"
+                 xmlns="jabber:client">
+            <retract id="${origin_id}" xmlns="${Strophe.NS.RETRACT}"/>
+            <body>/me retracted a message</body>
+            <store xmlns="${Strophe.NS.HINTS}"/>
+            <fallback xmlns="${Strophe.NS.FALLBACK}" for="${Strophe.NS.RETRACT}" />
+        </message>`;
+    return api.connection.get().send(stanza);
 }

+ 1 - 1
src/headless/shared/api/events.js

@@ -34,7 +34,7 @@ export default {
         }
         const promise = _converse.promises[name];
         if (promise !== undefined) {
-            promise.resolve();
+            promise.resolve(arguments[1]);
         }
     },
 

+ 8 - 6
src/headless/shared/api/public.js

@@ -1,9 +1,14 @@
 /**
  * @typedef {module:shared-api-public.ConversePrivateGlobal} ConversePrivateGlobal
  */
+import { sprintf } from 'sprintf-js';
 import dayjs from 'dayjs';
 import sizzle from 'sizzle';
 import URI from 'urijs';
+import { Strophe, $build, $iq, $msg, $pres, stx } from 'strophe.js';
+import { Collection, Model } from "@converse/skeletor";
+import { filesize } from 'filesize';
+import { html } from 'lit';
 
 import api from './index.js';
 import _converse from '../_converse.js';
@@ -13,13 +18,9 @@ import ConnectionFeedback from './../connection/feedback.js';
 import u, { setLogLevelFromRoute } from '../../utils/index.js';
 import { ANONYMOUS, CHAT_STATES, KEYCODES, VERSION_NAME } from '../constants.js';
 import { isTestEnv } from '../../utils/session.js';
-import { Collection, Model } from "@converse/skeletor";
-import { Strophe, $build, $iq, $msg, $pres, stx } from 'strophe.js';
 import { TimeoutError } from '../errors.js';
-import { filesize } from 'filesize';
-import { html } from 'lit';
 import { initAppSettings } from '../settings/utils.js';
-import { sprintf } from 'sprintf-js';
+import * as errors from '../errors.js';
 
 _converse.api = api;
 
@@ -178,7 +179,7 @@ const converse = Object.assign(/** @type {ConversePrivateGlobal} */(window).conv
      * @property {function} converse.env.sprintf
      * @property {object} converse.env._           - The instance of [lodash-es](http://lodash.com) used by Converse.
      * @property {object} converse.env.dayjs       - [DayJS](https://github.com/iamkun/dayjs) date manipulation library.
-     * @property {object} converse.env.utils       - Module containing common utility methods used by Converse.
+     * @property {Array<Error>} converse.env.errors
      * @memberOf converse
      */
     'env': {
@@ -195,6 +196,7 @@ const converse = Object.assign(/** @type {ConversePrivateGlobal} */(window).conv
         URI,
         VERSION_NAME,
         dayjs,
+        errors,
         filesize,
         html,
         log,

+ 22 - 29
src/headless/shared/api/send.js

@@ -1,17 +1,16 @@
-/**
- * @typedef {import('strophe.js/src/builder.js').Builder} Strophe.Builder
- */
 import _converse from '../_converse.js';
 import log from '../../log.js';
-import { Strophe, toStanza } from 'strophe.js';
+import { Strophe } from 'strophe.js';
 import { TimeoutError } from '../errors.js';
 
 export default {
     /**
+     * @typedef {import('strophe.js').Builder} Builder
+     *
      * Allows you to send XML stanzas.
      * @method _converse.api.send
-     * @param {Element|Strophe.Builder} stanza
-     * @return {void}
+     * @param {Element|Builder} stanza
+     * @returns {void}
      * @example
      * const msg = converse.env.$msg({
      *     'from': 'juliet@example.com/balcony',
@@ -20,31 +19,26 @@ export default {
      * });
      * _converse.api.send(msg);
      */
-    send (stanza) {
+    send(stanza) {
         const { api } = _converse;
         if (!api.connection.connected()) {
             log.warn("Not sending stanza because we're not connected!");
             log.warn(Strophe.serialize(stanza));
             return;
         }
-        if (typeof stanza === 'string') {
-            stanza = toStanza(stanza);
-        } else if (stanza?.tree) {
-            stanza = stanza.tree();
-        }
-
-        if (stanza.tagName === 'iq') {
-            return api.sendIQ(stanza);
+        const el = stanza instanceof Element ? stanza : stanza.tree();
+        if (el.tagName === 'iq') {
+            return api.sendIQ(el);
         } else {
-            api.connection.get().send(stanza);
-            api.trigger('send', stanza);
+            api.connection.get().send(el);
+            api.trigger('send', el);
         }
     },
 
     /**
      * Send an IQ stanza
      * @method _converse.api.sendIQ
-     * @param {Element|Strophe.Builder} stanza
+     * @param {Element|Builder} stanza
      * @param {number} [timeout] - The default timeout value is taken from
      *  the `stanza_timeout` configuration setting.
      * @param {boolean} [reject=true] - Whether an error IQ should cause the promise
@@ -54,9 +48,8 @@ export default {
      *  If the IQ stanza being sent is of type `result` or `error`, there's
      *  nothing to wait for, so an already resolved promise is returned.
      */
-    sendIQ (stanza, timeout, reject=true) {
+    sendIQ(stanza, timeout, reject = true) {
         const { api } = _converse;
-
         if (!api.connection.connected()) {
             throw new Error("Not sending IQ stanza because we're not connected!");
         }
@@ -64,26 +57,26 @@ export default {
         const connection = api.connection.get();
 
         let promise;
-        stanza = stanza.tree?.() ?? stanza;
-        if (['get', 'set'].includes(stanza.getAttribute('type'))) {
+        const el = stanza instanceof Element ? stanza : stanza.tree();
+        if (['get', 'set'].includes(el.getAttribute('type'))) {
             timeout = timeout || api.settings.get('stanza_timeout');
             if (reject) {
-                promise = new Promise((resolve, reject) => connection.sendIQ(stanza, resolve, reject, timeout));
+                promise = new Promise((resolve, reject) => connection.sendIQ(el, resolve, reject, timeout));
                 promise.catch((e) => {
                     if (e === null) {
                         throw new TimeoutError(
-                            `Timeout error after ${timeout}ms for the following IQ stanza: ${Strophe.serialize(stanza)}`
+                            `Timeout error after ${timeout}ms for the following IQ stanza: ${Strophe.serialize(el)}`
                         );
                     }
                 });
             } else {
-                promise = new Promise((resolve) => connection.sendIQ(stanza, resolve, resolve, timeout));
+                promise = new Promise((resolve) => connection.sendIQ(el, resolve, resolve, timeout));
             }
         } else {
-            connection.sendIQ(stanza);
+            connection.sendIQ(el);
             promise = Promise.resolve();
         }
-        api.trigger('send', stanza);
+        api.trigger('send', el);
         return promise;
-    }
-}
+    },
+};

+ 6 - 2
src/headless/shared/constants.js

@@ -76,6 +76,7 @@ Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
 Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');
 Strophe.addNamespace('DELAY', 'urn:xmpp:delay');
 Strophe.addNamespace('EME', 'urn:xmpp:eme:0');
+Strophe.addNamespace('FALLBACK', 'urn:xmpp:fallback:0');
 Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0');
 Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0');
 Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
@@ -84,7 +85,8 @@ Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
 Strophe.addNamespace('MARKERS', 'urn:xmpp:chat-markers:0');
 Strophe.addNamespace('MENTIONS', 'urn:xmpp:mmn:0');
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
-Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0');
+Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:1');
+Strophe.addNamespace('MODERATE0', 'urn:xmpp:message-moderate:0');
 Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
 Strophe.addNamespace('OCCUPANTID', 'urn:xmpp:occupant-id:0');
 Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
@@ -94,7 +96,8 @@ Strophe.addNamespace('RAI', 'urn:xmpp:rai:0');
 Strophe.addNamespace('RECEIPTS', 'urn:xmpp:receipts');
 Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 Strophe.addNamespace('REGISTER', 'jabber:iq:register');
-Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
+Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:1');
+Strophe.addNamespace('RETRACT0', 'urn:xmpp:message-retract:0');
 Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
 Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
 Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
@@ -112,6 +115,7 @@ Strophe.addNamespace('XHTML', 'http://www.w3.org/1999/xhtml');
 export const CORE_PLUGINS = [
     'converse-adhoc',
     'converse-bookmarks',
+    'converse-blocklist',
     'converse-bosh',
     'converse-caps',
     'converse-chat',

+ 62 - 3
src/headless/shared/errors.js

@@ -1,16 +1,75 @@
+export class MethodNotImplementedError extends Error {}
+
 /**
  * Custom error for indicating timeouts
  * @namespace converse.env
  */
 export class TimeoutError extends Error {
-
     /**
      * @param  {string} message
      */
-    constructor (message) {
+    constructor(message) {
         super(message);
         this.retry_event_id = null;
     }
 }
 
-export class NotImplementedError extends Error {}
+export class StanzaError extends Error {
+    /**
+     * @typedef {import("./types").ErrorName} ErrorName
+     * @typedef {import("./types").ErrorType} ErrorType
+     * @typedef {import("./types").ErrorExtra} ErrorExtra
+     */
+
+    /**
+     * @param {ErrorName|'unknown'} name
+     * @param {Element} e - The <error> element from a stanza
+     * @param {Object} extra - Extra properties from plugin parsers
+     */
+    constructor(name, e, extra) {
+        super(e.querySelector('text')?.textContent ?? '');
+        /** @type {ErrorName} */
+        this.name = name
+        /** @type {ErrorType} */
+        this.type = /** @type {ErrorType} */ (e.getAttribute('type'));
+        /** @type {Element} */
+        this.el = e;
+        /** @type {ErrorExtra} */
+        this.extra = extra;
+    }
+}
+
+export class StanzaParseError extends Error {
+    /**
+     * @param {Element} stanza
+     * @param {string} [message]
+     */
+    constructor(stanza, message) {
+        super(message);
+        this.name = 'StanzaParseError';
+        this.stanza = stanza;
+    }
+}
+
+export class BadRequestError extends StanzaError {}
+export class ConflictError extends StanzaError {}
+export class FeatureNotImplementedError extends StanzaError {}
+export class ForbiddenError extends StanzaError {}
+export class GoneError extends StanzaError {}
+export class InternalServerError extends StanzaError {}
+export class ItemNotFoundError extends StanzaError {}
+export class JIDMalformedError extends StanzaError {}
+export class NotAcceptableError extends StanzaError {}
+export class NotAllowedError extends StanzaError {}
+export class NotAuthorizedError extends StanzaError {}
+export class PaymentRequiredError extends StanzaError {}
+export class RecipientUnavailableError extends StanzaError {}
+export class RedirectError extends StanzaError {}
+export class RegistrationRequiredError extends StanzaError {}
+export class RemoteServerNotFoundError extends StanzaError {}
+export class RemoteServerTimeoutError extends StanzaError {}
+export class ResourceConstraintError extends StanzaError {}
+export class ServiceUnavailableError extends StanzaError {}
+export class SubscriptionRequiredError extends StanzaError {}
+export class UndefinedConditionError extends StanzaError {}
+export class UnexpectedRequestError extends StanzaError {}

+ 2 - 1
src/headless/shared/index.js

@@ -1,7 +1,8 @@
 import * as parsers from './parsers.js';
 import * as constants from './constants.js';
+import * as errors from './errors.js';
 import api from './api/index.js';
 import _converse from './_converse';
 import i18n from './i18n';
 
-export { _converse, api, constants, i18n, parsers };
+export { _converse, api, constants, i18n, parsers, errors };

+ 17 - 18
src/headless/shared/model-with-contact.js

@@ -8,33 +8,32 @@ import api from './api/index.js';
  * @param {T} BaseModel
  */
 export default function ModelWithContact(BaseModel) {
-
     return class ModelWithContact extends BaseModel {
         /**
-        * @typedef {import('../plugins/vcard/vcard').default} VCard
-        * @typedef {import('../plugins/roster/contact').default} RosterContact
-        * @typedef {import('./_converse.js').XMPPStatus} XMPPStatus
-        */
+         * @typedef {import('../plugins/vcard/vcard').default} VCard
+         * @typedef {import('../plugins/roster/contact').default} RosterContact
+         * @typedef {import('./_converse.js').XMPPStatus} XMPPStatus
+         */
 
         initialize() {
             super.initialize();
             this.rosterContactAdded = getOpenPromise();
             /**
-            * @public
-            * @type {RosterContact|XMPPStatus}
-            */
+             * @public
+             * @type {RosterContact|XMPPStatus}
+             */
             this.contact = null;
 
             /**
-            * @public
-            * @type {VCard}
-            */
+             * @public
+             * @type {VCard}
+             */
             this.vcard = null;
         }
 
         /**
-        * @param {string} jid
-        */
+         * @param {string} jid
+         */
         async setModelContact(jid) {
             if (this.contact?.get('jid') === jid) return;
 
@@ -44,10 +43,10 @@ export default function ModelWithContact(BaseModel) {
             if (Strophe.getBareJidFromJid(jid) === session.get('bare_jid')) {
                 contact = state.xmppstatus;
             } else {
-                contact = await api.contacts.get(jid) || await api.contacts.add({
-                    jid,
-                    subscription: 'none',
-                }, false, false);
+                contact = await api.contacts.get(jid);
+                if (!contact && !(await api.blocklist.get()).get(jid)) {
+                    await api.contacts.add({ jid, subscription: 'none' }, false, false);
+                }
             }
 
             if (contact) {
@@ -65,5 +64,5 @@ export default function ModelWithContact(BaseModel) {
                 this.trigger('contactAdded', this.contact);
             }
         }
-    }
+    };
 }

+ 105 - 101
src/headless/shared/model-with-messages.js

@@ -11,11 +11,11 @@ import converse from './api/public.js';
 import api from './api/index.js';
 import { isNewMessage } from '../plugins/chat/utils.js';
 import _converse from './_converse.js';
-import { NotImplementedError } from './errors.js';
+import { MethodNotImplementedError } from './errors.js';
 import { sendMarker, sendReceiptStanza, sendRetractionMessage } from './actions.js';
-import {parseMessage} from '../plugins/chat/parsers';
+import { parseMessage } from '../plugins/chat/parsers';
 
-const { Strophe, $msg, u } = converse.env;
+const { Strophe, stx, u } = converse.env;
 
 /**
  * Adds a messages collection to a model and various methods related to sending
@@ -30,7 +30,7 @@ const { Strophe, $msg, u } = converse.env;
  */
 export default function ModelWithMessages(BaseModel) {
     /**
-     * @typedef {import('./parsers').StanzaParseError} StanzaParseError
+     * @typedef {import('./errors').StanzaParseError} StanzaParseError
      * @typedef {import('../plugins/chat/message').default} Message
      * @typedef {import('../plugins/chat/model').default} ChatBox
      * @typedef {import('../plugins/muc/muc').default} MUC
@@ -155,10 +155,10 @@ export default function ModelWithMessages(BaseModel) {
         }
 
         /**
-         * @param {MessageAttributes|Error} attrs_or_error
+         * @param {MessageAttributes|Error} _attrs_or_error
          */
-        async onMessage(attrs_or_error) {
-            throw new NotImplementedError('onMessage is not implemented');
+        async onMessage(_attrs_or_error) {
+            throw new MethodNotImplementedError('onMessage is not implemented');
         }
 
         /**
@@ -262,7 +262,7 @@ export default function ModelWithMessages(BaseModel) {
          * @return {Promise<MessageAttributes>}
          */
         async getOutgoingMessageAttributes(_attrs) {
-            throw new NotImplementedError('getOutgoingMessageAttributes is not implemented');
+            throw new MethodNotImplementedError('getOutgoingMessageAttributes is not implemented');
         }
 
         /**
@@ -274,6 +274,8 @@ export default function ModelWithMessages(BaseModel) {
          *  chat.sendMessage({'body': 'hello world'});
          */
         async sendMessage(attrs) {
+            await converse.emojis?.initialized_promise;
+
             if (!this.canPostMessages()) {
                 log.warn('sendMessage was called but canPostMessages is false');
                 return;
@@ -333,18 +335,19 @@ export default function ModelWithMessages(BaseModel) {
          * @param {Message} message - The message which we're retracting.
          */
         retractOwnMessage(message) {
-            sendRetractionMessage(this.get('jid'), message);
+            const retraction_id = u.getUniqueId();
+            sendRetractionMessage(this.get('jid'), message, retraction_id);
             message.save({
                 'retracted': new Date().toISOString(),
                 'retracted_id': message.get('origin_id'),
-                'retraction_id': message.get('id'),
+                'retraction_id': retraction_id,
                 'is_ephemeral': true,
                 'editable': false,
             });
         }
 
         /**
-         * @param {File[]} files
+         * @param {File[]} files'
          */
         async sendFiles(files) {
             const { __, session } = _converse;
@@ -748,11 +751,48 @@ export default function ModelWithMessages(BaseModel) {
             }
         }
 
+        /**
+         * @param {Message} message
+         * @param {MessageAttributes} attrs
+         */
+        async getErrorAttributesForMessage(message, attrs) {
+            const { __ } = _converse;
+            const new_attrs = {
+                editable: false,
+                error: attrs.error,
+                error_condition: attrs.error_condition,
+                error_text: attrs.error_text,
+                error_type: attrs.error_type,
+                is_error: true,
+            };
+            if (attrs.msgid === message.get('retraction_id')) {
+                // The error message refers to a retraction
+                new_attrs.retraction_id = undefined;
+                if (!attrs.error) {
+                    if (attrs.error_condition === 'forbidden') {
+                        new_attrs.error = __("You're not allowed to retract your message.");
+                    } else {
+                        new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
+                    }
+                }
+            } else if (!attrs.error) {
+                if (attrs.error_condition === 'forbidden') {
+                    new_attrs.error = __("You're not allowed to send a message.");
+                } else {
+                    new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
+                }
+            }
+            /**
+             * *Hook* which allows plugins to add application-specific attributes
+             * @event _converse#getErrorAttributesForMessage
+             */
+            return await api.hook('getErrorAttributesForMessage', attrs, new_attrs);
+        }
+
         /**
          * @param {Element} stanza
          */
         async handleErrorMessageStanza(stanza) {
-            const { __ } = _converse;
             const attrs_or_error = await parseMessage(stanza);
             if (u.isErrorObject(attrs_or_error)) {
                 const { stanza, message } = /** @type {StanzaParseError} */ (attrs_or_error);
@@ -767,30 +807,7 @@ export default function ModelWithMessages(BaseModel) {
 
             const message = this.getMessageReferencedByError(attrs);
             if (message) {
-                const new_attrs = {
-                    'error': attrs.error,
-                    'error_condition': attrs.error_condition,
-                    'error_text': attrs.error_text,
-                    'error_type': attrs.error_type,
-                    'editable': false,
-                };
-                if (attrs.msgid === message.get('retraction_id')) {
-                    // The error message refers to a retraction
-                    new_attrs.retraction_id = undefined;
-                    if (!attrs.error) {
-                        if (attrs.error_condition === 'forbidden') {
-                            new_attrs.error = __("You're not allowed to retract your message.");
-                        } else {
-                            new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
-                        }
-                    }
-                } else if (!attrs.error) {
-                    if (attrs.error_condition === 'forbidden') {
-                        new_attrs.error = __("You're not allowed to send a message.");
-                    } else {
-                        new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
-                    }
-                }
+                const new_attrs = await this.getErrorAttributesForMessage(message, attrs);
                 message.save(new_attrs);
             } else {
                 this.createMessage(attrs);
@@ -814,7 +831,7 @@ export default function ModelWithMessages(BaseModel) {
             if (this.get('num_unread') > 0) {
                 this.sendMarkerForMessage(this.messages.last());
             }
-            u.safeSave(this, { 'num_unread': 0 });
+            u.safeSave(this, { num_unread: 0 });
         }
 
         /**
@@ -827,23 +844,26 @@ export default function ModelWithMessages(BaseModel) {
         async handleRetraction(attrs) {
             const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable'];
             if (attrs.retracted) {
-                if (attrs.is_tombstone) {
-                    return false;
-                }
-                const message = this.messages.findWhere({ 'origin_id': attrs.retracted_id, 'from': attrs.from });
-                if (!message) {
-                    attrs['dangling_retraction'] = true;
-                    await this.createMessage(attrs);
-                    return true;
+                if (attrs.is_tombstone) return false;
+
+                for (const m of this.messages.models) {
+                    if (m.get('from') !== attrs.from) continue;
+                    if (m.get('origin_id') === attrs.retracted_id ||
+                            m.get('msgid') === attrs.retracted_id) {
+                        m.save(pick(attrs, RETRACTION_ATTRIBUTES));
+                        return true;
+                    }
                 }
-                message.save(pick(attrs, RETRACTION_ATTRIBUTES));
+
+                attrs['dangling_retraction'] = true;
+                await this.createMessage(attrs);
                 return true;
             } else {
                 // Check if we have dangling retraction
                 const message = this.findDanglingRetraction(attrs);
                 if (message) {
                     const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
-                    const new_attrs = Object.assign({ 'dangling_retraction': false }, attrs, retraction_attrs);
+                    const new_attrs = Object.assign({ dangling_retraction: false }, attrs, retraction_attrs);
                     delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
                     message.save(new_attrs);
                     return true;
@@ -873,64 +893,48 @@ export default function ModelWithMessages(BaseModel) {
         /**
          * Given a {@link Message} return the XML stanza that represents it.
          * @method ChatBox#createMessageStanza
-         * @param { Message } message - The message object
+         * @param {Message} message - The message object
          */
         async createMessageStanza(message) {
-            const stanza = $msg({
-                'from': message.get('from') || api.connection.get().jid,
-                'to': message.get('to') || this.get('jid'),
-                'type': this.get('message_type'),
-                'id': (message.get('edited') && u.getUniqueId()) || message.get('msgid'),
-            })
-                .c('body')
-                .t(message.get('body'))
-                .up()
-                .c(constants.ACTIVE, { 'xmlns': Strophe.NS.CHATSTATES })
-                .root();
-
-            if (message.get('type') === 'chat') {
-                stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).root();
-            }
-
-            if (!message.get('is_encrypted')) {
-                if (message.get('is_spoiler')) {
-                    if (message.get('spoiler_hint')) {
-                        stanza.c('spoiler', { 'xmlns': Strophe.NS.SPOILER }, message.get('spoiler_hint')).root();
-                    } else {
-                        stanza.c('spoiler', { 'xmlns': Strophe.NS.SPOILER }).root();
-                    }
-                }
-                (message.get('references') || []).forEach((reference) => {
-                    const attrs = {
-                        'xmlns': Strophe.NS.REFERENCE,
-                        'begin': reference.begin,
-                        'end': reference.end,
-                        'type': reference.type,
-                    };
-                    if (reference.uri) {
-                        attrs.uri = reference.uri;
+            const {
+                body,
+                edited,
+                is_encrypted,
+                is_spoiler,
+                msgid,
+                oob_url,
+                origin_id,
+                references,
+                spoiler_hint,
+                type,
+            } = message.attributes;
+
+            const stanza = stx`
+                <message xmlns="jabber:client"
+                        from="${message.get('type') === 'groupchat' ? api.connection.get().jid : message.get('from')}"
+                        to="${message.get('to') || this.get('jid')}"
+                        type="${this.get('message_type')}"
+                        id="${(edited && u.getUniqueId()) || msgid}">
+                    ${body ? stx`<body>${body}</body>` : ''}
+                    <active xmlns="${Strophe.NS.CHATSTATES}"/>
+                    ${type === 'chat' ? stx`<request xmlns="${Strophe.NS.RECEIPTS}"></request>` : ''}
+                    ${!is_encrypted && oob_url ? stx`<x xmlns="${Strophe.NS.OUTOFBAND}"><url>${oob_url}</url></x>` : ''}
+                    ${!is_encrypted && is_spoiler ? stx`<spoiler xmlns="${Strophe.NS.SPOILER}">${spoiler_hint ?? ''}</spoiler>` : ''}
+                    ${
+                        !is_encrypted
+                            ? references?.map(
+                                  (ref) => stx`<reference xmlns="${Strophe.NS.REFERENCE}"
+                                                begin="${ref.begin}"
+                                                end="${ref.end}"
+                                                type="${ref.type}"
+                                                uri="${ref.uri}"></reference>`
+                              )
+                            : ''
                     }
-                    stanza.c('reference', attrs).root();
-                });
-
-                if (message.get('oob_url')) {
-                    stanza.c('x', { 'xmlns': Strophe.NS.OUTOFBAND }).c('url').t(message.get('oob_url')).root();
-                }
-            }
+                    ${edited ? stx`<replace xmlns="${Strophe.NS.MESSAGE_CORRECT}" id="${msgid}"></replace>` : ''}
+                    ${origin_id ? stx`<origin-id xmlns="${Strophe.NS.SID}" id="${origin_id}"></origin-id>` : ''}
+                </message>`;
 
-            if (message.get('edited')) {
-                stanza
-                    .c('replace', {
-                        'xmlns': Strophe.NS.MESSAGE_CORRECT,
-                        'id': message.get('msgid'),
-                    })
-                    .root();
-            }
-
-            if (message.get('origin_id')) {
-                stanza.c('origin-id', { 'xmlns': Strophe.NS.SID, 'id': message.get('origin_id') }).root();
-            }
-            stanza.root();
             /**
              * *Hook* which allows plugins to update an outgoing message stanza
              * @event _converse#createMessageStanza

+ 120 - 34
src/headless/shared/parsers.js

@@ -11,20 +11,80 @@ import { decodeHTMLEntities } from '../utils/html.js';
 import { getAttributes } from '../utils/stanza.js';
 import { rejectMessage } from './actions.js';
 import { XFORM_TYPE_MAP,  XFORM_VALIDATE_TYPE_MAP } from './constants.js';
+import * as errors from './errors.js';
 
 
 const { NS } = Strophe;
 
-export class StanzaParseError extends Error {
+/**
+ * @param {Element|Error} stanza - The stanza to be parsed. As a convenience,
+ * an Error element can be passed in as well, so that this function can be
+ * called in a catch block without first checking if a stanza or Error
+ * element was received.
+ * @returns {Promise<Error|errors.StanzaError|null>}
+ */
+export async function parseErrorStanza(stanza) {
+    if (stanza instanceof Error) return stanza;
+    if (stanza.getAttribute('type') !== 'error') return null;
+
+    const error = stanza.querySelector('error');
+    if (!error) return null;
+
+    const e = sizzle(`[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
+    const name = e?.nodeName;
+
     /**
-     * @param {string} message
-     * @param {Element} stanza
+     * *Hook* which allows plugins to add application-specific error parsing
+     * @event _converse#parseErrorStanza
      */
-    constructor (message, stanza) {
-        super(message);
-        this.name = 'StanzaParseError';
-        this.stanza = stanza;
+    const extra = await api.hook('parseErrorStanza', stanza, {});
+
+    if (name === 'bad-request') {
+        return new errors.BadRequestError(name, error, extra);
+    } else if (name === 'conflict') {
+        return new errors.ConflictError(name, error, extra);
+    } else if (name === 'feature-not-implemented') {
+        return new errors.FeatureNotImplementedError(name, error, extra);
+    } else if (name === 'forbidden') {
+        return new errors.ForbiddenError(name, error, extra);
+    } else if (name === 'gone') {
+        return new errors.GoneError(name, error, extra);
+    } else if (name === 'internal-server-error') {
+        return new errors.InternalServerError(name, error, extra);
+    } else if (name === 'item-not-found') {
+        return new errors.ItemNotFoundError(name, error, extra);
+    } else if (name === 'jid-malformed') {
+        return new errors.JIDMalformedError(name, error, extra);
+    } else if (name === 'not-acceptable') {
+        return new errors.NotAcceptableError(name, error, extra);
+    } else if (name === 'not-allowed') {
+        return new errors.NotAllowedError(name, error, extra);
+    } else if (name === 'not-authorized') {
+        return new errors.NotAuthorizedError(name, error, extra);
+    } else if (name === 'payment-required') {
+        return new errors.PaymentRequiredError(name, error, extra);
+    } else if (name === 'recipient-unavailable') {
+        return new errors.RecipientUnavailableError(name, error, extra);
+    } else if (name === 'redirect') {
+        return new errors.RedirectError(name, error, extra);
+    } else if (name === 'registration-required') {
+        return new errors.RegistrationRequiredError(name, error, extra);
+    } else if (name === 'remote-server-not-found') {
+        return new errors.RemoteServerNotFoundError(name, error, extra);
+    } else if (name === 'remote-server-timeout') {
+        return new errors.RemoteServerTimeoutError(name, error, extra);
+    } else if (name === 'resource-constraint') {
+        return new errors.ResourceConstraintError(name, error, extra);
+    } else if (name === 'service-unavailable') {
+        return new errors.ServiceUnavailableError(name, error, extra);
+    } else if (name === 'subscription-required') {
+        return new errors.SubscriptionRequiredError(name, error, extra);
+    } else if (name === 'undefined-condition') {
+        return new errors.UndefinedConditionError(name, error, extra);
+    } else if (name === 'unexpected-request') {
+        return new errors.UnexpectedRequestError(name, error, extra);
     }
+    return new errors.StanzaError('unknown', error);
 }
 
 /**
@@ -36,14 +96,21 @@ export class StanzaParseError extends Error {
  * @returns {Object}
  */
 export function getStanzaIDs (stanza, original_stanza) {
-    const attrs = {};
-    // Store generic stanza ids
+    // Generic stanza ids
     const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
     const sid_attrs = sids.reduce((acc, s) => {
         acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
         return acc;
     }, {});
-    Object.assign(attrs, sid_attrs);
+
+    // Origin id
+    const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop()?.getAttribute('id');
+
+    const attrs = {
+        origin_id,
+        msgid: stanza.getAttribute('id') || original_stanza.getAttribute('id'),
+        ...sid_attrs,
+    };
 
     // Store the archive id
     const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
@@ -53,11 +120,6 @@ export function getStanzaIDs (stanza, original_stanza) {
         attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
     }
 
-    // Store the origin id
-    const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
-    if (origin_id) {
-        attrs['origin_id'] = origin_id.getAttribute('id');
-    }
     return attrs;
 }
 
@@ -83,33 +145,56 @@ export function getEncryptionAttributes (stanza) {
  * @param {Element} stanza - The message stanza
  * @param {Element} original_stanza - The original stanza, that contains the
  *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns {Object}
+ * @returns {import('./types').RetractionAttrs | {}}
  */
-export function getRetractionAttributes (stanza, original_stanza) {
+export function getDeprecatedRetractionAttributes (stanza, original_stanza) {
     const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
     if (fastening) {
         const applies_to_id = fastening.getAttribute('id');
-        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
+        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT0}"]`, fastening).pop();
         if (retracted) {
             const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
             const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
             return {
-                'editable': false,
-                'retracted': time,
-                'retracted_id': applies_to_id
+                editable: false,
+                retracted: time,
+                retracted_id: applies_to_id
             };
         }
+    }
+    return {};
+}
+
+/**
+ * @param {Element} stanza - The message stanza
+ * @param {Element} original_stanza - The original stanza, that contains the
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns {import('./types').RetractionAttrs | {}}
+ */
+export function getRetractionAttributes (stanza, original_stanza) {
+    const retraction = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+    if (retraction) {
+        const delay = sizzle(`> delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+        const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
+        return {
+            editable: false,
+            retracted: time,
+            retracted_id: retraction.getAttribute('id')
+        };
     } else {
-        const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+        const tombstone =
+            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop() ||
+            sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT0}"]`, stanza).pop();
         if (tombstone) {
             return {
-                'editable': false,
-                'is_tombstone': true,
-                'retracted': tombstone.getAttribute('stamp')
+                editable: false,
+                is_tombstone: true,
+                retracted: tombstone.getAttribute('stamp'),
+                retraction_id: tombstone.getAttribute('id')
             };
         }
     }
-    return {};
+    return getDeprecatedRetractionAttributes(stanza, original_stanza);
 }
 
 /**
@@ -199,10 +284,11 @@ export function getErrorAttributes (stanza) {
         const error = stanza.querySelector('error');
         const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
         return {
-            'is_error': true,
-            'error_text': text?.textContent,
-            'error_type': error.getAttribute('type'),
-            'error_condition': error.firstElementChild.nodeName
+            is_error: true,
+            error_text: text?.textContent,
+            error_type: error.getAttribute('type'),
+            error_condition: error.firstElementChild.nodeName,
+            errors: Array.from(error.children).map((e) => ({ name: e.nodeName, xmlns: e.getAttribute('xmlns') })),
         };
     }
     return {};
@@ -292,7 +378,7 @@ export function throwErrorIfInvalidForward (stanza) {
     if (bare_forward) {
         rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported');
         const from_jid = stanza.getAttribute('from');
-        throw new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza);
+        throw new errors.StanzaParseError(stanza, `Ignoring unencapsulated forwarded message from ${from_jid}`);
     }
 }
 
@@ -480,9 +566,9 @@ export function getInputType(field) {
 }
 
 /**
-* @param {Element} stanza
-* @returns {import('./types').XForm}
-*/
+ * @param {Element} stanza
+ * @returns {import('./types').XForm}
+ */
 export function parseXForm(stanza) {
     const xs = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, stanza);
     if (xs.length > 1) {

+ 3 - 1
src/headless/shared/settings/constants.js

@@ -33,7 +33,7 @@
  * @property {Array<String>} [whitelisted_plugins]
  */
 export const DEFAULT_SETTINGS = {
-    allow_non_roster_messaging: false,
+    allow_non_roster_messaging: true,
     allow_url_history_change: true,
     assets_path: '/dist',
     authentication: 'login', // Available values are "login", "prebind", "anonymous" and "external".
@@ -45,6 +45,7 @@ export const DEFAULT_SETTINGS = {
     credentials_url: null, // URL from where login credentials can be fetched
     disable_effects: false, // Disabled UI transition effects. Mainly used for tests.
     discover_connection_methods: true,
+    embed_3rd_party_media_players: true,
     geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
     geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
     i18n: undefined,
@@ -86,6 +87,7 @@ export const DEFAULT_SETTINGS = {
         'ro',
         'ru',
         'sv',
+        'ta',
         'th',
         'tr',
         'ug',

+ 37 - 0
src/headless/shared/types.ts

@@ -13,6 +13,14 @@ type EncryptionPayloadAttrs = {
     device_id: string;
 };
 
+export type RetractionAttrs = {
+    editable: boolean;
+    is_tombstone?: boolean;
+    retracted: string;
+    retracted_id?: string; // ID of the message being retracted
+    retraction_id?: string; // ID of the retraction message
+}
+
 export type EncryptionAttrs = {
     encrypted?: EncryptionPayloadAttrs; //  XEP-0384 encryption payload attributes
     is_encrypted: boolean;
@@ -87,3 +95,32 @@ export type XEP372Reference = {
     value: string;
     uri: string;
 };
+
+export type ErrorExtra = Record<string, string>;
+
+// https://datatracker.ietf.org/doc/html/rfc6120#section-8.3
+export type ErrorName =
+    | 'bad-request'
+    | 'conflict'
+    | 'feature-not-implemented'
+    | 'forbidden'
+    | 'gone'
+    | 'internal-server-error'
+    | 'item-not-found'
+    | 'jid-malformed'
+    | 'not-acceptable'
+    | 'not-allowed'
+    | 'not-authorized'
+    | 'payment-required'
+    | 'recipient-unavailable'
+    | 'redirect'
+    | 'registration-required'
+    | 'remote-server-not-found'
+    | 'remote-server-timeout'
+    | 'resource-constraint'
+    | 'service-unavailable'
+    | 'subscription-required'
+    | 'undefined-condition'
+    | 'unexpected-request';
+
+export type ErrorType = 'auth' | 'cancel' | 'continue' | 'modify' | 'wait';

+ 26 - 0
src/headless/types/plugins/blocklist/api.d.ts

@@ -0,0 +1,26 @@
+export default blocklist_api;
+declare namespace blocklist_api {
+    export { blocklist };
+}
+declare namespace blocklist {
+    /**
+     * Retrieves the current user's blocklist
+     * @returns {Promise<import('./collection').default>}
+     */
+    function get(): Promise<import("./collection").default>;
+    /**
+     * Adds a new entity to the blocklist
+     * @param {string|string[]} jid
+     * @param {boolean} [send_stanza=true]
+     * @returns {Promise<import('./collection').default>}
+     */
+    function add(jid: string | string[], send_stanza?: boolean): Promise<import("./collection").default>;
+    /**
+     * Removes an entity from the blocklist
+     * @param {string|string[]} jid
+     * @param {boolean} [send_stanza=true]
+     * @returns {Promise<import('./collection').default>}
+     */
+    function remove(jid: string | string[], send_stanza?: boolean): Promise<import("./collection").default>;
+}
+//# sourceMappingURL=api.d.ts.map

+ 25 - 0
src/headless/types/plugins/blocklist/collection.d.ts

@@ -0,0 +1,25 @@
+export default Blocklist;
+declare class Blocklist extends Collection {
+    constructor();
+    get idAttribute(): string;
+    model: typeof BlockedEntity;
+    initialize(): Promise<void>;
+    fetched_flag: string;
+    /**
+     * @param {BlockedEntity} item
+     */
+    rejectContactRequest(item: BlockedEntity): Promise<void>;
+    fetchBlocklist(): any;
+    /**
+     * @param {Object} deferred
+     */
+    fetchBlocklistFromServer(deferred: any): Promise<void>;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBlocklistReceived(deferred: any, iq: Element): Promise<any>;
+}
+import { Collection } from '@converse/skeletor';
+import BlockedEntity from './model.js';
+//# sourceMappingURL=collection.d.ts.map

+ 2 - 0
src/headless/types/plugins/blocklist/index.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=index.d.ts.map

+ 6 - 0
src/headless/types/plugins/blocklist/model.d.ts

@@ -0,0 +1,6 @@
+export default BlockedEntity;
+declare class BlockedEntity extends Model {
+    getDisplayName(): any;
+}
+import { Model } from '@converse/skeletor';
+//# sourceMappingURL=model.d.ts.map

+ 2 - 0
src/headless/types/plugins/blocklist/plugin.d.ts

@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=plugin.d.ts.map

+ 11 - 0
src/headless/types/plugins/blocklist/utils.d.ts

@@ -0,0 +1,11 @@
+/**
+ * Sends an IQ stanza to remove one or more JIDs from the blocklist
+ * @param {string|string[]} jid
+ */
+export function sendUnblockStanza(jid: string | string[]): Promise<void>;
+/**
+ * Sends an IQ stanza to add one or more JIDs from the blocklist
+ * @param {string|string[]} jid
+ */
+export function sendBlockStanza(jid: string | string[]): Promise<void>;
+//# sourceMappingURL=utils.d.ts.map

+ 23 - 0
src/headless/types/plugins/bookmarks/api.d.ts

@@ -0,0 +1,23 @@
+export default bookmarks_api;
+declare namespace bookmarks_api {
+    export { bookmarks };
+}
+declare namespace bookmarks {
+    /**
+     * Calling this function will result in an IQ stanza being sent out to set
+     * the bookmark on the server.
+     *
+     * @method api.bookmarks.set
+     * @param {import('./types').BookmarkAttrs} attrs - The room attributes
+     * @param {boolean} create=true - Whether the bookmark should be created if it doesn't exist
+     * @returns {Promise<import('./model').default>}
+     */
+    function set(attrs: import("./types").BookmarkAttrs, create?: boolean): Promise<import("./model").default>;
+    /**
+     * @method api.bookmarks.get
+     * @param {string} jid - The JID of the bookmark to return.
+     * @returns {Promise<import('./model').default|undefined>}
+     */
+    function get(jid: string): Promise<import("./model").default | undefined>;
+}
+//# sourceMappingURL=api.d.ts.map

+ 41 - 9
src/headless/types/plugins/bookmarks/collection.d.ts

@@ -3,6 +3,7 @@ export type MUC = import("../muc/muc.js").default;
 declare class Bookmarks extends Collection {
     static checkBookmarksSupport(): Promise<any>;
     constructor();
+    get idAttribute(): string;
     initialize(): Promise<void>;
     fetched_flag: string;
     model: typeof Bookmark;
@@ -11,10 +12,28 @@ declare class Bookmarks extends Collection {
      */
     openBookmarkedRoom(bookmark: Bookmark): Promise<Bookmark>;
     fetchBookmarks(): any;
-    createBookmark(options: any): void;
-    sendBookmarkStanza(): any;
-    onBookmarkError(iq: any, options: any): void;
-    fetchBookmarksFromServer(deferred: any): void;
+    /**
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    setBookmark(attrs: import("./types").BookmarkAttrs, create?: boolean): void;
+    /**
+     * @param {'urn:xmpp:bookmarks:1'|'storage:bookmarks'} node
+     * @returns {Stanza|Stanza[]}
+     */
+    getPublishedItems(node: "urn:xmpp:bookmarks:1" | "storage:bookmarks"): Stanza | Stanza[];
+    /**
+     * @returns {Promise<void|Element>}
+     */
+    sendBookmarkStanza(): Promise<void | Element>;
+    /**
+     * @param {Element} iq
+     * @param {import('./types').BookmarkAttrs} attrs
+     */
+    onBookmarkError(iq: Element, attrs: import("./types").BookmarkAttrs): void;
+    /**
+     * @param {Promise} deferred
+     */
+    fetchBookmarksFromServer(deferred: Promise<any>): Promise<void>;
     /**
      * @param {Bookmark} bookmark
      */
@@ -22,15 +41,28 @@ declare class Bookmarks extends Collection {
     /**
      * @param {Bookmark} bookmark
      */
-    markRoomAsUnbookmarked(bookmark: Bookmark): void;
+    onAutoJoinChanged(bookmark: Bookmark): void;
+    /**
+     * @param {Bookmark} bookmark
+     */
+    leaveRoom(bookmark: Bookmark): Promise<void>;
     /**
      * @param {Element} stanza
      */
-    createBookmarksFromStanza(stanza: Element): void;
-    onBookmarksReceived(deferred: any, iq: any): any;
-    onBookmarksReceivedError(deferred: any, iq: any): any;
+    setBookmarksFromStanza(stanza: Element): Promise<void>;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBookmarksReceived(deferred: any, iq: Element): Promise<any>;
+    /**
+     * @param {Object} deferred
+     * @param {Element} iq
+     */
+    onBookmarksReceivedError(deferred: any, iq: Element): Promise<void>;
     getUnopenedBookmarks(): Promise<any>;
 }
-import { Collection } from "@converse/skeletor";
+import { Collection } from '@converse/skeletor';
 import Bookmark from './model.js';
+import { Stanza } from 'strophe.js';
 //# sourceMappingURL=collection.d.ts.map

+ 6 - 0
src/headless/types/plugins/bookmarks/parsers.d.ts

@@ -0,0 +1,6 @@
+/**
+ * @param {Element} stanza
+ * @returns {Promise<Array<import('./types.js').BookmarkAttrs>>}
+ */
+export function parseStanzaForBookmarks(stanza: Element): Promise<Array<import("./types.js").BookmarkAttrs>>;
+//# sourceMappingURL=parsers.d.ts.map

+ 9 - 0
src/headless/types/plugins/bookmarks/types.d.ts

@@ -0,0 +1,9 @@
+export type BookmarkAttrs = {
+    jid: string;
+    name?: string;
+    autojoin?: boolean;
+    nick?: string;
+    password?: string;
+    extensions: string[];
+};
+//# sourceMappingURL=types.d.ts.map

+ 10 - 2
src/headless/types/plugins/bookmarks/utils.d.ts

@@ -1,4 +1,12 @@
 export function initBookmarks(): Promise<void>;
-export function getNicknameFromBookmark(jid: any): any;
-export function handleBookmarksPush(message: any): boolean;
+/**
+ * @param {string} jid - The JID of the bookmark.
+ * @returns {string|null} The nickname if found, otherwise null.
+ */
+export function getNicknameFromBookmark(jid: string): string | null;
+/**
+ * @param {import('../chat/message')} message
+ * @returns {true}
+ */
+export function handleBookmarksPush(message: typeof import("../chat/message")): true;
 //# sourceMappingURL=utils.d.ts.map

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است