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

Implement support for XEP-0421 occupant ids

This let's us populate the `from_real_jid` attribute for messages in
cases where the user's nickname has changed.

Only save the occupant-id if the MUC supports it

Store all advertised features on the `chatbox.features` model.
This allows us to look up a feature without using the async
`disco.supports` API.

Updates #2241
JC Brand 3 роки тому
батько
коміт
ecfc3e9fcf

+ 10 - 0
conversejs.doap

@@ -216,6 +216,11 @@
         <xmpp:since>4.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0371.html"/>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0372.html"/>
@@ -245,6 +250,11 @@
         <xmpp:since>5.0.0</xmpp:since>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0421.html"/>
+      </xmpp:SupportedXep>
+    </implements>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0422.html"/>

+ 2 - 0
karma.conf.js

@@ -29,7 +29,9 @@ module.exports = function(config) {
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/affiliations.js", type: 'module' },
+      { pattern: "src/headless/plugins/muc/tests/messages.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/muc.js", type: 'module' },
+      { pattern: "src/headless/plugins/muc/tests/occupants.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/pruning.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/registration.js", type: 'module' },
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },

+ 1 - 0
src/headless/core.js

@@ -58,6 +58,7 @@ Strophe.addNamespace('MENTIONS', 'urn:xmpp:mmn:0');
 Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
 Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0');
 Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
+Strophe.addNamespace('OCCUPANTID', 'urn:xmpp:occupant-id:0');
 Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
 Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
 Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');

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

