瀏覽代碼

Register a XEP-0316 MEP handler

Add caps element to the MUC join presence, so that the MUC MEP node can
know whether we're interested in receiving MEP messages.

Create info messages for any `conference-info` tags that contain `activity` tags.

Check for both `headline` and `normal` MEP messages (even though the XEP
only show `headline` examples), since `normal` messages can be archived
in MAM, but `headline` ones not.

Update the XEP-0372 reference-parsing code to take the `anchor`
attribute into consideration, specifically to check which text element
the reference applies to.

Add support for rendering XEP-0372 mentions in "info" messages and for
triggering HTML5 Desktop notifications for such mentions.

Background:
-----------

XEP-0316 describes a way for a MUC to send out PEP-like messages to MUC
participants. This feature can be used to describe custom activity happening
in the MUC.
JC Brand 3 年之前
父節點
當前提交
90ea092e4d

+ 1 - 0
CHANGES.md

@@ -26,6 +26,7 @@
 - Show a gap placeholder when there are gaps in the chat history. The user can click these to fill the gaps.
 - Use the MUC stanza id when sending XEP-0333 markers
 - Add support for pausing Gif images
+- Add limited support for XEP-0316 MUC notifications
 
 ### New configuration setings
 

+ 2 - 0
README.md

@@ -94,6 +94,7 @@ In embedded mode, Converse can be embedded into an element in the DOM.
 - [XEP-0297](https://xmpp.org/extensions/xep-0297.html) Stanza Forwarding (limited support)
 - [XEP-0308](https://xmpp.org/extensions/xep-0308.html) Last Message Correction
 - [XEP-0313](https://xmpp.org/extensions/xep-0313.html) Message Archive Management
+- [XEP-0316](https://xmpp.org/extensions/xep-0316.html) MUC Eventing protocol (limited support)
 - [XEP-0317](https://xmpp.org/extensions/xep-0317.html) Hats (limited support)
 - [XEP-0333](https://xmpp.org/extensions/xep-0333.html) Chat Markers (limited support)
 - [XEP-0352](https://xmpp.org/extensions/xep-0352.html) Client State Indication
@@ -108,6 +109,7 @@ In embedded mode, Converse can be embedded into an element in the DOM.
 - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions
 - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation
 - [XEP-0437](https://xmpp.org/extensions/xep-0437.html) Room Activity Indicators
+- [XEP-0453](https://xmpp.org/extensions/xep-0453.html) DOAP Usage in XMPP
 - [XEP-0454](https://xmpp.org/extensions/xep-0454.html) OMEMO Media sharing
 
 ## Integration into other servers and frameworks

+ 6 - 0
conversejs.doap

@@ -261,5 +261,11 @@
         <xmpp:since>8.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/>
+        <xmpp:since>8.0.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
   </Project>
 </rdf:RDF>

+ 1 - 0
karma.conf.js

@@ -63,6 +63,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/mep.js", type: 'module' },
       { 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/emojis.js", type: 'module' },

+ 1 - 0
src/headless/core.js

@@ -47,6 +47,7 @@ import {
 dayjs.extend(advancedFormat);
 
 // Add Strophe Namespaces
+Strophe.addNamespace('ACTIVITY', 'http://jabber.org/protocol/activity');
 Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2');
 Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
 Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');

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

@@ -16,5 +16,6 @@ converse.plugins.add('converse-caps', {
 
     initialize () {
         api.listen.on('constructedPresence', (_, p) => (p.root().cnode(createCapsNode()).up() && p));
+        api.listen.on('constructedMUCPresence', (_, p) => (p.root().cnode(createCapsNode()).up() && p));
     }
 });

+ 3 - 3
src/headless/plugins/caps/tests/caps.js

@@ -36,7 +36,7 @@ describe("A sent presence stanza", function () {
             `<presence xmlns="jabber:client">`+
                 `<status>Hello world</status>`+
                 `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
 
@@ -47,7 +47,7 @@ describe("A sent presence stanza", function () {
                 `<show>away</show>`+
                 `<status>Going jogging</status>`+
                 `<priority>2</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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
 
@@ -58,7 +58,7 @@ describe("A sent presence stanza", function () {
                 `<show>dnd</show>`+
                 `<status>Doing taxes</status>`+
                 `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
     }));

+ 6 - 0
src/headless/plugins/muc/index.js

@@ -26,6 +26,7 @@ import {
     onStatusInitialized,
     onWindowStateChanged,
     registerDirectInvitationHandler,
+    registerPEPPushHandler,
     routeToRoom,
 } from './utils.js';
 import { computeAffiliationsDelta } from './affiliations/utils.js';
@@ -277,14 +278,19 @@ converse.plugins.add('converse-muc', {
 
 
         /************************ BEGIN Event Handlers ************************/
+
         if (api.settings.get('allow_muc_invitations')) {
             api.listen.on('connected', registerDirectInvitationHandler);
             api.listen.on('reconnected', registerDirectInvitationHandler);
         }
+
+        api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.CONFINFO}+notify`));
         api.listen.on('addClientFeatures', onAddClientFeatures);
         api.listen.on('beforeResourceBinding', onBeforeResourceBinding);
         api.listen.on('beforeTearDown', onBeforeTearDown);
         api.listen.on('chatBoxesFetched', autoJoinRooms);
+        api.listen.on('connected', registerPEPPushHandler);
+        api.listen.on('reconnected', registerPEPPushHandler);
         api.listen.on('disconnected', disconnectChatRooms);
         api.listen.on('statusInitialized', onStatusInitialized);
         api.listen.on('windowStateChanged', onWindowStateChanged);

+ 18 - 16
src/headless/plugins/muc/muc.js

@@ -158,7 +158,6 @@ const ChatRoomMixin = {
             // so we don't send out a presence stanza again.
             return this;
         }
-
         // Set this early, so we don't rejoin in onHiddenChange
         this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
         await this.refreshDiscoInfo();
@@ -170,21 +169,7 @@ const ChatRoomMixin = {
             }
             return this;
         }
-        const stanza = $pres({
-            'from': _converse.connection.jid,
-            'to': this.getRoomJIDAndNick()
-        })
-            .c('x', { 'xmlns': Strophe.NS.MUC })
-            .c('history', {
-                'maxstanzas': this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas')
-            })
-            .up();
-
-        password = password || this.get('password');
-        if (password) {
-            stanza.cnode(Strophe.xmlElement('password', [], password));
-        }
-        api.send(stanza);
+        api.send(await this.constructPresence(password));
         return this;
     },
 
@@ -200,6 +185,23 @@ const ChatRoomMixin = {
         return this.join();
     },
 
+    async constructPresence (password) {
+        let stanza = $pres({
+            'from': _converse.connection.jid,
+            'to': this.getRoomJIDAndNick()
+        }).c('x', { 'xmlns': Strophe.NS.MUC })
+          .c('history', {
+                'maxstanzas': this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas')
+            }).up();
+
+        password = password || this.get('password');
+        if (password) {
+            stanza.cnode(Strophe.xmlElement('password', [], password));
+        }
+        stanza = await api.hook('constructedMUCPresence', null, stanza);
+        return stanza;
+    },
+
     clearOccupantsCache () {
         if (this.occupants.length) {
             // Remove non-members when reconnecting

+ 26 - 0
src/headless/plugins/muc/parsers.js

@@ -25,6 +25,32 @@ import { api, converse } from '@converse/headless/core';
 const { Strophe, sizzle, u } = converse.env;
 const { NS } = Strophe;
 
+/**
+ * Parses a message stanza for XEP-0317 MEP notification data
+ * @param { XMLElement } stanza - The message stanza
+ * @returns { Array } Returns an array of objects representing <activity> elements.
+ */
+export function getMEPActivities (stanza) {
+    const items_el = sizzle(`items[node="${Strophe.NS.CONFINFO}"]`, stanza).pop();
+    if (!items_el) {
+        return [];
+    }
+    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(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': 'info' };
+        }
+        return {};
+    });
+}
+
 /**
  * @private
  * @param { XMLElement } stanza - The message stanza

+ 56 - 1
src/headless/plugins/muc/tests/muc.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { u } = converse.env;
+const { Strophe, sizzle, u } = converse.env;
 
 describe("Groupchats", function () {
 
@@ -36,4 +36,59 @@ describe("Groupchats", function () {
         expect(model.get('num_unread_general')).toBe(0);
         expect(model.get('num_unread')).toBe(0);
     }));
+
+    describe("An groupchat", function () {
+
+        it("reconnects when no-acceptable error is returned when sending a message",
+                mock.initConverse([], {}, async function (_converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const model = _converse.chatboxes.get(muc_jid);
+            expect(model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
+            model.sendMessage({'body': 'hello world'});
+
+            const stanza = u.toStanza(`
+                <message xmlns='jabber:client'
+                         from='${muc_jid}'
+                         type='error'
+                         to='${_converse.bare_jid}'>
+                    <error type='cancel'>
+                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                    </error>
+                </message>`);
+            _converse.connection._dataRecv(mock.createRequest(stanza));
+
+            let sent_stanzas = _converse.connection.sent_stanzas;
+            const iq = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.PING}"]`, s).length).pop());
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit/romeo" type="get" xmlns="jabber:client">`+
+                    `<ping xmlns="urn:xmpp:ping"/>`+
+                `</iq>`);
+
+            const result = u.toStanza(`
+                <iq from='${muc_jid}'
+                    id='${iq.getAttribute('id')}'
+                    to='${_converse.bare_jid}'
+                    type='error'>
+                <error type='cancel'>
+                    <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                </error>
+                </iq>`);
+            sent_stanzas = _converse.connection.sent_stanzas;
+            const index = sent_stanzas.length -1;
+
+            _converse.connection.IQ_stanzas = [];
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await mock.getRoomFeatures(_converse, muc_jid);
+
+            const pres = await u.waitUntil(
+                () => sent_stanzas.slice(index).filter(s => s.nodeName === 'presence').pop());
+            expect(Strophe.serialize(pres)).toBe(
+                `<presence from="${_converse.jid}" to="coven@chat.shakespeare.lit/romeo" xmlns="jabber:client">`+
+                    `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `</presence>`);
+        }));
+    });
 });

+ 49 - 0
src/headless/plugins/muc/utils.js

@@ -2,6 +2,7 @@ import isObject from 'lodash-es/isObject';
 import log from "@converse/headless/log.js";
 import { ROLES } from '@converse/headless/plugins/muc/index.js';
 import { _converse, api, converse } from '@converse/headless/core.js';
+import { getMEPActivities } from '@converse/headless/plugins/muc/parsers.js';
 import { safeSave } from '@converse/headless/utils/core.js';
 
 const { Strophe, sizzle, u } = converse.env;
@@ -179,6 +180,54 @@ export async function autoJoinRooms () {
 }
 
 
+/**
+ * Given a stanza, look for XEP-0316 Room Notifications and create info
+ * messages for them.
+ * @param { XMLElement } stanza
+ */
+async function handleMEPNotification (stanza) {
+    const msgid = stanza.getAttribute('id');
+    const from = stanza.getAttribute('from');
+    const room = await api.rooms.get(from);
+    if (!room) {
+        log.warn(`Received a MEP message for a non-existent room: ${from}`);
+        return;
+    }
+    if (room.messages.findWhere({ msgid })) {
+        // We already handled this stanza before
+        return;
+    }
+    getMEPActivities(stanza, room).forEach(attrs => {
+        room.createMessage(attrs);
+        api.trigger('message', { stanza, attrs, 'chatbox': room });
+    });
+}
+
+
+function checkIfMEP (message) {
+    try {
+        if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) {
+            handleMEPNotification(message);
+        }
+    } catch (e) {
+        log.error(e.message);
+    }
+    return true;
+}
+
+
+export function registerPEPPushHandler () {
+    // Add a handler for devices pushed from other connected clients
+    _converse.connection.addHandler(checkIfMEP, null, 'message', 'headline');
+
+    // XXX: This is a hack. Prosody's MUC MAM doesn't allow for quering
+    // non-groupchat messages. So even though they might be archived, they
+    // don't get returned on query. To bypass this, some MEP messages are sent
+    // with type="groupchat".
+    // https://hg.prosody.im/prosody-modules/rev/da9469e68dee
+    _converse.connection.addHandler(checkIfMEP, null, 'message', 'groupchat');
+}
+
 export function onAddClientFeatures () {
     if (api.settings.get('allow_muc')) {
         api.disco.own.features.add(Strophe.NS.MUC);

+ 7 - 2
src/headless/shared/parsers.js

@@ -243,8 +243,13 @@ export function getErrorAttributes (stanza) {
 }
 
 export function getReferences (stanza) {
-    const text = stanza.querySelector('body')?.textContent;
     return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
+        const anchor = ref.getAttribute('anchor');
+        const text = stanza.querySelector(anchor ? `#${anchor}` : 'body')?.textContent;
+        if (!text) {
+            log.warn(`Could not find referenced text for ${ref}`);
+            return null;
+        }
         const begin = ref.getAttribute('begin');
         const end = ref.getAttribute('end');
         return {
@@ -254,7 +259,7 @@ export function getReferences (stanza) {
             'value': text.slice(begin, end),
             'uri': ref.getAttribute('uri')
         };
-    });
+    }).filter(r => r);
 }
 
 export function getReceiptId (stanza) {

+ 2 - 2
src/plugins/controlbox/tests/controlbox.js

@@ -134,7 +134,7 @@ describe("The Controlbox", function () {
                 `<presence xmlns="jabber:client">`+
                     `<show>dnd</show>`+
                     `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `</presence>`);
             const view = await u.waitUntil(() => document.querySelector('converse-user-profile'));
             const first_child = view.querySelector('.xmpp-status span:first-child');
@@ -161,7 +161,7 @@ describe("The Controlbox", function () {
                 `<presence xmlns="jabber:client">`+
                     `<status>I am happy</status>`+
                     `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `</presence>`);
 
             const view = await u.waitUntil(() => document.querySelector('converse-user-profile'));

+ 132 - 0
src/plugins/muc-views/tests/mep.js

@@ -0,0 +1,132 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+describe("A XEP-0316 MEP notification", function () {
+
+    it("is rendered as an info message",
+            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        const nick = 'romeo';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+        const view = _converse.chatboxviews.get(muc_jid);
+        let msg = 'An anonymous user has saluted romeo';
+        let reason = 'Thank you for helping me yesterday';
+        let message = u.toStanza(`
+            <message from='${muc_jid}'
+                    to='${_converse.jid}'
+                    type='headline'
+                    id='zns61f38'>
+                <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                    <items node='urn:ietf:params:xml:ns:conference-info'>
+                        <item id='ehs51f40'>
+                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+                                <activity xmlns='http://jabber.org/protocol/activity'>
+                                    <other/>
+                                    <text id="activity-text" xml:lang="en">${msg}</text>
+                                    <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+                                    <reason id="activity-reason">${reason}</reason>
+                                </activity>
+                            </conference-info>
+                        </item>
+                    </items>
+                </event>
+            </message>`);
+
+        _converse.connection._dataRecv(mock.createRequest(message));
+        await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
+        expect(view.querySelector('.chat-info__message').textContent.trim()).toBe(msg);
+        expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
+
+        // Check that duplicates aren't created
+        _converse.connection._dataRecv(mock.createRequest(message));
+        let promise = u.getOpenPromise();
+        setTimeout(() => {
+            expect(view.querySelectorAll('.chat-info').length).toBe(1);
+            promise.resolve();
+        }, 250);
+        await promise;
+
+        // Also check a MEP message of type "groupchat"
+        msg = 'An anonymous user has poked romeo';
+        reason = 'Can you please help me with something else?';
+        message = u.toStanza(`
+            <message from='${muc_jid}'
+                    to='${_converse.jid}'
+                    type='groupchat'
+                    id='zns61f39'>
+                <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                    <items node='urn:ietf:params:xml:ns:conference-info'>
+                        <item id='ehs51f40'>
+                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+                                <activity xmlns='http://jabber.org/protocol/activity'>
+                                    <other/>
+                                    <text id="activity-text" xml:lang="en">${msg}</text>
+                                    <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="28" end="33" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+                                    <reason id="activity-reason">${reason}</reason>
+                                </activity>
+                            </conference-info>
+                        </item>
+                    </items>
+                </event>
+            </message>`);
+
+        _converse.connection._dataRecv(mock.createRequest(message));
+        await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+        expect(view.querySelector('converse-chat-message:last-child .chat-info__message').textContent.trim()).toBe(msg);
+        expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason);
+
+        // Check that duplicates aren't created
+        _converse.connection._dataRecv(mock.createRequest(message));
+        promise = u.getOpenPromise();
+        setTimeout(() => {
+            expect(view.querySelectorAll('.chat-info').length).toBe(2);
+            promise.resolve();
+        }, 250);
+        return promise;
+    }));
+
+    it("can trigger a notification if sent to a hidden MUC",
+            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        // const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
+        // spyOn(window, 'Notification').and.returnValue(stub);
+
+        const muc_jid = 'lounge@montague.lit';
+        const nick = 'romeo';
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, {'hidden': true});
+        const msg = 'An anonymous user has saluted romeo';
+        const reason = 'Thank you for helping me yesterday';
+        const message = u.toStanza(`
+            <message from='${muc_jid}'
+                    to='${_converse.jid}'
+                    type='headline'
+                    id='zns61f38'>
+                <event xmlns='http://jabber.org/protocol/pubsub#event'>
+                    <items node='urn:ietf:params:xml:ns:conference-info'>
+                        <item id='ehs51f40'>
+                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
+                                <activity xmlns='http://jabber.org/protocol/activity'>
+                                    <other/>
+                                    <text id="activity-text" xml:lang="en">${msg}</text>
+                                    <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
+                                    <reason id="activity-reason">${reason}</reason>
+                                </activity>
+                            </conference-info>
+                        </item>
+                    </items>
+                </event>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(message));
+        await u.waitUntil(() => model.messages.length === 1);
+        // expect(window.Notification.calls.count()).toBe(1);
+
+        model.set('hidden', false);
+
+        const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid));
+        await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000);
+        expect(view.querySelector('.chat-info__message').textContent.trim()).toBe(msg);
+        expect(view.querySelector('.reason').textContent.trim()).toBe(reason);
+    }));
+});

+ 2 - 53
src/plugins/muc-views/tests/muc.js

@@ -1946,57 +1946,6 @@ describe("Groupchats", function () {
             return promise;
         }));
 
-        it("reconnects when no-acceptable error is returned when sending a message",
-                mock.initConverse([], {}, async function (_converse) {
-
-            const muc_jid = 'coven@chat.shakespeare.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
-            await mock.sendMessage(view, 'hello world');
-
-            const stanza = u.toStanza(`
-                <message xmlns='jabber:client'
-                         from='${muc_jid}'
-                         type='error'
-                         to='${_converse.bare_jid}'>
-                    <error type='cancel'>
-                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                    </error>
-                </message>`);
-            _converse.connection._dataRecv(mock.createRequest(stanza));
-
-            let sent_stanzas = _converse.connection.sent_stanzas;
-            const iq = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.PING}"]`, s).length).pop());
-            expect(Strophe.serialize(iq)).toBe(
-                `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit/romeo" type="get" xmlns="jabber:client">`+
-                    `<ping xmlns="urn:xmpp:ping"/>`+
-                `</iq>`);
-
-            const result = u.toStanza(`
-                <iq from='${muc_jid}'
-                    id='${iq.getAttribute('id')}'
-                    to='${_converse.bare_jid}'
-                    type='error'>
-                <error type='cancel'>
-                    <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
-                </error>
-                </iq>`);
-            sent_stanzas = _converse.connection.sent_stanzas;
-            const index = sent_stanzas.length -1;
-
-            _converse.connection.IQ_stanzas = [];
-            _converse.connection._dataRecv(mock.createRequest(result));
-            await mock.getRoomFeatures(_converse, muc_jid);
-
-            const pres = await u.waitUntil(
-                () => sent_stanzas.slice(index).filter(s => s.nodeName === 'presence').pop());
-            expect(Strophe.serialize(pres)).toBe(
-                `<presence from="${_converse.jid}" to="coven@chat.shakespeare.lit/romeo" xmlns="jabber:client">`+
-                    `<x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x>`+
-                `</presence>`);
-        }));
-
 
         it("informs users if the room configuration has changed",
                 mock.initConverse([], {}, async function (_converse) {
@@ -5001,7 +4950,7 @@ describe("Groupchats", function () {
             expect(Strophe.serialize(probe)).toBe(
                 `<presence to="${muc_jid}/ralphm" type="probe" xmlns="jabber:client">`+
                     `<priority>0</priority>`+
-                    `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `</presence>`);
 
             let presence = u.toStanza(
@@ -5033,7 +4982,7 @@ describe("Groupchats", function () {
             expect(Strophe.serialize(probe)).toBe(
                 `<presence to="${muc_jid}/gonePhising" type="probe" xmlns="jabber:client">`+
                     `<priority>0</priority>`+
-                    `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `</presence>`);
 
             presence = u.toStanza(

+ 6 - 6
src/plugins/muc-views/tests/rai.js

@@ -82,13 +82,13 @@ describe("XEP-0437 Room Activity Indicators", function () {
         expect(Strophe.serialize(sent_stanzas[1])).toBe(
             `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
         expect(Strophe.serialize(sent_stanzas[2])).toBe(
             `<presence to="montague.lit" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `<rai xmlns="urn:xmpp:rai:0"/>`+
             `</presence>`
         );
@@ -144,13 +144,13 @@ describe("XEP-0437 Room Activity Indicators", function () {
         expect(Strophe.serialize(sent_presences[1])).toBe(
             `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
             `</presence>`
         );
         expect(Strophe.serialize(sent_presences[2])).toBe(
             `<presence to="montague.lit" xmlns="jabber:client">`+
                 `<priority>0</priority>`+
-                `<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `<rai xmlns="urn:xmpp:rai:0"/>`+
             `</presence>`
         );
@@ -197,13 +197,13 @@ describe("XEP-0437 Room Activity Indicators", function () {
         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"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" 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"/>`+
+                `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `<rai xmlns="urn:xmpp:rai:0"/>`+
             `</presence>`
         );

+ 0 - 4
src/plugins/notifications/tests/notification.js

@@ -38,10 +38,6 @@ describe("Notifications", function () {
                     await mock.waitForRoster(_converse, 'current');
                     await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
                     const view = _converse.chatboxviews.get('lounge@montague.lit');
-                    if (!view.querySelectorAll('.chat-area').length) {
-                        view.renderChatArea();
-                    }
-
                     const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
                     spyOn(window, 'Notification').and.returnValue(stub);
 

+ 16 - 7
src/plugins/notifications/utils.js

@@ -46,7 +46,8 @@ export function updateUnreadFavicon () {
  * @param { MUCMessageAttributes } attrs
  */
 export async function shouldNotifyOfGroupMessage (attrs) {
-    if (!attrs?.body) {
+    if (!attrs?.body && !attrs?.message) {
+        // attrs.message is used by 'info' messages
         return false;
     }
     const jid = attrs.from;
@@ -174,9 +175,11 @@ function showMessageNotification (data) {
         return;
     }
     let title, roster_item;
-    const full_from_jid = attrs.from,
-        from_jid = Strophe.getBareJidFromJid(full_from_jid);
-    if (attrs.type === 'headline') {
+    const full_from_jid = attrs.from;
+    const from_jid = Strophe.getBareJidFromJid(full_from_jid);
+    if (attrs.type == 'info') {
+        title = attrs.message;
+    } else if (attrs.type === 'headline') {
         if (!from_jid.includes('@') || api.settings.get('allow_non_roster_messaging')) {
             title = __('Notification from %1$s', from_jid);
         } else {
@@ -204,10 +207,16 @@ function showMessageNotification (data) {
         }
     }
 
-    const body = attrs.is_encrypted ? __('Encrypted message received') : attrs.body;
-    if (!body) {
-        return;
+    let body;
+    if (attrs.type == 'info') {
+        body = attrs.reason;
+    } else {
+        body = attrs.is_encrypted ? attrs.plaintext : attrs.body;
+        if (!body) {
+            return;
+        }
     }
+
     const n = new Notification(title, {
         'body': body,
         'lang': _converse.locale,

+ 2 - 2
src/plugins/rosterview/tests/presence.js

@@ -29,7 +29,7 @@ describe("A sent presence stanza", function () {
             .toBe(`<presence xmlns="jabber:client">`+
                     `<status>My custom status</status>`+
                     `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                     `</presence>`)
         await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
         await u.waitUntil(() => !u.isVisible(modal.el));
@@ -47,7 +47,7 @@ describe("A sent presence stanza", function () {
                     `<show>dnd</show>`+
                     `<status>My custom status</status>`+
                     `<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="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                 `</presence>`)
     }));
 });

+ 2 - 15
src/shared/chat/message.js

@@ -7,6 +7,7 @@ import OccupantModal from 'modals/occupant.js';
 import UserDetailsModal from 'modals/user-details.js';
 import filesize from 'filesize';
 import log from '@converse/headless/log';
+import tpl_info_message from './templates/info-message.js';
 import tpl_message from './templates/message.js';
 import tpl_message_text from './templates/message-text.js';
 import tpl_spinner from 'templates/spinner.js';
@@ -87,21 +88,7 @@ export default class Message extends CustomElement {
     }
 
     renderInfoMessage () {
-        const isodate = dayjs(this.model.get('time')).toISOString();
-        const i18n_retry = __('Retry');
-        return html`
-            <div class="message chat-info chat-${this.model.get('type')}"
-                data-isodate="${isodate}"
-                data-type="${this.data_name}"
-                data-value="${this.data_value}">
-
-                <div class="chat-info__message">
-                    ${ this.model.getMessageText() }
-                </div>
-                ${ this.model.get('reason') ? html`<q class="reason">${this.model.get('reason')}</q>` : `` }
-                ${ this.model.get('error_text') ? html`<q class="reason">${this.model.get('error_text')}</q>` : `` }
-                ${ this.model.get('retry_event_id') ? html`<a class="retry" @click=${this.onRetryClicked}>${i18n_retry}</a>` : '' }
-            </div>`;
+        return tpl_info_message(this);
     }
 
     renderFileProgress () {

+ 27 - 0
src/shared/chat/templates/info-message.js

@@ -0,0 +1,27 @@
+import { __ } from 'i18n';
+import { converse } from  '@converse/headless/core';
+import { html } from 'lit';
+
+const { dayjs } = converse.env;
+
+export default (el) => {
+    const isodate = dayjs(el.model.get('time')).toISOString();
+    const i18n_retry = __('Retry');
+    return html`
+        <div class="message chat-info chat-${el.model.get('type')}"
+            data-isodate="${isodate}"
+            data-type="${el.data_name}"
+            data-value="${el.data_value}">
+
+            <div class="chat-info__message">
+                <converse-rich-text
+                    .mentions=${el.model.get('references')}
+                    render_styling
+                    text=${el.model.getMessageText()}>
+                </converse-rich-text>
+            </div>
+            ${ el.model.get('reason') ? html`<q class="reason">${el.model.get('reason')}</q>` : `` }
+            ${ el.model.get('error_text') ? html`<q class="reason">${el.model.get('error_text')}</q>` : `` }
+            ${ el.model.get('retry_event_id') ? html`<a class="retry" @click=${el.onRetryClicked}>${i18n_retry}</a>` : '' }
+        </div>`;
+}