Commit 21217666 authored by JC Brand's avatar JC Brand

More RAI improvements

- Add test for incoming RAI message
- Only enable RAI if the user is affilated in MUC being left
- Handle error presence indicating a resouce-constraint
- Don't unregister stanza handlers in `leave`, since we still want to
  listen to RAI-related stanzas. Instead unregister upon the `destroy`
  event.
parent fe365a65
......@@ -4,6 +4,7 @@
- #1083: Add support for XEP-0393 Message Styling
- #2275: Allow punctuation to immediately precede a mention
- Add support for XEP-0437 Room Activity Indicators see [muc-subscribe-to-rai](https://conversejs.org/docs/html/configuration.html#muc-subscribe-to-rai)
- Bugfix: Connection protocol not updated based on XEP-0156 connection methods
- Bugfix: `null` inserted by emoji picker and can't switch between skintones
- New hook: [getMessageActionButtons](https://conversejs.org/docs/html/api/-_converse.html#event:getMessageActionButtons)
......
......@@ -1444,7 +1444,7 @@ muc_show_logs_before_join
If set to ``true``, when opening a MUC for the first time (or if you don't have
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.
muc_subscribe_to_rai
......@@ -1452,11 +1452,12 @@ muc_subscribe_to_rai
* Default: ``false``
This option enables support for XEP-0437 Room Activity Indicators.
This option enables support for `XEP-0437 Room Activity Indicators <https://xmpp.org/extensions/xep-0313.html>`_.
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.
When a MUC is no longer visible (specifically, when the ``hidden`` flag becomes ``true``),
then Converse will exit the MUC and subscribe to activity indicators on the MUC host.
When the MUC becomes visible again (``hidden`` gets set to ``false``), the MUC will be rejoined.
.. _`nickname`:
......
......@@ -2367,10 +2367,11 @@ describe("Groupchats", function () {
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
const nick = "some1";
const IQ_stanzas = _converse.connection.IQ_stanzas;
const muc_jid = 'coven@chat.shakespeare.lit';
await _converse.api.rooms.open(muc_jid, {'nick': 'some1'});
await _converse.api.rooms.open(muc_jid, { nick });
const stanza = await u.waitUntil(() => _.filter(
IQ_stanzas,
iq => iq.querySelector(
......@@ -2423,7 +2424,9 @@ describe("Groupchats", function () {
.c('feature', {'var': 'muc_nonanonymous'});
_converse.connection._dataRecv(mock.createRequest(features_stanza));
let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
const sent_stanzas = _converse.connection.sent_stanzas;
await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
expect(view.model.features.get('fetched')).toBeTruthy();
expect(view.model.features.get('passwordprotected')).toBe(true);
......@@ -4412,8 +4415,8 @@ describe("Groupchats", function () {
spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
const sent_IQs = _converse.connection.IQ_stanzas;
const muc_jid = 'coven@chat.shakespeare.lit';
const room_creation_promise = _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
const nick = 'romeo';
const room_creation_promise = _converse.api.rooms.open(muc_jid, {nick});
// Check that the groupchat queried for the features.
let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
......@@ -4441,11 +4444,11 @@ describe("Groupchats", function () {
.c('feature', {'var': 'muc_temporary'}).up()
.c('feature', {'var': 'muc_membersonly'}).up();
_converse.connection._dataRecv(mock.createRequest(features_stanza));
await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
const sent_stanzas = _converse.connection.sent_stanzas;
await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
expect(view.model.features.get('membersonly')).toBeTruthy();
await room_creation_promise;
await mock.createContacts(_converse, 'current');
let sent_stanza, sent_id;
......
......@@ -5,11 +5,14 @@ const u = converse.env.utils;
// See: https://xmpp.org/rfcs/rfc3921.html
fdescribe("XEP-0437 Room Activity Indicators", function () {
describe("XEP-0437 Room Activity Indicators", function () {
it("will be activated for a MUC that becomes hidden",
mock.initConverse(
['rosterGroupsFetched'], {'muc_subscribe_to_rai': true, 'view_mode': 'fullscreen'},
['rosterGroupsFetched'], {
'allow_bookmarks': false, // Hack to get the rooms list to render
'muc_subscribe_to_rai': true,
'view_mode': 'fullscreen'},
async function (done, _converse) {
expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
......@@ -79,18 +82,86 @@ fdescribe("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="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
`<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" 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="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
`<c hash="sha-1" node="https://conversejs.org" ver="Hxbsr5fazs62i+O0GxIXf2OEDNs=" xmlns="http://jabber.org/protocol/caps"/>`+
`<rai xmlns="urn:xmpp:rai:0"/>`+
`</presence>`
);
view.model.save({'hidden': false});
await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED);
expect(view.model.get('has_activity')).toBe(false);
const lview = _converse.rooms_list_view
const room_el = await u.waitUntil(() => lview.el.querySelector(".available-chatroom"));
expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
const activity_stanza = u.toStanza(`
<message from="${Strophe.getDomainFromJid(muc_jid)}">
<rai xmlns="urn:xmpp:rai:0">
<activity>${muc_jid}</activity>
</rai>
</message>
`);
_converse.connection._dataRecv(mock.createRequest(activity_stanza));
await u.waitUntil(() => view.model.get('has_activity'));
expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
done();
}));
it("may not be activated due to server resource constraints",
mock.initConverse(
['rosterGroupsFetched'], {
'allow_bookmarks': false, // Hack to get the rooms list to render
'muc_subscribe_to_rai': true,
'view_mode': 'fullscreen'},
async function (done, _converse) {
expect(_converse.session.get('rai_enabled_domains')).toBe(undefined);
const muc_jid = 'lounge@montague.lit';
const muc_domain = Strophe.getDomainFromJid(muc_jid);
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.api.chatviews.get(muc_jid);
expect(view.model.get('hidden')).toBe(false);
const sent_stanzas = [];
spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
view.model.save({'hidden': true});
await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'presence').length === 2);
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"/>`+
`</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"/>`+
`<rai xmlns="urn:xmpp:rai:0"/>`+
`</presence>`
);
expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.DISCONNECTED);
expect(_converse.session.get('rai_enabled_domains')).toBe(` ${muc_domain}`);
// If an error presence with "resource-constraint" is returned, we rejoin
const activity_stanza = u.toStanza(`
<presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}">
<error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
</presence>
`);
_converse.connection._dataRecv(mock.createRequest(activity_stanza));
await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
expect(_converse.session.get('rai_enabled_domains')).toBe(' ');
done();
}));
......
......@@ -66,6 +66,7 @@ const ChatRoomMixin = {
this.on('change:chat_state', this.sendChatState, this);
this.on('change:hidden', this.onHiddenChange, this);
this.on('destroy', this.removeHandlers, this);
await this.restoreSession();
this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
......@@ -109,20 +110,6 @@ 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
* @private
......@@ -138,6 +125,8 @@ 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();
nick = await this.getAndPersistNickname(nick);
if (!nick) {
......@@ -161,7 +150,6 @@ const ChatRoomMixin = {
if (password) {
stanza.cnode(Strophe.xmlElement('password', [], password));
}
this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
api.send(stanza);
return this;
},
......@@ -200,13 +188,8 @@ 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')) {
async enableRAI () {
if (api.settings.get('muc_subscribe_to_rai') && this.getOwnAffiliation() !== 'none') {
this.sendMarkerForLastMessage('received', true);
if (this.session.get('connection_status') !== converse.ROOMSTATUS.DISCONNECTED) {
await this.leave();
......@@ -217,8 +200,19 @@ const ChatRoomMixin = {
api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
_converse.session.save({ 'rai_enabled_domains': `${rai_enabled} ${muc_domain}` });
}
}
},
/**
* Handler that gets called when the 'hidden' flag is toggled.
* @private
* @method _converse.ChatRoomView#onHiddenChange
*/
onHiddenChange () {
if (this.get('hidden')) {
this.enableRAI();
} else if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
this.onReconnection();
this.rejoin();
}
},
......@@ -259,6 +253,7 @@ const ChatRoomMixin = {
* @method _converse.ChatRoom#rejoin
*/
rejoin () {
this.registerHandlers();
this.clearCache();
return this.join();
},
......@@ -285,7 +280,6 @@ const ChatRoomMixin = {
},
async onReconnection () {
this.registerHandlers();
await this.rejoin();
this.announceReconnection();
},
......@@ -409,6 +403,24 @@ const ChatRoomMixin = {
}
},
/**
* Handles incoming message stanzas from the service that hosts this MUC
* @private
* @method _converse.ChatRoom#handleMessageFromMUCHost
* @param { XMLElement } stanza
*/
handleMessageFromMUCHost (stanza) {
const rai = sizzle(`rai[xmlns="${Strophe.NS.RAI}"]`, stanza).pop();
const active_mucs = Array.from(rai?.querySelectorAll('activity') || []).map(m => m.textContent);
if (active_mucs.includes(this.get('jid'))) {
this.save({
'has_activity': true,
'num_unread_general': 0 // Either/or between activity and unreads
});
}
},
/**
* Parses an incoming message stanza and queues it for processing.
* @private
......@@ -452,6 +464,7 @@ const ChatRoomMixin = {
*/
registerHandlers () {
const muc_jid = this.get('jid');
const muc_domain = Strophe.getDomainFromJid(muc_jid);
this.removeHandlers();
this.presence_handler = _converse.connection.addHandler(
stanza => this.onPresence(stanza) || true,
......@@ -463,11 +476,10 @@ const ChatRoomMixin = {
{ 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
);
const muc_domain = Strophe.getDomainFromJid(muc_jid);
this.domain_presence_handler = _converse.connection.addHandler(
stanza => this.handleMessageFromMUCService(stanza) || true,
stanza => this.onPresenceFromMUCHost(stanza) || true,
null,
'message',
'presence',
null,
null,
muc_domain
......@@ -483,6 +495,15 @@ const ChatRoomMixin = {
{ 'matchBareFromJid': true }
);
this.domain_message_handler = _converse.connection.addHandler(
stanza => this.handleMessageFromMUCHost(stanza) || true,
null,
'message',
null,
null,
muc_domain
);
this.affiliation_message_handler = _converse.connection.addHandler(
stanza => this.handleAffiliationChangedMessage(stanza) || true,
Strophe.NS.MUC_USER,
......@@ -500,10 +521,18 @@ const ChatRoomMixin = {
_converse.connection && _converse.connection.deleteHandler(this.message_handler);
delete this.message_handler;
}
if (this.domain_message_handler) {
_converse.connection && _converse.connection.deleteHandler(this.domain_message_handler);
delete this.domain_message_handler;
}
if (this.presence_handler) {
_converse.connection && _converse.connection.deleteHandler(this.presence_handler);
delete this.presence_handler;
}
if (this.domain_presence_handler) {
_converse.connection && _converse.connection.deleteHandler(this.domain_presence_handler);
delete this.domain_presence_handler;
}
if (this.affiliation_message_handler) {
_converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
delete this.affiliation_message_handler;
......@@ -717,7 +746,6 @@ const ChatRoomMixin = {
api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
}
u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
this.removeHandlers();
},
async close () {
......@@ -1131,7 +1159,7 @@ const ChatRoomMixin = {
* @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
*/
getOwnAffiliation () {
return this.getOwnOccupant()?.attributes?.affiliation;
return this.getOwnOccupant()?.attributes?.affiliation || 'none';
},
/**
......@@ -2142,6 +2170,32 @@ const ChatRoomMixin = {
}
},
/**
* Listens for incoming presence stanzas from the service that hosts this MUC
* @private
* @method _converse.ChatRoom#onPresenceFromMUCHost
* @param { XMLElement } stanza - The presence stanza
*/
onPresenceFromMUCHost (stanza) {
if (stanza.getAttribute('type') === 'error') {
const error = stanza.querySelector('error');
if (error?.getAttribute('type') === 'wait' && error?.querySelector('resource-constraint')) {
// If we get a <resource-constraint> error, we assume it's in context of XEP-0437 RAI.
// We remove this MUC's host from the list of enabled domains and rejoin the MUC.
const rai_enabled = _converse.session.get('rai_enabled_domains') || '';
const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
if (rai_enabled.includes(muc_domain)) {
const regex = new RegExp(muc_domain, 'g');
_converse.session.save({ 'rai_enabled_domains': rai_enabled.replace(regex, '') });
if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
this.rejoin();
}
}
}
}
},
/**
* Handles incoming presence stanzas coming from the MUC
* @private
......@@ -2284,7 +2338,7 @@ const ChatRoomMixin = {
},
clearUnreadMsgCounter () {
if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0) {
if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0 || this.get('has_activity')) {
this.sendMarkerForMessage(this.messages.last());
}
u.safeSave(this, {
......
......@@ -1030,11 +1030,8 @@ const ChatBoxView = View.extend({
onWindowStateChanged (state) {
if (state === 'visible') {
if (!this.model.isHidden()) {
// this.model.setChatState(_converse.ACTIVE);
if (this.model.get('num_unread', 0)) {
this.model.clearUnreadMsgCounter();
}
if (!this.model.isHidden() && this.model.get('num_unread', 0)) {
this.model.clearUnreadMsgCounter();
}
} else if (state === 'hidden') {
this.model.setChatState(_converse.INACTIVE, { 'silent': true });
......
......@@ -11,7 +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_destroyed from 'templates/muc_destroyed.js';
import tpl_muc_disconnect from 'templates/muc_disconnect.js';
import { $build, $pres, Strophe } from 'strophe.js/src/strophe';
import { $pres, Strophe } from 'strophe.js/src/strophe';
import tpl_muc_nickname_form from 'templates/muc_nickname_form.js';
import tpl_spinner from 'templates/spinner.js';
import { Model } from '@converse/skeletor/src/model.js';
......@@ -694,9 +694,7 @@ const ChatRoomViewMixin = {
// Override from converse-chatview, specifically to avoid
// the 'active' chat state from being sent out prematurely.
// This is instead done in `onConnectionStatusChanged` below.
if (u.isPersistableModel(this.model)) {
this.model.clearUnreadMsgCounter();
}
this.model.clearUnreadMsgCounter();
this.scrollDown();
},
......
......@@ -23,31 +23,36 @@ const bookmark = (o) => {
}
const unread_indicator = (o) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ o.room.get('num_unread') }</span>`;
const activity_indicator = () => html`<span class="list-item-badge badge badge--muc msgs-indicator"></span>`;
const room_item = (o) => {
const i18n_leave_room = __('Leave this groupchat');
const unread_indicator = (o) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ o.room.get('num_unread') }</span>`;
const has_unread_msgs = o.room.get('num_unread_general') || o.room.get('has_activity');
return html`
<div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ o.currently_open(o.room) ? 'open' : '' } ${ o.room.get('num_unread_general') ? 'unread-msgs' : '' }"
<div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ o.currently_open(o.room) ? 'open' : '' } ${ has_unread_msgs ? 'unread-msgs' : '' }"
data-room-jid="${o.room.get('jid')}">
${ o.room.get('num_unread') ? unread_indicator(o) : '' }
${ o.room.get('num_unread') ? unread_indicator(o) : (o.room.get('has_activity') ? activity_indicator(o) : '') }
<a class="list-item-link open-room available-room w-100"
data-room-jid="${o.room.get('jid')}"
title="${__('Click to open this groupchat')}"
@click=${o.openRoom}>${o.room.getDisplayName()}</a>
data-room-jid="${o.room.get('jid')}"
title="${__('Click to open this groupchat')}"
@click=${o.openRoom}>${o.room.getDisplayName()}</a>
${ o.allow_bookmarks ? bookmark(o) : '' }
<a class="list-item-action room-info fa fa-info-circle"
data-room-jid="${o.room.get('jid')}"
title="${__('Show more information on this groupchat')}"
@click=${o.showRoomDetailsModal}></a>
data-room-jid="${o.room.get('jid')}"
title="${__('Show more information on this groupchat')}"
@click=${o.showRoomDetailsModal}></a>
<a class="list-item-action fa fa-sign-out-alt close-room"
data-room-jid="${o.room.get('jid')}"
data-room-name="${o.room.getDisplayName()}"
title="${i18n_leave_room}"
@click=${o.closeRoom}></a>
data-room-jid="${o.room.get('jid')}"
data-room-name="${o.room.getDisplayName()}"
title="${i18n_leave_room}"
@click=${o.closeRoom}></a>
</div>`;
}
......
......@@ -30,7 +30,7 @@ const RoomsListView = View.extend({
},
renderIfRelevantChange (model) {
const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general'];
const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity'];
const changed = model.changed || {};
if (u.isChatRoom(model) && Object.keys(changed).filter(m => attrs.includes(m)).length) {
this.render();
......
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