@@ -150,9 +150,7 @@ const MessageMixin = {
     },
 
     getDisplayName () {
-        if (this.get('type') === 'groupchat') {
-            return this.get('nick');
-        } else if (this.contact) {
+        if (this.contact) {
             return this.contact.getDisplayName();
         } else if (this.vcard) {
             return this.vcard.getDisplayName();

+ 19 - 9
src/headless/plugins/muc/message.js

@@ -28,6 +28,11 @@ const ChatRoomMessageMixin = {
         api.trigger('chatRoomMessageInitialized', this);
     },
 
+
+    getDisplayName () {
+        return this.occupant?.getDisplayName() || this.get('nick');
+    },
+
     /**
      * Determines whether this messsage may be moderated,
      * based on configuration settings and server support.
@@ -66,16 +71,21 @@ const ChatRoomMessageMixin = {
     },
 
     onOccupantAdded (occupant) {
-        if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) {
-            this.occupant = occupant;
-            this.trigger('occupantAdded');
-            this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
-            const chatbox = this?.collection?.chatbox;
-            if (!chatbox) {
-                return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
+        if (this.get('occupant_id')) {
+            if (occupant.get('occupant_id') !== this.get('occupant_id')) {
+                return;
             }
-            this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
+        } else if (occupant.get('nick') !== Strophe.getResourceFromJid(this.get('from'))) {
+            return;
+        }
+        this.occupant = occupant;
+        this.trigger('occupantAdded');
+        this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
+        const chatbox = this?.collection?.chatbox;
+        if (!chatbox) {
+            return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
         }
+        this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
     },
 
     setOccupant () {
@@ -87,7 +97,7 @@ const ChatRoomMessageMixin = {
             return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
         }
         const nick = Strophe.getResourceFromJid(this.get('from'));
-        this.occupant = chatbox.occupants.findWhere({ nick });
+        this.occupant = chatbox.occupants.findOccupant({ nick, 'occupant_id': this.get('occupant_id') });
 
         if (!this.occupant && api.settings.get('muc_send_probes')) {
             this.occupant = chatbox.occupants.create({ nick, 'type': 'unavailable' });

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

@@ -1165,6 +1165,8 @@ const ChatRoomMixin = {
             if (!fieldname.startsWith('muc_')) {
                 if (fieldname === Strophe.NS.MAM) {
                     attrs.mam_enabled = true;
+                } else {
+                    attrs[fieldname] = true;
                 }
                 return;
             }
@@ -1684,8 +1686,8 @@ const ChatRoomMixin = {
      * @param { XMLElement } pres - The presence stanza
      */
     updateOccupantsOnPresence (pres) {
-        const data = parseMUCPresence(pres);
-        if (data.type === 'error' || (!data.jid && !data.nick)) {
+        const data = parseMUCPresence(pres, this);
+        if (data.type === 'error' || (!data.jid && !data.nick && !data.occupant_id)) {
             return true;
         }
         const occupant = this.occupants.findOccupant(data);

+ 5 - 2
src/headless/plugins/muc/occupants.js

@@ -92,6 +92,7 @@ const ChatRoomOccupants = Collection.extend({
      * @typedef { Object} OccupantData
      * @property { String } [jid]
      * @property { String } [nick]
+     * @property { String } [occupant_id]
      */
     /**
      * Try to find an existing occupant based on the passed in
@@ -105,8 +106,10 @@ const ChatRoomOccupants = Collection.extend({
      * @param { OccupantData } data
      */
     findOccupant (data) {
-        const jid = Strophe.getBareJidFromJid(data.jid);
-        return (jid && this.findWhere({ jid })) || this.findWhere({ 'nick': data.nick });
+        const jid = data.jid && Strophe.getBareJidFromJid(data.jid);
+        return jid && this.findWhere({ jid }) ||
+            data.occupant_id && this.findWhere({ 'occupant_id': data.occupant_id }) ||
+            data.nick && this.findWhere({ 'nick': data.nick });
     }
 });
 

+ 35 - 8
src/headless/plugins/muc/parsers.js

@@ -93,6 +93,12 @@ function getModerationAttributes (stanza) {
     return {};
 }
 
+function getOccupantID (stanza, chatbox) {
+    if (chatbox.features.get(Strophe.NS.OCCUPANTID)) {
+        return sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id');
+    }
+}
+
 /**
  * Parses a passed in message stanza and returns an object of attributes.
  * @param { XMLElement } stanza - The message stanza
@@ -117,6 +123,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
     }
     const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
     const from = stanza.getAttribute('from');
+    const from_muc = Strophe.getBareJidFromJid(from);
     const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from));
     const marker = getChatMarker(stanza);
     const now = new Date().toISOString();
@@ -159,6 +166,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
      * @property { String } moderation_reason - The reason provided why this message moderates another
      * @property { String } msgid - The root `id` attribute of the stanza
      * @property { String } nick - The MUC nickname of the sender
+     * @property { String } occupant_id - The XEP-0421 occupant ID
      * @property { String } oob_desc - The description of the XEP-0066 out of band data
      * @property { String } oob_url - The URL of the XEP-0066 out of band data
      * @property { String } origin_id - The XEP-0359 Origin ID
@@ -175,17 +183,15 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
      * @property { String } to - The recipient JID
      * @property { String } type - The type of message
      */
-
     let attrs = Object.assign(
         {
             from,
+            from_muc,
             nick,
-            'is_forwarded': !!stanza?.querySelector('forwarded'),
+            'is_forwarded': !!stanza.querySelector('forwarded'),
             'activities': getMEPActivities(stanza),
             'body': stanza.querySelector('body')?.textContent?.trim(),
             'chat_state': getChatState(stanza),
-            'from_muc': Strophe.getBareJidFromJid(from),
-            'from_real_jid': chatbox.occupants.findOccupant({ nick })?.get('jid'),
             'is_archived': isArchived(original_stanza),
             'is_carbon': isCarbon(original_stanza),
             'is_delayed': !!delay,
@@ -195,6 +201,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
             'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
             'marker_id': marker && marker.getAttribute('id'),
             'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
+            'occupant_id': getOccupantID(stanza, chatbox),
             'receipt_id': getReceiptId(stanza),
             'received': new Date().toISOString(),
             'references': getReferences(stanza),
@@ -215,14 +222,15 @@ export async function parseMUCMessage (stanza, chatbox, _converse) {
         getEncryptionAttributes(stanza, _converse),
     );
 
-
     await api.emojis.initialize();
+
     attrs = Object.assign(
         {
+            'from_real_jid': chatbox.occupants.findOccupant(attrs)?.get('jid'),
             'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
             'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
             'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead
-            'sender': attrs.nick === chatbox.get('nick') ? 'me' : 'them'
+            'sender': attrs.nick === chatbox.get('nick') ? 'me' : 'them',
         },
         attrs
     );
@@ -299,13 +307,26 @@ export function parseMemberListIQ (iq) {
  * Parses a passed in MUC presence stanza and returns an object of attributes.
  * @method parseMUCPresence
  * @param { XMLElement } stanza - The presence stanza
- * @returns { Object }
+ * @param { _converse.ChatRoom } chatbox
+ * @returns { MUCPresenceAttributes }
  */
-export function parseMUCPresence (stanza) {
+export function parseMUCPresence (stanza, chatbox) {
+    /**
+     * @typedef { Object } MUCPresenceAttributes
+     * The object which {@link parseMUCPresence} returns
+     * @property { ("offline|online") } show
+     * @property { Array<MUCHat> } hats - An array of XEP-0317 hats
+     * @property { Array<string> } states
+     * @property { String } from - The sender JID (${muc_jid}/${nick})
+     * @property { String } nick - The nickname of the sender
+     * @property { String } occupant_id - The XEP-0421 occupant ID
+     * @property { String } type - The type of presence
+     */
     const from = stanza.getAttribute('from');
     const type = stanza.getAttribute('type');
     const data = {
         'from': from,
+        'occupant_id': getOccupantID(stanza, chatbox),
         'nick': Strophe.getResourceFromJid(from),
         'type': type,
         'states': [],
@@ -331,6 +352,12 @@ export function parseMUCPresence (stanza) {
         } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) {
             data.image_hash = child.querySelector('photo')?.textContent;
         } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) {
+            /**
+             * @typedef { Object } MUCHat
+             * Object representing a XEP-0371 Hat
+             * @property { String } title
+             * @property { String } uri
+             */
             data['hats'] = Array.from(child.children).map(
                 c =>
                     c.matches('hat') && {

+ 43 - 0
src/headless/plugins/muc/tests/messages.js

@@ -0,0 +1,43 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+describe("A MUC message", function () {
+
+    it("saves the user's real JID as looked up via the XEP-0421 occupant id",
+            mock.initConverse([], {}, async function (_converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        const nick = 'romeo';
+        const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID];
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+        const occupant_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const presence = u.toStanza(`
+            <presence
+                from="${muc_jid}/thirdwitch"
+                id="${u.getUniqueId()}"
+                to="${_converse.bare_jid}">
+            <x xmlns="http://jabber.org/protocol/muc#user">
+                <item jid="${occupant_jid}" />
+            </x>
+            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </presence>`);
+        _converse.connection._dataRecv(mock.createRequest(presence));
+        expect(model.getOccupantByNickname('thirdwitch').get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');
+
+        const stanza = u.toStanza(`
+            <message
+                from='${muc_jid}/thirdwitch'
+                id='hysf1v37'
+                to='${_converse.bare_jid}'
+                type='groupchat'>
+            <body>Harpier cries: 'tis time, 'tis time.</body>
+            <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => model.messages.length);
+        expect(model.messages.at(0).get('occupant_id')).toBe("dd72603deec90a38ba552f7c68cbcc61bca202cd");
+        expect(model.messages.at(0).get('from_real_jid')).toBe(occupant_jid);
+    }));
+});

+ 101 - 0
src/headless/plugins/muc/tests/occupants.js

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

+ 3 - 2
src/plugins/omemo/utils.js

@@ -255,9 +255,10 @@ function getJIDForDecryption (attrs) {
     const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from;
     if (!from_jid) {
         Object.assign(attrs, {
-            'error_text': __("Sorry, could not decrypt a received OMEMO message because we don't have the XMPP address for that user."),
+            'error_text': __("Sorry, could not decrypt a received OMEMO "+
+                "message because we don't have the XMPP address for that user."),
             'error_type': 'Decryption',
-            'is_ephemeral': false,
+            'is_ephemeral': true,
             'is_error': true,
             'type': 'error'
         });

+ 1 - 0
src/shared/chat/message.js

@@ -57,6 +57,7 @@ export default class Message extends CustomElement {
                 this.listenTo(this.model.occupant, 'change', () => this.requestUpdate());
             } else {
                 this.listenTo(this.model, 'occupantAdded', () => {
+                    this.requestUpdate();
                     this.listenTo(this.model.occupant, 'change', () => this.requestUpdate())
                 });
             }