Commit c4570815 authored by JC Brand's avatar JC Brand

WIP: Add support for XEP-437: Room Activity Indicators

- Send marker for last message before leaving and before subscribing to RAI
- clear cache of RAI-subscribed domains on reconnection
parent e6e41630
{ {
"arrowParens": "avoid", "arrowParens": "avoid",
"printWidth": 100, "printWidth": 120,
"quoteProps": "preserve", "quoteProps": "preserve",
"singleQuote": true, "singleQuote": true,
"spaceBeforeFunctionParen": true, "spaceBeforeFunctionParen": true,
......
...@@ -105,6 +105,7 @@ In embedded mode, Converse can be embedded into an element in the DOM. ...@@ -105,6 +105,7 @@ In embedded mode, Converse can be embedded into an element in the DOM.
- [XEP-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support) - [XEP-0422](https://xmpp.org/extensions/xep-0422.html) Message Fastening (limited support)
- [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions - [XEP-0424](https://xmpp.org/extensions/xep-0424.html) Message Retractions
- [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation - [XEP-0425](https://xmpp.org/extensions/xep-0425.html) Message Moderation
- [XEP-0437](https://xmpp.org/extensions/xep-0437.html) Room Activity Indicators
## Integration into other servers and frameworks ## Integration into other servers and frameworks
......
...@@ -1447,6 +1447,18 @@ a nickname configured for it), you'll see the message history (if the ...@@ -1447,6 +1447,18 @@ a nickname configured for it), you'll see the message history (if the
server supports [XEP-0313 Message Archive Management](https://xmpp.org/extensions/xep-0313.html)) server supports [XEP-0313 Message Archive Management](https://xmpp.org/extensions/xep-0313.html))
and the nickname form at the bottom. and the nickname form at the bottom.
muc_subscribe_to_rai
--------------------
* Default: ``false``
This option enables support for XEP-0437 Room Activity Indicators.
When a MUC is no longer visible (the ``hidden`` flag becomes ``true``), then
Converse will make sure that its subscribed to activity indicators on the MUC
host.
.. _`nickname`: .. _`nickname`:
nickname nickname
......
...@@ -2757,7 +2757,8 @@ ...@@ -2757,7 +2757,8 @@
"dependencies": { "dependencies": {
"filesize": { "filesize": {
"version": "6.1.0", "version": "6.1.0",
"resolved": false "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
"integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg=="
}, },
"fs-extra": { "fs-extra": {
"version": "8.1.0", "version": "8.1.0",
...@@ -2813,20 +2814,22 @@ ...@@ -2813,20 +2814,22 @@
}, },
"localforage": { "localforage": {
"version": "1.7.3", "version": "1.7.3",
"resolved": false, "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz",
"integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==",
"requires": { "requires": {
"lie": "3.1.1" "lie": "3.1.1"
} }
}, },
"pluggable.js": { "pluggable.js": {
"version": "2.0.1", "version": "2.0.1",
"resolved": false, "resolved": "https://registry.npmjs.org/pluggable.js/-/pluggable.js-2.0.1.tgz",
"integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==",
"requires": { "requires": {
"lodash": "^4.17.11" "lodash": "^4.17.11"
} }
}, },
"skeletor.js": { "skeletor.js": {
"version": "0.0.1", "version": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
"from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561", "from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
"requires": { "requires": {
"lodash": "^4.17.14" "lodash": "^4.17.14"
...@@ -2834,7 +2837,11 @@ ...@@ -2834,7 +2837,11 @@
}, },
"strophe.js": { "strophe.js": {
"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", "version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f" "from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
"requires": {
"abab": "^2.0.3",
"xmldom": "^0.1.27"
}
}, },
"twemoji": { "twemoji": {
"version": "12.1.5", "version": "12.1.5",
......
...@@ -44,6 +44,7 @@ Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick'); ...@@ -44,6 +44,7 @@ Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl'); Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob'); Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub'); Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('RAI', 'urn:xmpp:rai:0');
Strophe.addNamespace('REGISTER', 'jabber:iq:register'); Strophe.addNamespace('REGISTER', 'jabber:iq:register');
Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0'); Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx'); Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
......
...@@ -580,13 +580,41 @@ const ChatBox = ModelWithContact.extend({ ...@@ -580,13 +580,41 @@ const ChatBox = ModelWithContact.extend({
return _converse.connection.send(msg); return _converse.connection.send(msg);
}, },
sendMarkerForMessage (msg) {
if (msg?.get('is_markable')) { /**
* Finds the last eligible message and then sends a XEP-0333 chat marker for it.
* @param { Boolean } force - Whether a marker should be sent for the
* message, even if it didn't include a `markable` element.
*/
sendMarkerForLastMessage (force=false) {
const msgs = Array.from(this.messages.models);
msgs.reverse();
const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
msg && this.sendMarkerForMessage(msg);
},
/**
* Given the passed in message object, send a XEP-0333 chat marker.
* @param { _converse.Message } msg
* @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
* @param { Boolean } force - Whether a marker should be sent for the
* message, even if it didn't include a `markable` element.
*/
sendMarkerForMessage (msg, type='displayed', force=false) {
if (!msg) return;
if (msg?.get('is_markable') || force) {
const from_jid = Strophe.getBareJidFromJid(msg.get('from')); const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
this.sendMarker(from_jid, msg.get('msgid'), 'displayed', msg.get('type')); this.sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
} }
}, },
/**
* Send out a XEP-0333 chat marker
* @param { String } to_jid
* @param { String } id - The id of the message being marked
* @param { String } type - The marker type
* @param { String } msg_type
*/
sendMarker (to_jid, id, type, msg_type) { sendMarker (to_jid, id, type, msg_type) {
const stanza = $msg({ const stanza = $msg({
'from': _converse.connection.jid, 'from': _converse.connection.jid,
......
...@@ -106,6 +106,17 @@ converse.ROOMSTATUS = { ...@@ -106,6 +106,17 @@ converse.ROOMSTATUS = {
}; };
function registerDirectInvitationHandler () {
_converse.connection.addHandler(
message => {
_converse.onDirectMUCInvitation(message);
return true;
},
'jabber:x:conference',
'message'
);
}
function disconnectChatRooms () { function disconnectChatRooms () {
/* When disconnecting, mark all groupchats as /* When disconnecting, mark all groupchats as
* disconnected, so that they will be properly entered again * disconnected, so that they will be properly entered again
...@@ -241,7 +252,8 @@ converse.plugins.add('converse-muc', { ...@@ -241,7 +252,8 @@ converse.plugins.add('converse-muc', {
...converse.MUC_INFO_CODES.join_leave_events, ...converse.MUC_INFO_CODES.join_leave_events,
...converse.MUC_INFO_CODES.role_changes ...converse.MUC_INFO_CODES.role_changes
], ],
'muc_show_logs_before_join': false 'muc_show_logs_before_join': false,
'muc_subscribe_to_rai': false,
}); });
api.promises.add(['roomsAutoJoined']); api.promises.add(['roomsAutoJoined']);
...@@ -413,22 +425,15 @@ converse.plugins.add('converse-muc', { ...@@ -413,22 +425,15 @@ converse.plugins.add('converse-muc', {
} }
}; };
/************************ BEGIN Event Handlers ************************/
if (api.settings.get('allow_muc_invitations')) { if (api.settings.get('allow_muc_invitations')) {
const registerDirectInvitationHandler = function () {
_converse.connection.addHandler(
message => {
_converse.onDirectMUCInvitation(message);
return true;
},
'jabber:x:conference',
'message'
);
};
api.listen.on('connected', registerDirectInvitationHandler); api.listen.on('connected', registerDirectInvitationHandler);
api.listen.on('reconnected', registerDirectInvitationHandler); api.listen.on('reconnected', registerDirectInvitationHandler);
} }
/************************ BEGIN Event Handlers ************************/ api.listen.on('reconnected', () => _converse.session.save('rai_enabled_domains', ''));
api.listen.on('beforeTearDown', () => { api.listen.on('beforeTearDown', () => {
const groupchats = _converse.chatboxes.where({ 'type': _converse.CHATROOMS_TYPE }); const groupchats = _converse.chatboxes.where({ 'type': _converse.CHATROOMS_TYPE });
groupchats.forEach(muc => groupchats.forEach(muc =>
......
...@@ -28,6 +28,13 @@ const MUCSession = Model.extend({ ...@@ -28,6 +28,13 @@ const MUCSession = Model.extend({
const ChatRoomMixin = { const ChatRoomMixin = {
defaults () { defaults () {
return { return {
'bookmarked': false,
'chat_state': undefined,
'has_activity': false, // XEP-437
'hidden': _converse.isUniView() && !api.settings.get('singleton'),
'hidden_occupants': !!api.settings.get('hide_muc_participants'),
'message_type': 'groupchat',
'name': '',
// For group chats, we distinguish between generally unread // For group chats, we distinguish between generally unread
// messages and those ones that specifically mention the // messages and those ones that specifically mention the
// user. // user.
...@@ -37,12 +44,6 @@ const ChatRoomMixin = { ...@@ -37,12 +44,6 @@ const ChatRoomMixin = {
// mention the user and `num_unread_general` to indicate // mention the user and `num_unread_general` to indicate
// generally unread messages (which *includes* mentions!). // generally unread messages (which *includes* mentions!).
'num_unread_general': 0, 'num_unread_general': 0,
'bookmarked': false,
'chat_state': undefined,
'hidden': _converse.isUniView() && !api.settings.get('singleton'),
'hidden_occupants': !!api.settings.get('hide_muc_participants'),
'message_type': 'groupchat',
'name': '',
'num_unread': 0, 'num_unread': 0,
'roomconfig': {}, 'roomconfig': {},
'time_opened': this.get('time_opened') || new Date().getTime(), 'time_opened': this.get('time_opened') || new Date().getTime(),
...@@ -62,6 +63,8 @@ const ChatRoomMixin = { ...@@ -62,6 +63,8 @@ const ChatRoomMixin = {
this.registerHandlers(); this.registerHandlers();
this.on('change:chat_state', this.sendChatState, this); this.on('change:chat_state', this.sendChatState, this);
this.on('change:hidden', this.onHiddenChange, this);
await this.restoreSession(); await this.restoreSession();
this.session.on('change:connection_status', this.onConnectionStatusChanged, this); this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
...@@ -104,6 +107,20 @@ const ChatRoomMixin = { ...@@ -104,6 +107,20 @@ const ChatRoomMixin = {
} }
}, },
/**
* Handles incoming message stanzas from the service that hosts this MUC
* @private
* @method _converse.ChatRoom#onPresence
* @param { XMLElement } stanza
*/
handleMessageFromMUCService (stanza) {
const rai = stanza.querySelector(`rai[xmlns="${Strophe.NS.RAI}"]`);
const active_mucs = Array.from(rai?.querySelectorAll('activity') || []).map(m => m.getAttribute('xmlns'));
if (active_mucs.includes(this.get('jid'))) {
this.save({ 'has_activity': true });
}
},
/** /**
* Join the MUC * Join the MUC
* @private * @private
...@@ -161,6 +178,28 @@ const ChatRoomMixin = { ...@@ -161,6 +178,28 @@ const ChatRoomMixin = {
} }
}, },
/**
* Handler that gets called when the 'hidden' flag is toggled.
* @private
* @method _converse.ChatRoomView#onHiddenChange
*/
async onHiddenChange () {
if (this.get('hidden') && api.settings.get('muc_subscribe_to_rai')) {
this.sendMarkerForLastMessage(true);
if (this.session.get('connection_status') !== converse.ROOMSTATUS.DISCONNECTED) {
await this.leave();
}
const rai_enabled = _converse.session.get('rai_enabled_domains') || '';
const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
if (!rai_enabled.includes(muc_domain)) {
api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
_converse.session.save({ 'rai_enabled_domains': `${rai_enabled} ${muc_domain}` });
}
} else if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
this.onReconnection();
}
},
onOccupantAdded (occupant) { onOccupantAdded (occupant) {
if ( if (
_converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) && _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
...@@ -384,9 +423,13 @@ const ChatRoomMixin = { ...@@ -384,9 +423,13 @@ const ChatRoomMixin = {
return attrs && this.queueMessage(attrs); return attrs && this.queueMessage(attrs);
}, },
/**
* Register presence and message handlers relevant to this groupchat
* @private
* @method _converse.ChatRoom#registerHandlers
*/
registerHandlers () { registerHandlers () {
// Register presence and message handlers for this groupchat const muc_jid = this.get('jid');
const room_jid = this.get('jid');
this.removeHandlers(); this.removeHandlers();
this.presence_handler = _converse.connection.addHandler( this.presence_handler = _converse.connection.addHandler(
stanza => this.onPresence(stanza) || true, stanza => this.onPresence(stanza) || true,
...@@ -394,17 +437,27 @@ const ChatRoomMixin = { ...@@ -394,17 +437,27 @@ const ChatRoomMixin = {
'presence', 'presence',
null, null,
null, null,
room_jid, muc_jid,
{ 'ignoreNamespaceFragment': true, 'matchBareFromJid': true } { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
); );
const muc_domain = Strophe.getDomainFromJid(muc_jid);
this.domain_presence_handler = _converse.connection.addHandler(
stanza => this.handleMessageFromMUCService(stanza) || true,
null,
'message',
null,
null,
muc_domain
);
this.message_handler = _converse.connection.addHandler( this.message_handler = _converse.connection.addHandler(
stanza => !!this.handleMessageStanza(stanza) || true, stanza => !!this.handleMessageStanza(stanza) || true,
null, null,
'message', 'message',
'groupchat', 'groupchat',
null, null,
room_jid, muc_jid,
{ 'matchBareFromJid': true } { 'matchBareFromJid': true }
); );
...@@ -414,7 +467,7 @@ const ChatRoomMixin = { ...@@ -414,7 +467,7 @@ const ChatRoomMixin = {
'message', 'message',
null, null,
null, null,
room_jid muc_jid
); );
}, },
...@@ -634,11 +687,9 @@ const ChatRoomMixin = { ...@@ -634,11 +687,9 @@ const ChatRoomMixin = {
this.occupants.clearStore(); this.occupants.clearStore();
api.settings.get('muc_clear_messages_on_leave') && this.messages.clearStore(); api.settings.get('muc_clear_messages_on_leave') && this.messages.clearStore();
if (_converse.disco_entities) { const disco_entity = _converse.disco_entities?.get(this.get('jid'));
const disco_entity = _converse.disco_entities.get(this.get('jid')); if (disco_entity) {
if (disco_entity) { await new Promise((success, error) => disco_entity.destroy({ success, error }));
await new Promise((success, error) => disco_entity.destroy({ success, error }));
}
} }
if (api.connection.connected()) { if (api.connection.connected()) {
api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg); api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
...@@ -2071,7 +2122,7 @@ const ChatRoomMixin = { ...@@ -2071,7 +2122,7 @@ const ChatRoomMixin = {
}, },
/** /**
* Handles all MUC presence stanzas. * Handles incoming presence stanzas coming from the MUC
* @private * @private
* @method _converse.ChatRoom#onPresence * @method _converse.ChatRoom#onPresence
* @param { XMLElement } stanza * @param { XMLElement } stanza
...@@ -2216,6 +2267,7 @@ const ChatRoomMixin = { ...@@ -2216,6 +2267,7 @@ const ChatRoomMixin = {
this.sendMarkerForMessage(this.messages.last()); this.sendMarkerForMessage(this.messages.last());
} }
u.safeSave(this, { u.safeSave(this, {
'has_activity': false,
'num_unread': 0, 'num_unread': 0,
'num_unread_general': 0 'num_unread_general': 0
}); });
......
...@@ -11,6 +11,7 @@ import tpl_chatroom_head from 'templates/chatroom_head.js'; ...@@ -11,6 +11,7 @@ import tpl_chatroom_head from 'templates/chatroom_head.js';
import tpl_muc_bottom_panel from 'templates/muc_bottom_panel.js'; import tpl_muc_bottom_panel from 'templates/muc_bottom_panel.js';
import tpl_muc_destroyed from 'templates/muc_destroyed.js'; import tpl_muc_destroyed from 'templates/muc_destroyed.js';
import tpl_muc_disconnect from 'templates/muc_disconnect.js'; import tpl_muc_disconnect from 'templates/muc_disconnect.js';
import { $build, $pres, Strophe } from 'strophe.js/src/strophe';
import tpl_muc_nickname_form from 'templates/muc_nickname_form.js'; import tpl_muc_nickname_form from 'templates/muc_nickname_form.js';
import tpl_spinner from 'templates/spinner.js'; import tpl_spinner from 'templates/spinner.js';
import { Model } from '@converse/skeletor/src/model.js'; import { Model } from '@converse/skeletor/src/model.js';
...@@ -19,7 +20,7 @@ import { _converse, api, converse } from '@converse/headless/core'; ...@@ -19,7 +20,7 @@ import { _converse, api, converse } from '@converse/headless/core';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { render } from 'lit-html'; import { render } from 'lit-html';
const { Strophe, sizzle, $pres } = converse.env; const { sizzle } = converse.env;
const u = converse.env.utils; const u = converse.env.utils;
const OWNER_COMMANDS = ['owner']; const OWNER_COMMANDS = ['owner'];
......
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
} }
}); });
converse.initialize({ converse.initialize({
muc_subscribe_to_rai: true,
theme: 'concord', theme: 'concord',
show_send_button: true, show_send_button: true,
auto_away: 300, auto_away: 300,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment