Skip to content

Commit

Permalink
Implement support for XEP-0421 occupant ids
Browse files Browse the repository at this point in the history
This let's us populate the `from_real_jid` attribute for messages in
cases where the user's nickname has changed.

Updates #2241
  • Loading branch information
jcbrand committed Nov 6, 2021
1 parent a60127e commit bc88394
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 22 deletions.
10 changes: 10 additions & 0 deletions conversejs.doap
Original file line number Diff line number Diff line change
Expand Up @@ -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"/>
Expand Down Expand Up @@ -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"/>
Expand Down
1 change: 1 addition & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module.exports = function(config) {
{ 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/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' },
Expand Down
1 change: 1 addition & 0 deletions src/headless/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 1 addition & 3 deletions src/headless/plugins/chat/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 20 additions & 8 deletions src/headless/plugins/muc/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,16 +71,23 @@ 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;
}
} else {
if (occupant.get('nick') !== Strophe.getResourceFromJid(this.get('from'))) {
return;
}
this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
}
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 () {
Expand Down
32 changes: 30 additions & 2 deletions src/headless/plugins/muc/muc.js
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,8 @@ const ChatRoomMixin = {
* @property { module:converse-muc~MUCMessageData } data
*/
api.trigger('message', data);
return attrs && this.queueMessage(attrs);
attrs && this.queueMessage(attrs);
attrs && this.updateOccupantOnMessage(attrs);
},

/**
Expand Down Expand Up @@ -924,6 +925,14 @@ const ChatRoomMixin = {
return RegExp(`(?:\\p{P}|\\p{Z}|^)@(${longNickString})(?![\\w@-])`, 'uig');
},

/**
* Given a XEP-0421 occupant id, return the occupant
* @param { string } id
*/
getOccupantByID (occupant_id) {
return this.occupants.findOccupant({ occupant_id });
},

getOccupantByJID (jid) {
return this.occupants.findOccupant({ jid });
},
Expand Down Expand Up @@ -1677,6 +1686,25 @@ const ChatRoomMixin = {
return api.sendIQ(iq).catch(e => log.error(e));
},

/**
* Given {@link MessageAttributes} for a non-delayed message,
* look for an occupant based on the XEP-0421 occupant id and
* update it with a new nickname if it's different in the message.
* @method _converse.ChatRoom#updateOccupantOnMessage
* @param { MessageAttributes } attrs
*/
updateOccupantOnMessage (attrs) {
if (attrs.is_delayed || !attrs.occupant_id) {
return false;
}
const occupant = this.getOccupantByID(attrs.occupant_id);
if (occupant) {
occupant.save({
'nick': attrs.nick,
});
}
},

/**
* Given a presence stanza, update the occupant model based on its contents.
* @private
Expand All @@ -1685,7 +1713,7 @@ const ChatRoomMixin = {
*/
updateOccupantsOnPresence (pres) {
const data = parseMUCPresence(pres);
if (data.type === 'error' || (!data.jid && !data.nick)) {
if (data.type === 'error' || (!data.jid && !data.nick && !data.occupant_id)) {
return true;
}
const occupant = this.occupants.findOccupant(data);
Expand Down
7 changes: 5 additions & 2 deletions src/headless/plugins/muc/occupants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
}
});

Expand Down
30 changes: 25 additions & 5 deletions src/headless/plugins/muc/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,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
Expand All @@ -175,17 +176,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,
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,
Expand All @@ -195,6 +194,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': sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id'),
'receipt_id': getReceiptId(stanza),
'received': new Date().toISOString(),
'references': getReferences(stanza),
Expand All @@ -217,12 +217,14 @@ export async function parseMUCMessage (stanza, chatbox, _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
);
Expand Down Expand Up @@ -299,13 +301,25 @@ 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 }
* @returns { MUCPresenceAttributes }
*/
export function parseMUCPresence (stanza) {
/**
* @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': sizzle(`occupant-id[xmlns="${Strophe.NS.OCCUPANTID}"]`, stanza).pop()?.getAttribute('id'),
'nick': Strophe.getResourceFromJid(from),
'type': type,
'states': [],
Expand All @@ -331,6 +345,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') && {
Expand Down
120 changes: 120 additions & 0 deletions src/headless/plugins/muc/tests/occupants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*global mock, converse */

const { 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) {
// TODO
}));

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 model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);

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("is updated with the XEP-0421 occupant id received from a message stanza",
mock.initConverse([], {}, async function (_converse) {

const muc_jid = 'lounge@montague.lit';
const nick = 'romeo';
const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);

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}/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);
expect(model.messages.at(0).get('occupant_id')).toBe("dd72603deec90a38ba552f7c68cbcc61bca202cd");
expect(model.messages.at(0).get('from_real_jid')).toBe(occupant_jid);
expect(model.getOccupantByID("dd72603deec90a38ba552f7c68cbcc61bca202cd").get('nick')).toBe('3rdwitch');
}));

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 model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
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));
expect(model.getOccupantByNickname('thirdwitch').get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd');

message = model.messages.at(0);
const occupant = model.getOccupantByID("dd72603deec90a38ba552f7c68cbcc61bca202cd");
expect(occupant.get('nick')).toBe('thirdwitch');
expect(message.occupant).toEqual(occupant);
expect(message.getDisplayName()).toBe('thirdwitch');
}));
});
Loading

0 comments on commit bc88394

Please sign in to comment.