Explorar o código

More RAI improvements

- Add test for incoming RAI message
- Only enable RAI if the user is affilated in MUC being left
- Handle error presence indicating a resouce-constraint
- Don't unregister stanza handlers in `leave`, since we still want to
  listen to RAI-related stanzas. Instead unregister upon the `destroy`
  event.
JC Brand %!s(int64=4) %!d(string=hai) anos
pai
achega
2121766623

+ 1 - 0
CHANGES.md

@@ -4,6 +4,7 @@
 
 
 - #1083: Add support for XEP-0393 Message Styling
 - #1083: Add support for XEP-0393 Message Styling
 - #2275: Allow punctuation to immediately precede a mention
 - #2275: Allow punctuation to immediately precede a mention
+- Add support for XEP-0437 Room Activity Indicators see [muc-subscribe-to-rai](https://conversejs.org/docs/html/configuration.html#muc-subscribe-to-rai)
 - Bugfix: Connection protocol not updated based on XEP-0156 connection methods
 - Bugfix: Connection protocol not updated based on XEP-0156 connection methods
 - Bugfix: `null` inserted by emoji picker and can't switch between skintones
 - Bugfix: `null` inserted by emoji picker and can't switch between skintones
 - New hook: [getMessageActionButtons](https://conversejs.org/docs/html/api/-_converse.html#event:getMessageActionButtons)
 - New hook: [getMessageActionButtons](https://conversejs.org/docs/html/api/-_converse.html#event:getMessageActionButtons)

+ 6 - 5
docs/source/configuration.rst

@@ -1444,7 +1444,7 @@ muc_show_logs_before_join
 
 
 If set to ``true``, when opening a MUC for the first time (or if you don't have
 If set to ``true``, when opening a MUC for the first time (or if you don't have
 a nickname configured for it), you'll see the message history (if the
 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
 muc_subscribe_to_rai
@@ -1452,11 +1452,12 @@ muc_subscribe_to_rai
 
 
 * Default: ``false``
 * Default: ``false``
 
 
-This option enables support for XEP-0437 Room Activity Indicators.
+This option enables support for `XEP-0437 Room Activity Indicators <https://xmpp.org/extensions/xep-0313.html>`_.
 
 
-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.
+When a MUC is no longer visible (specifically, when the ``hidden`` flag becomes ``true``),
+then Converse will exit the MUC and subscribe to activity indicators on the MUC host.
+
+When the MUC becomes visible again (``hidden`` gets set to ``false``), the MUC will be rejoined.
 
 
 
 
 .. _`nickname`:
 .. _`nickname`:

+ 9 - 6
spec/muc.js

@@ -2367,10 +2367,11 @@ describe("Groupchats", function () {
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 async function (done, _converse) {
                 async function (done, _converse) {
 
 
+            const nick = "some1";
             const IQ_stanzas = _converse.connection.IQ_stanzas;
             const IQ_stanzas = _converse.connection.IQ_stanzas;
             const muc_jid = 'coven@chat.shakespeare.lit';
             const muc_jid = 'coven@chat.shakespeare.lit';
 
 
-            await _converse.api.rooms.open(muc_jid, {'nick': 'some1'});
+            await _converse.api.rooms.open(muc_jid, { nick });
             const stanza = await u.waitUntil(() => _.filter(
             const stanza = await u.waitUntil(() => _.filter(
                 IQ_stanzas,
                 IQ_stanzas,
                 iq => iq.querySelector(
                 iq => iq.querySelector(
@@ -2423,7 +2424,9 @@ describe("Groupchats", function () {
                     .c('feature', {'var': 'muc_nonanonymous'});
                     .c('feature', {'var': 'muc_nonanonymous'});
             _converse.connection._dataRecv(mock.createRequest(features_stanza));
             _converse.connection._dataRecv(mock.createRequest(features_stanza));
             let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
             let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
             view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
             view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
             expect(view.model.features.get('fetched')).toBeTruthy();
             expect(view.model.features.get('fetched')).toBeTruthy();
             expect(view.model.features.get('passwordprotected')).toBe(true);
             expect(view.model.features.get('passwordprotected')).toBe(true);
@@ -4412,8 +4415,8 @@ describe("Groupchats", function () {
             spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
             spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
             const sent_IQs = _converse.connection.IQ_stanzas;
             const sent_IQs = _converse.connection.IQ_stanzas;
             const muc_jid = 'coven@chat.shakespeare.lit';
             const muc_jid = 'coven@chat.shakespeare.lit';
-
-            const room_creation_promise = _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
+            const nick = 'romeo';
+            const room_creation_promise = _converse.api.rooms.open(muc_jid, {nick});
 
 
             // Check that the groupchat queried for the features.
             // Check that the groupchat queried for the features.
             let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
             let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
@@ -4441,11 +4444,11 @@ describe("Groupchats", function () {
                     .c('feature', {'var': 'muc_temporary'}).up()
                     .c('feature', {'var': 'muc_temporary'}).up()
                     .c('feature', {'var': 'muc_membersonly'}).up();
                     .c('feature', {'var': 'muc_membersonly'}).up();
             _converse.connection._dataRecv(mock.createRequest(features_stanza));
             _converse.connection._dataRecv(mock.createRequest(features_stanza));
-            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
             expect(view.model.features.get('membersonly')).toBeTruthy();
             expect(view.model.features.get('membersonly')).toBeTruthy();
 
 
             await room_creation_promise;
             await room_creation_promise;
-
             await mock.createContacts(_converse, 'current');
             await mock.createContacts(_converse, 'current');
 
 
             let sent_stanza, sent_id;
             let sent_stanza, sent_id;

+ 76 - 5
spec/rai.js

@@ -5,11 +5,14 @@ const u = converse.env.utils;
 // See: https://xmpp.org/rfcs/rfc3921.html
 // See: https://xmpp.org/rfcs/rfc3921.html
 
 
 
 
-fdescribe("XEP-0437 Room Activity Indicators", function () {
+describe("XEP-0437 Room Activity Indicators", function () {
 
 
     it("will be activated for a MUC that becomes hidden",
     it("will be activated for a MUC that becomes hidden",
         mock.initConverse(
         mock.initConverse(
-            ['rosterGroupsFetched'], {'muc_subscribe_to_rai': true, 'view_mode': 'fullscreen'},
+            ['rosterGroupsFetched'], {
+                'allow_bookmarks': false, // Hack to get the rooms list to render
+                'muc_subscribe_to_rai': true,
+                'view_mode': 'fullscreen'},
             async function (done, _converse) {
             async function (done, _converse) {
 
 
         expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
         expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
@@ -79,18 +82,86 @@ fdescribe("XEP-0437 Room Activity Indicators", function () {
         expect(Strophe.serialize(sent_stanzas[1])).toBe(
         expect(Strophe.serialize(sent_stanzas[1])).toBe(
             `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
             `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
             `</presence>`
         );
         );
         expect(Strophe.serialize(sent_stanzas[2])).toBe(
         expect(Strophe.serialize(sent_stanzas[2])).toBe(
             `<presence to="montague.lit" xmlns="jabber:client">`+
             `<presence to="montague.lit" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `<rai xmlns="urn:xmpp:rai:0"/>`+
                 `<rai xmlns="urn:xmpp:rai:0"/>`+
             `</presence>`
             `</presence>`
         );
         );
 
 
-        view.model.save({'hidden': false});
+        await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
+        expect(view.model.get('has_activity')).toBe(false);
+
+        const lview = _converse.rooms_list_view
+        const room_el = await u.waitUntil(() => lview.el.querySelector(".available-chatroom"));
+        expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
+
+        const activity_stanza = u.toStanza(`
+            <message from="${Strophe.getDomainFromJid(muc_jid)}">
+                <rai xmlns="urn:xmpp:rai:0">
+                    <activity>${muc_jid}</activity>
+                </rai>
+            </message>
+        `);
+        _converse.connection._dataRecv(mock.createRequest(activity_stanza));
+
+        await u.waitUntil(() => view.model.get('has_activity'));
+        expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
+        done();
+    }));
+
+
+    it("may not be activated due to server resource constraints",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {
+                'allow_bookmarks': false, // Hack to get the rooms list to render
+                'muc_subscribe_to_rai': true,
+                'view_mode': 'fullscreen'},
+            async function (done, _converse) {
+
+        expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
+
+        const muc_jid = 'lounge@montague.lit';
+        const muc_domain = Strophe.getDomainFromJid(muc_jid);
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        expect(view.model.get('hidden')).toBe(false);
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+        view.model.save({'hidden': true});
+        await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'presence').length === 2);
+
+        expect(Strophe.serialize(sent_stanzas[0])).toBe(
+            `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
+                `<priority>0</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+            `</presence>`
+        );
+        expect(Strophe.serialize(sent_stanzas[1])).toBe(
+            `<presence to="montague.lit" xmlns="jabber:client">`+
+                `<priority>0</priority>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<rai xmlns="urn:xmpp:rai:0"/>`+
+            `</presence>`
+        );
+        expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.DISCONNECTED);
+                expect(_converse.session.get('rai_enabled_domains')).toBe(` ${muc_domain}`);
+
+        // If an error presence with "resource-constraint" is returned, we rejoin
+        const activity_stanza = u.toStanza(`
+            <presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}">
+                <error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+            </presence>
+        `);
+        _converse.connection._dataRecv(mock.createRequest(activity_stanza));
+
+        await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
+
+        expect(_converse.session.get('rai_enabled_domains')).toBe(' ');
         done();
         done();
     }));
     }));
 
 

+ 84 - 30
src/headless/plugins/muc/muc.js

@@ -66,6 +66,7 @@ const ChatRoomMixin = {
 
 
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:hidden', this.onHiddenChange, this);
         this.on('change:hidden', this.onHiddenChange, this);
+        this.on('destroy', this.removeHandlers, 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);
@@ -109,20 +110,6 @@ 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
@@ -138,6 +125,8 @@ const ChatRoomMixin = {
             // so we don't send out a presence stanza again.
             // so we don't send out a presence stanza again.
             return this;
             return this;
         }
         }
+        // Set this early, so we don't rejoin in onHiddenChange
+        this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
         await this.refreshDiscoInfo();
         await this.refreshDiscoInfo();
         nick = await this.getAndPersistNickname(nick);
         nick = await this.getAndPersistNickname(nick);
         if (!nick) {
         if (!nick) {
@@ -161,7 +150,6 @@ const ChatRoomMixin = {
         if (password) {
         if (password) {
             stanza.cnode(Strophe.xmlElement('password', [], password));
             stanza.cnode(Strophe.xmlElement('password', [], password));
         }
         }
-        this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
         api.send(stanza);
         api.send(stanza);
         return this;
         return this;
     },
     },
@@ -200,13 +188,8 @@ 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')) {
+    async enableRAI () {
+        if (api.settings.get('muc_subscribe_to_rai') && this.getOwnAffiliation() !== 'none') {
             this.sendMarkerForLastMessage('received', true);
             this.sendMarkerForLastMessage('received', true);
             if (this.session.get('connection_status') !== converse.ROOMSTATUS.DISCONNECTED) {
             if (this.session.get('connection_status') !== converse.ROOMSTATUS.DISCONNECTED) {
                 await this.leave();
                 await this.leave();
@@ -217,8 +200,19 @@ const ChatRoomMixin = {
                 api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
                 api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
                 _converse.session.save({ 'rai_enabled_domains': `${rai_enabled} ${muc_domain}` });
                 _converse.session.save({ 'rai_enabled_domains': `${rai_enabled} ${muc_domain}` });
             }
             }
+        }
+    },
+
+    /**
+     * Handler that gets called when the 'hidden' flag is toggled.
+     * @private
+     * @method _converse.ChatRoomView#onHiddenChange
+     */
+    onHiddenChange () {
+        if (this.get('hidden')) {
+            this.enableRAI();
         } else if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
         } else if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
-            this.onReconnection();
+            this.rejoin();
         }
         }
     },
     },
 
 
@@ -259,6 +253,7 @@ const ChatRoomMixin = {
      * @method _converse.ChatRoom#rejoin
      * @method _converse.ChatRoom#rejoin
      */
      */
     rejoin () {
     rejoin () {
+        this.registerHandlers();
         this.clearCache();
         this.clearCache();
         return this.join();
         return this.join();
     },
     },
@@ -285,7 +280,6 @@ const ChatRoomMixin = {
     },
     },
 
 
     async onReconnection () {
     async onReconnection () {
-        this.registerHandlers();
         await this.rejoin();
         await this.rejoin();
         this.announceReconnection();
         this.announceReconnection();
     },
     },
@@ -409,6 +403,24 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
+
+    /**
+     * Handles incoming message stanzas from the service that hosts this MUC
+     * @private
+     * @method _converse.ChatRoom#handleMessageFromMUCHost
+     * @param { XMLElement } stanza
+     */
+    handleMessageFromMUCHost (stanza) {
+        const rai = sizzle(`rai[xmlns="${Strophe.NS.RAI}"]`, stanza).pop();
+        const active_mucs = Array.from(rai?.querySelectorAll('activity') || []).map(m => m.textContent);
+        if (active_mucs.includes(this.get('jid'))) {
+            this.save({
+                'has_activity': true,
+                'num_unread_general': 0 // Either/or between activity and unreads
+            });
+        }
+    },
+
     /**
     /**
      * Parses an incoming message stanza and queues it for processing.
      * Parses an incoming message stanza and queues it for processing.
      * @private
      * @private
@@ -452,6 +464,7 @@ const ChatRoomMixin = {
      */
      */
     registerHandlers () {
     registerHandlers () {
         const muc_jid = this.get('jid');
         const muc_jid = this.get('jid');
+        const muc_domain = Strophe.getDomainFromJid(muc_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,
@@ -463,11 +476,10 @@ const ChatRoomMixin = {
             { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
             { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
         );
         );
 
 
-        const muc_domain = Strophe.getDomainFromJid(muc_jid);
         this.domain_presence_handler = _converse.connection.addHandler(
         this.domain_presence_handler = _converse.connection.addHandler(
-            stanza => this.handleMessageFromMUCService(stanza) || true,
+            stanza => this.onPresenceFromMUCHost(stanza) || true,
             null,
             null,
-            'message',
+            'presence',
             null,
             null,
             null,
             null,
             muc_domain
             muc_domain
@@ -483,6 +495,15 @@ const ChatRoomMixin = {
             { 'matchBareFromJid': true }
             { 'matchBareFromJid': true }
         );
         );
 
 
+        this.domain_message_handler = _converse.connection.addHandler(
+            stanza => this.handleMessageFromMUCHost(stanza) || true,
+            null,
+            'message',
+            null,
+            null,
+            muc_domain
+        );
+
         this.affiliation_message_handler = _converse.connection.addHandler(
         this.affiliation_message_handler = _converse.connection.addHandler(
             stanza => this.handleAffiliationChangedMessage(stanza) || true,
             stanza => this.handleAffiliationChangedMessage(stanza) || true,
             Strophe.NS.MUC_USER,
             Strophe.NS.MUC_USER,
@@ -500,10 +521,18 @@ const ChatRoomMixin = {
             _converse.connection && _converse.connection.deleteHandler(this.message_handler);
             _converse.connection && _converse.connection.deleteHandler(this.message_handler);
             delete this.message_handler;
             delete this.message_handler;
         }
         }
+        if (this.domain_message_handler) {
+            _converse.connection && _converse.connection.deleteHandler(this.domain_message_handler);
+            delete this.domain_message_handler;
+        }
         if (this.presence_handler) {
         if (this.presence_handler) {
             _converse.connection && _converse.connection.deleteHandler(this.presence_handler);
             _converse.connection && _converse.connection.deleteHandler(this.presence_handler);
             delete this.presence_handler;
             delete this.presence_handler;
         }
         }
+        if (this.domain_presence_handler) {
+            _converse.connection && _converse.connection.deleteHandler(this.domain_presence_handler);
+            delete this.domain_presence_handler;
+        }
         if (this.affiliation_message_handler) {
         if (this.affiliation_message_handler) {
             _converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
             _converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
             delete this.affiliation_message_handler;
             delete this.affiliation_message_handler;
@@ -717,7 +746,6 @@ const ChatRoomMixin = {
             api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
             api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
         }
         }
         u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
         u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
-        this.removeHandlers();
     },
     },
 
 
     async close () {
     async close () {
@@ -1131,7 +1159,7 @@ const ChatRoomMixin = {
      * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
      * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
      */
      */
     getOwnAffiliation () {
     getOwnAffiliation () {
-        return this.getOwnOccupant()?.attributes?.affiliation;
+        return this.getOwnOccupant()?.attributes?.affiliation || 'none';
     },
     },
 
 
     /**
     /**
@@ -2142,6 +2170,32 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
+    /**
+     * Listens for incoming presence stanzas from the service that hosts this MUC
+     * @private
+     * @method _converse.ChatRoom#onPresenceFromMUCHost
+     * @param { XMLElement } stanza - The presence stanza
+     */
+    onPresenceFromMUCHost (stanza) {
+        if (stanza.getAttribute('type') === 'error') {
+            const error = stanza.querySelector('error');
+            if (error?.getAttribute('type') === 'wait' && error?.querySelector('resource-constraint')) {
+                // If we get a <resource-constraint> error, we assume it's in context of XEP-0437 RAI.
+                // We remove this MUC's host from the list of enabled domains and rejoin the MUC.
+                const rai_enabled = _converse.session.get('rai_enabled_domains') || '';
+                const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
+                if (rai_enabled.includes(muc_domain)) {
+                    const regex = new RegExp(muc_domain, 'g');
+                    _converse.session.save({ 'rai_enabled_domains': rai_enabled.replace(regex, '') });
+
+                    if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
+                        this.rejoin();
+                    }
+                }
+            }
+        }
+    },
+
     /**
     /**
      * Handles incoming presence stanzas coming from the MUC
      * Handles incoming presence stanzas coming from the MUC
      * @private
      * @private
@@ -2284,7 +2338,7 @@ const ChatRoomMixin = {
     },
     },
 
 
     clearUnreadMsgCounter () {
     clearUnreadMsgCounter () {
-        if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0) {
+        if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0 || this.get('has_activity')) {
             this.sendMarkerForMessage(this.messages.last());
             this.sendMarkerForMessage(this.messages.last());
         }
         }
         u.safeSave(this, {
         u.safeSave(this, {

+ 2 - 5
src/plugins/chatview/view.js

@@ -1030,11 +1030,8 @@ const ChatBoxView = View.extend({
 
 
     onWindowStateChanged (state) {
     onWindowStateChanged (state) {
         if (state === 'visible') {
         if (state === 'visible') {
-            if (!this.model.isHidden()) {
-                // this.model.setChatState(_converse.ACTIVE);
-                if (this.model.get('num_unread', 0)) {
-                    this.model.clearUnreadMsgCounter();
-                }
+            if (!this.model.isHidden() && this.model.get('num_unread', 0)) {
+                this.model.clearUnreadMsgCounter();
             }
             }
         } else if (state === 'hidden') {
         } else if (state === 'hidden') {
             this.model.setChatState(_converse.INACTIVE, { 'silent': true });
             this.model.setChatState(_converse.INACTIVE, { 'silent': true });

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

@@ -11,7 +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 { $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';
@@ -694,9 +694,7 @@ const ChatRoomViewMixin = {
         // Override from converse-chatview, specifically to avoid
         // Override from converse-chatview, specifically to avoid
         // the 'active' chat state from being sent out prematurely.
         // the 'active' chat state from being sent out prematurely.
         // This is instead done in `onConnectionStatusChanged` below.
         // This is instead done in `onConnectionStatusChanged` below.
-        if (u.isPersistableModel(this.model)) {
-            this.model.clearUnreadMsgCounter();
-        }
+        this.model.clearUnreadMsgCounter();
         this.scrollDown();
         this.scrollDown();
     },
     },
 
 

+ 18 - 13
src/plugins/roomslist/templates/roomslist.js

@@ -23,31 +23,36 @@ const bookmark = (o) => {
 }
 }
 
 
 
 
+const unread_indicator = (o) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ o.room.get('num_unread') }</span>`;
+const activity_indicator = () => html`<span class="list-item-badge badge badge--muc msgs-indicator"></span>`;
+
+
 const room_item = (o) => {
 const room_item = (o) => {
     const i18n_leave_room = __('Leave this groupchat');
     const i18n_leave_room = __('Leave this groupchat');
-    const unread_indicator = (o) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ o.room.get('num_unread') }</span>`;
+    const has_unread_msgs = o.room.get('num_unread_general') || o.room.get('has_activity');
     return html`
     return html`
-        <div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ o.currently_open(o.room) ? 'open' : '' } ${ o.room.get('num_unread_general') ? 'unread-msgs' : '' }"
+        <div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ o.currently_open(o.room) ? 'open' : '' } ${ has_unread_msgs ? 'unread-msgs' : '' }"
             data-room-jid="${o.room.get('jid')}">
             data-room-jid="${o.room.get('jid')}">
 
 
-            ${ o.room.get('num_unread') ? unread_indicator(o) : '' }
+            ${ o.room.get('num_unread') ? unread_indicator(o) : (o.room.get('has_activity') ? activity_indicator(o) : '') }
+
             <a class="list-item-link open-room available-room w-100"
             <a class="list-item-link open-room available-room w-100"
-            data-room-jid="${o.room.get('jid')}"
-            title="${__('Click to open this groupchat')}"
-            @click=${o.openRoom}>${o.room.getDisplayName()}</a>
+                data-room-jid="${o.room.get('jid')}"
+                title="${__('Click to open this groupchat')}"
+                @click=${o.openRoom}>${o.room.getDisplayName()}</a>
 
 
             ${ o.allow_bookmarks ? bookmark(o) : '' }
             ${ o.allow_bookmarks ? bookmark(o) : '' }
 
 
             <a class="list-item-action room-info fa fa-info-circle"
             <a class="list-item-action room-info fa fa-info-circle"
-            data-room-jid="${o.room.get('jid')}"
-            title="${__('Show more information on this groupchat')}"
-            @click=${o.showRoomDetailsModal}></a>
+                data-room-jid="${o.room.get('jid')}"
+                title="${__('Show more information on this groupchat')}"
+                @click=${o.showRoomDetailsModal}></a>
 
 
             <a class="list-item-action fa fa-sign-out-alt close-room"
             <a class="list-item-action fa fa-sign-out-alt close-room"
-            data-room-jid="${o.room.get('jid')}"
-            data-room-name="${o.room.getDisplayName()}"
-            title="${i18n_leave_room}"
-            @click=${o.closeRoom}></a>
+                data-room-jid="${o.room.get('jid')}"
+                data-room-name="${o.room.getDisplayName()}"
+                title="${i18n_leave_room}"
+                @click=${o.closeRoom}></a>
         </div>`;
         </div>`;
 }
 }
 
 

+ 1 - 1
src/plugins/roomslist/view.js

@@ -30,7 +30,7 @@ const RoomsListView = View.extend({
     },
     },
 
 
     renderIfRelevantChange (model) {
     renderIfRelevantChange (model) {
-        const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general'];
+        const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity'];
         const changed = model.changed || {};
         const changed = model.changed || {};
         if (u.isChatRoom(model) && Object.keys(changed).filter(m => attrs.includes(m)).length) {
         if (u.isChatRoom(model) && Object.keys(changed).filter(m => attrs.includes(m)).length) {
             this.render();
             this.render();