Переглянути джерело

WIP: Add support for XEP-437: Room Activity Indicators

- Send marker for last message before leaving and before subscribing to RAI
- clear cache of RAI-subscribed domains on reconnection
JC Brand 4 роки тому
батько
коміт
c457081597

+ 1 - 1
.prettierrc

@@ -1,6 +1,6 @@
 {
 {
   "arrowParens": "avoid",
   "arrowParens": "avoid",
-  "printWidth": 100,
+  "printWidth": 120,
   "quoteProps": "preserve",
   "quoteProps": "preserve",
   "singleQuote": true,
   "singleQuote": true,
   "spaceBeforeFunctionParen": true,
   "spaceBeforeFunctionParen": true,

+ 1 - 0
README.md

@@ -105,6 +105,7 @@ In embedded mode, Converse can be embedded into an element in the DOM.
 - [XEP-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support)
 - [XEP-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support)
 - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions
 - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions
 - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation
 - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation
+- [XEP-0437](https://xmpp.org/extensions/xep-0437.html) Room Activity Indicators
 
 
 
 
 ## Integration into other servers and frameworks
 ## Integration into other servers and frameworks

+ 12 - 0
docs/source/configuration.rst

@@ -1447,6 +1447,18 @@ a nickname configured for it), you'll see the message history (if the
 server supports [XEP-0313 Message Archive Management](https://xmpp.org/extensions/xep-0313.html))
 server supports [XEP-0313 Message Archive Management](https://xmpp.org/extensions/xep-0313.html))
 and the nickname form at the bottom.
 and the nickname form at the bottom.
 
 
+muc_subscribe_to_rai
+--------------------
+
+* Default: ``false``
+
+This option enables support for XEP-0437 Room Activity Indicators.
+
+When a MUC is no longer visible (the ``hidden`` flag becomes ``true``), then
+Converse will make sure that its subscribed to activity indicators on the MUC
+host.
+
+
 .. _`nickname`:
 .. _`nickname`:
 
 
 nickname
 nickname

+ 12 - 5
package-lock.json

@@ -2757,7 +2757,8 @@
 			"dependencies": {
 			"dependencies": {
 				"filesize": {
 				"filesize": {
 					"version": "6.1.0",
 					"version": "6.1.0",
-					"resolved": false
+					"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
+					"integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg=="
 				},
 				},
 				"fs-extra": {
 				"fs-extra": {
 					"version": "8.1.0",
 					"version": "8.1.0",
@@ -2813,20 +2814,22 @@
 				},
 				},
 				"localforage": {
 				"localforage": {
 					"version": "1.7.3",
 					"version": "1.7.3",
-					"resolved": false,
+					"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz",
+					"integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==",
 					"requires": {
 					"requires": {
 						"lie": "3.1.1"
 						"lie": "3.1.1"
 					}
 					}
 				},
 				},
 				"pluggable.js": {
 				"pluggable.js": {
 					"version": "2.0.1",
 					"version": "2.0.1",
-					"resolved": false,
+					"resolved": "https://registry.npmjs.org/pluggable.js/-/pluggable.js-2.0.1.tgz",
+					"integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==",
 					"requires": {
 					"requires": {
 						"lodash": "^4.17.11"
 						"lodash": "^4.17.11"
 					}
 					}
 				},
 				},
 				"skeletor.js": {
 				"skeletor.js": {
-					"version": "0.0.1",
+					"version": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
 					"from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
 					"from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
 					"requires": {
 					"requires": {
 						"lodash": "^4.17.14"
 						"lodash": "^4.17.14"
@@ -2834,7 +2837,11 @@
 				},
 				},
 				"strophe.js": {
 				"strophe.js": {
 					"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
 					"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
-					"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f"
+					"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+					"requires": {
+						"abab": "^2.0.3",
+						"xmldom": "^0.1.27"
+					}
 				},
 				},
 				"twemoji": {
 				"twemoji": {
 					"version": "12.1.5",
 					"version": "12.1.5",

+ 1 - 0
src/headless/core.js

@@ -44,6 +44,7 @@ Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
 Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
 Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
 Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
 Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
 Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
 Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
+Strophe.addNamespace('RAI', 'urn:xmpp:rai:0');
 Strophe.addNamespace('REGISTER', 'jabber:iq:register');
 Strophe.addNamespace('REGISTER', 'jabber:iq:register');
 Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
 Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
 Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
 Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');

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

@@ -580,13 +580,41 @@ const ChatBox = ModelWithContact.extend({
         return _converse.connection.send(msg);
         return _converse.connection.send(msg);
     },
     },
 
 
-    sendMarkerForMessage (msg) {
-        if (msg?.get('is_markable')) {
+
+    /**
+     * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
+     * @param { Boolean } force - Whether a marker should be sent for the
+     *  message, even if it didn't include a `markable` element.
+     */
+    sendMarkerForLastMessage (force=false) {
+        const msgs = Array.from(this.messages.models);
+        msgs.reverse();
+        const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
+        msg && this.sendMarkerForMessage(msg);
+    },
+
+    /**
+     * Given the passed in message object, send a XEP-0333 chat marker.
+     * @param { _converse.Message } msg
+     * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
+     * @param { Boolean } force - Whether a marker should be sent for the
+     *  message, even if it didn't include a `markable` element.
+     */
+    sendMarkerForMessage (msg, type='displayed', force=false) {
+        if (!msg) return;
+        if (msg?.get('is_markable') || force) {
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
-            this.sendMarker(from_jid, msg.get('msgid'), 'displayed', msg.get('type'));
+            this.sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
         }
         }
     },
     },
 
 
+    /**
+     * Send out a XEP-0333 chat marker
+     * @param { String } to_jid
+     * @param { String } id - The id of the message being marked
+     * @param { String } type - The marker type
+     * @param { String } msg_type
+     */
     sendMarker (to_jid, id, type, msg_type) {
     sendMarker (to_jid, id, type, msg_type) {
         const stanza = $msg({
         const stanza = $msg({
             'from': _converse.connection.jid,
             'from': _converse.connection.jid,

+ 17 - 12
src/headless/plugins/muc/index.js

@@ -106,6 +106,17 @@ converse.ROOMSTATUS = {
 };
 };
 
 
 
 
+function registerDirectInvitationHandler () {
+    _converse.connection.addHandler(
+        message => {
+            _converse.onDirectMUCInvitation(message);
+            return true;
+        },
+        'jabber:x:conference',
+        'message'
+    );
+}
+
 function disconnectChatRooms () {
 function disconnectChatRooms () {
     /* When disconnecting, mark all groupchats as
     /* When disconnecting, mark all groupchats as
      * disconnected, so that they will be properly entered again
      * disconnected, so that they will be properly entered again
@@ -241,7 +252,8 @@ converse.plugins.add('converse-muc', {
                 ...converse.MUC_INFO_CODES.join_leave_events,
                 ...converse.MUC_INFO_CODES.join_leave_events,
                 ...converse.MUC_INFO_CODES.role_changes
                 ...converse.MUC_INFO_CODES.role_changes
             ],
             ],
-            'muc_show_logs_before_join': false
+            'muc_show_logs_before_join': false,
+            'muc_subscribe_to_rai': false,
         });
         });
         api.promises.add(['roomsAutoJoined']);
         api.promises.add(['roomsAutoJoined']);
 
 
@@ -413,22 +425,15 @@ converse.plugins.add('converse-muc', {
             }
             }
         };
         };
 
 
+        /************************ BEGIN Event Handlers ************************/
+
         if (api.settings.get('allow_muc_invitations')) {
         if (api.settings.get('allow_muc_invitations')) {
-            const registerDirectInvitationHandler = function () {
-                _converse.connection.addHandler(
-                    message => {
-                        _converse.onDirectMUCInvitation(message);
-                        return true;
-                    },
-                    'jabber:x:conference',
-                    'message'
-                );
-            };
             api.listen.on('connected', registerDirectInvitationHandler);
             api.listen.on('connected', registerDirectInvitationHandler);
             api.listen.on('reconnected', registerDirectInvitationHandler);
             api.listen.on('reconnected', registerDirectInvitationHandler);
         }
         }
 
 
-        /************************ BEGIN Event Handlers ************************/
+        api.listen.on('reconnected', () => _converse.session.save('rai_enabled_domains', ''));
+
         api.listen.on('beforeTearDown', () => {
         api.listen.on('beforeTearDown', () => {
             const groupchats = _converse.chatboxes.where({ 'type': _converse.CHATROOMS_TYPE });
             const groupchats = _converse.chatboxes.where({ 'type': _converse.CHATROOMS_TYPE });
             groupchats.forEach(muc =>
             groupchats.forEach(muc =>

+ 69 - 17
src/headless/plugins/muc/muc.js

@@ -28,6 +28,13 @@ const MUCSession = Model.extend({
 const ChatRoomMixin = {
 const ChatRoomMixin = {
     defaults () {
     defaults () {
         return {
         return {
+            'bookmarked': false,
+            'chat_state': undefined,
+            'has_activity': false, // XEP-437
+            'hidden': _converse.isUniView() && !api.settings.get('singleton'),
+            'hidden_occupants': !!api.settings.get('hide_muc_participants'),
+            'message_type': 'groupchat',
+            'name': '',
             // For group chats, we distinguish between generally unread
             // For group chats, we distinguish between generally unread
             // messages and those ones that specifically mention the
             // messages and those ones that specifically mention the
             // user.
             // user.
@@ -37,12 +44,6 @@ const ChatRoomMixin = {
             // mention the user and `num_unread_general` to indicate
             // mention the user and `num_unread_general` to indicate
             // generally unread messages (which *includes* mentions!).
             // generally unread messages (which *includes* mentions!).
             'num_unread_general': 0,
             'num_unread_general': 0,
-            'bookmarked': false,
-            'chat_state': undefined,
-            'hidden': _converse.isUniView() && !api.settings.get('singleton'),
-            'hidden_occupants': !!api.settings.get('hide_muc_participants'),
-            'message_type': 'groupchat',
-            'name': '',
             'num_unread': 0,
             'num_unread': 0,
             'roomconfig': {},
             'roomconfig': {},
             'time_opened': this.get('time_opened') || new Date().getTime(),
             'time_opened': this.get('time_opened') || new Date().getTime(),
@@ -62,6 +63,8 @@ const ChatRoomMixin = {
         this.registerHandlers();
         this.registerHandlers();
 
 
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:chat_state', this.sendChatState, this);
+        this.on('change:hidden', this.onHiddenChange, this);
+
         await this.restoreSession();
         await this.restoreSession();
         this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
         this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
 
 
@@ -104,6 +107,20 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
+    /**
+     * Handles incoming message stanzas from the service that hosts this MUC
+     * @private
+     * @method _converse.ChatRoom#onPresence
+     * @param { XMLElement } stanza
+     */
+    handleMessageFromMUCService (stanza) {
+        const rai = stanza.querySelector(`rai[xmlns="${Strophe.NS.RAI}"]`);
+        const active_mucs = Array.from(rai?.querySelectorAll('activity') || []).map(m => m.getAttribute('xmlns'));
+        if (active_mucs.includes(this.get('jid'))) {
+            this.save({ 'has_activity': true });
+        }
+    },
+
     /**
     /**
      * Join the MUC
      * Join the MUC
      * @private
      * @private
@@ -161,6 +178,28 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
+    /**
+     * Handler that gets called when the 'hidden' flag is toggled.
+     * @private
+     * @method _converse.ChatRoomView#onHiddenChange
+     */
+    async onHiddenChange () {
+        if (this.get('hidden') && api.settings.get('muc_subscribe_to_rai')) {
+            this.sendMarkerForLastMessage(true);
+            if (this.session.get('connection_status') !== converse.ROOMSTATUS.DISCONNECTED) {
+                await this.leave();
+            }
+            const rai_enabled = _converse.session.get('rai_enabled_domains') || '';
+            const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
+            if (!rai_enabled.includes(muc_domain)) {
+                api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
+                _converse.session.save({ 'rai_enabled_domains': `${rai_enabled} ${muc_domain}` });
+            }
+        } else if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
+            this.onReconnection();
+        }
+    },
+
     onOccupantAdded (occupant) {
     onOccupantAdded (occupant) {
         if (
         if (
             _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
             _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
@@ -384,9 +423,13 @@ const ChatRoomMixin = {
         return attrs && this.queueMessage(attrs);
         return attrs && this.queueMessage(attrs);
     },
     },
 
 
+    /**
+     * Register presence and message handlers relevant to this groupchat
+     * @private
+     * @method _converse.ChatRoom#registerHandlers
+     */
     registerHandlers () {
     registerHandlers () {
-        // Register presence and message handlers for this groupchat
-        const room_jid = this.get('jid');
+        const muc_jid = this.get('jid');
         this.removeHandlers();
         this.removeHandlers();
         this.presence_handler = _converse.connection.addHandler(
         this.presence_handler = _converse.connection.addHandler(
             stanza => this.onPresence(stanza) || true,
             stanza => this.onPresence(stanza) || true,
@@ -394,17 +437,27 @@ const ChatRoomMixin = {
             'presence',
             'presence',
             null,
             null,
             null,
             null,
-            room_jid,
+            muc_jid,
             { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
             { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
         );
         );
 
 
+        const muc_domain = Strophe.getDomainFromJid(muc_jid);
+        this.domain_presence_handler = _converse.connection.addHandler(
+            stanza => this.handleMessageFromMUCService(stanza) || true,
+            null,
+            'message',
+            null,
+            null,
+            muc_domain
+        );
+
         this.message_handler = _converse.connection.addHandler(
         this.message_handler = _converse.connection.addHandler(
             stanza => !!this.handleMessageStanza(stanza) || true,
             stanza => !!this.handleMessageStanza(stanza) || true,
             null,
             null,
             'message',
             'message',
             'groupchat',
             'groupchat',
             null,
             null,
-            room_jid,
+            muc_jid,
             { 'matchBareFromJid': true }
             { 'matchBareFromJid': true }
         );
         );
 
 
@@ -414,7 +467,7 @@ const ChatRoomMixin = {
             'message',
             'message',
             null,
             null,
             null,
             null,
-            room_jid
+            muc_jid
         );
         );
     },
     },
 
 
@@ -634,11 +687,9 @@ const ChatRoomMixin = {
         this.occupants.clearStore();
         this.occupants.clearStore();
         api.settings.get('muc_clear_messages_on_leave') && this.messages.clearStore();
         api.settings.get('muc_clear_messages_on_leave') && this.messages.clearStore();
 
 
-        if (_converse.disco_entities) {
-            const disco_entity = _converse.disco_entities.get(this.get('jid'));
-            if (disco_entity) {
-                await new Promise((success, error) => disco_entity.destroy({ success, error }));
-            }
+        const disco_entity = _converse.disco_entities?.get(this.get('jid'));
+        if (disco_entity) {
+            await new Promise((success, error) => disco_entity.destroy({ success, error }));
         }
         }
         if (api.connection.connected()) {
         if (api.connection.connected()) {
             api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
             api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
@@ -2071,7 +2122,7 @@ const ChatRoomMixin = {
     },
     },
 
 
     /**
     /**
-     * Handles all MUC presence stanzas.
+     * Handles incoming presence stanzas coming from the MUC
      * @private
      * @private
      * @method _converse.ChatRoom#onPresence
      * @method _converse.ChatRoom#onPresence
      * @param { XMLElement } stanza
      * @param { XMLElement } stanza
@@ -2216,6 +2267,7 @@ const ChatRoomMixin = {
             this.sendMarkerForMessage(this.messages.last());
             this.sendMarkerForMessage(this.messages.last());
         }
         }
         u.safeSave(this, {
         u.safeSave(this, {
+            'has_activity': false,
             'num_unread': 0,
             'num_unread': 0,
             'num_unread_general': 0
             'num_unread_general': 0
         });
         });

+ 2 - 1
src/plugins/muc-views/muc.js

@@ -11,6 +11,7 @@ import tpl_chatroom_head from 'templates/chatroom_head.js';
 import tpl_muc_bottom_panel from 'templates/muc_bottom_panel.js';
 import tpl_muc_bottom_panel from 'templates/muc_bottom_panel.js';
 import tpl_muc_destroyed from 'templates/muc_destroyed.js';
 import tpl_muc_destroyed from 'templates/muc_destroyed.js';
 import tpl_muc_disconnect from 'templates/muc_disconnect.js';
 import tpl_muc_disconnect from 'templates/muc_disconnect.js';
+import { $build, $pres, Strophe } from 'strophe.js/src/strophe';
 import tpl_muc_nickname_form from 'templates/muc_nickname_form.js';
 import tpl_muc_nickname_form from 'templates/muc_nickname_form.js';
 import tpl_spinner from 'templates/spinner.js';
 import tpl_spinner from 'templates/spinner.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';
@@ -19,7 +20,7 @@ import { _converse, api, converse } from '@converse/headless/core';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
 import { render } from 'lit-html';
 import { render } from 'lit-html';
 
 
-const { Strophe, sizzle, $pres } = converse.env;
+const { sizzle } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 const OWNER_COMMANDS = ['owner'];
 const OWNER_COMMANDS = ['owner'];

+ 1 - 0
webpack.html

@@ -20,6 +20,7 @@
         }
         }
     });
     });
     converse.initialize({
     converse.initialize({
+        muc_subscribe_to_rai: true,
         theme: 'concord',
         theme: 'concord',
         show_send_button: true,
         show_send_button: true,
         auto_away: 300,
         auto_away: 300,