From ecfc3e9fcf5b1aa0b0a9fd917148475c95735f52 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 3 Nov 2021 21:33:23 +0100 Subject: [PATCH] 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 --- conversejs.doap | 10 ++ karma.conf.js | 2 + src/headless/core.js | 1 + src/headless/plugins/chat/message.js | 4 +- src/headless/plugins/muc/message.js | 28 ++++-- src/headless/plugins/muc/muc.js | 6 +- src/headless/plugins/muc/occupants.js | 7 +- src/headless/plugins/muc/parsers.js | 43 +++++++-- src/headless/plugins/muc/tests/messages.js | 43 +++++++++ src/headless/plugins/muc/tests/occupants.js | 101 ++++++++++++++++++++ src/plugins/omemo/utils.js | 5 +- src/shared/chat/message.js | 1 + 12 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 src/headless/plugins/muc/tests/messages.js create mode 100644 src/headless/plugins/muc/tests/occupants.js diff --git a/conversejs.doap b/conversejs.doap index b998701eb..32e74d4a9 100644 --- a/conversejs.doap +++ b/conversejs.doap @@ -216,6 +216,11 @@ 4.0.0 + + + + + @@ -245,6 +250,11 @@ 5.0.0 + + + + + diff --git a/karma.conf.js b/karma.conf.js index a2d59f99c..e5210d5a0 100644 --- a/karma.conf.js +++ b/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' }, diff --git a/src/headless/core.js b/src/headless/core.js index ee086429a..7f552cf50 100644 --- a/src/headless/core.js +++ b/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'); diff --git a/src/headless/plugins/chat/message.js b/src/headless/plugins/chat/message.js index e735cecf4..5271755db 100644 --- a/src/headless/plugins/chat/message.js +++ b/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(); diff --git a/src/headless/plugins/muc/message.js b/src/headless/plugins/muc/message.js index e203351b0..b074a951a 100644 --- a/src/headless/plugins/muc/message.js +++ b/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' }); diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 7a66137ab..47af6ff7f 100644 --- a/src/headless/plugins/muc/muc.js +++ b/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); diff --git a/src/headless/plugins/muc/occupants.js b/src/headless/plugins/muc/occupants.js index 05ed078d7..9b178676a 100644 --- a/src/headless/plugins/muc/occupants.js +++ b/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 }); } }); diff --git a/src/headless/plugins/muc/parsers.js b/src/headless/plugins/muc/parsers.js index f94cfb069..af106ffab 100644 --- a/src/headless/plugins/muc/parsers.js +++ b/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 } hats - An array of XEP-0317 hats + * @property { Array } 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') && { diff --git a/src/headless/plugins/muc/tests/messages.js b/src/headless/plugins/muc/tests/messages.js new file mode 100644 index 000000000..8c6d18525 --- /dev/null +++ b/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(` + + + + + + `); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(model.getOccupantByNickname('thirdwitch').get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd'); + + const stanza = u.toStanza(` + + Harpier cries: 'tis time, 'tis time. + + `); + _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); + })); +}); diff --git a/src/headless/plugins/muc/tests/occupants.js b/src/headless/plugins/muc/tests/occupants.js new file mode 100644 index 000000000..f151f75d6 --- /dev/null +++ b/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(` + + + + `); + _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 + + + `); + _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(` + + Harpier cries: 'tis time, 'tis time. + + `); + _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(` + + + + + + `); + _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'); + })); +}); diff --git a/src/plugins/omemo/utils.js b/src/plugins/omemo/utils.js index 727532753..afd94e074 100644 --- a/src/plugins/omemo/utils.js +++ b/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' }); diff --git a/src/shared/chat/message.js b/src/shared/chat/message.js index ee39ed89c..71b24f3af 100644 --- a/src/shared/chat/message.js +++ b/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()) }); }