diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 3d8b1bb2e..183e30d60 100755 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -50,6 +50,9 @@ - Bugfix. `TypeError: this.sendConfiguration(...).then is not a function` when an instant room is created. [jcbrand] - Ensure consistent behavior from `show_controlbox_by_default` [jcbrand] +- #365 Show join/leave messages for chat rooms. + New configuration setting: + [muc_show_join_leave](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave) - #366 Show the chat room occupant's JID in the tooltip (if you're allowed to see it). [jcbrand] - #610, #785 Add presence priority handling [w3host, jcbrand] - #694 The `notification_option` wasn't being used consistently. [jcbrand] diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 65694dfeb..1d4398fff 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -791,6 +791,14 @@ automatically be "john". If now john@differentdomain.com tries to join the room, his nickname will be "john-2", and if john@somethingelse.com joins, then his nickname will be "john-3", and so forth. +muc_show_join_leave +------------------- + +* Default; ``true`` + +Determines whether Converse.js will show info messages inside a chat room +whenever a user joins or leaves it. + notify_all_room_messages ------------------------ diff --git a/spec/chatroom.js b/spec/chatroom.js index 5c8f5a668..112b8c0d9 100644 --- a/spec/chatroom.js +++ b/spec/chatroom.js @@ -345,6 +345,76 @@ describe("A Chat Room", function () { + it("shows join/leave messages when users enter or exit a room", mock.initConverse(function (_converse) { + test_utils.openChatRoom(_converse, "coven", 'chat.shakespeare.lit', 'some1'); + var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + var $chat_content = view.$el.find('.chat-content'); + + /* We don't show join/leave messages for existing occupants. We + * know about them because we receive their presences before we + * receive our own. + */ + presence = $pres({ + to: 'dummy@localhost/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/oldguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'oldguy@localhost/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(test_utils.createRequest(presence)); + expect($chat_content.find('div.chat-info').length).toBe(0); + + /* + * + * + * + * + * + */ + var presence = $pres({ + to: 'dummy@localhost/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'dummy@localhost/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(test_utils.createRequest(presence)); + expect($chat_content.find('div.chat-info:first').html()).toBe("some1 has joined the room"); + + presence = $pres({ + to: 'dummy@localhost/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@localhost/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(test_utils.createRequest(presence)); + expect($chat_content.find('div.chat-info').length).toBe(2); + expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has joined the room"); + + presence = $pres({ + to: 'dummy@localhost/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@localhost/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(test_utils.createRequest(presence)); + expect($chat_content.find('div.chat-info').length).toBe(3); + expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has left the room"); + })); + it("shows its description in the chat heading", mock.initConverse(function (_converse) { var sent_IQ, IQ_id; var sendIQ = _converse.connection.sendIQ; @@ -1036,8 +1106,7 @@ _converse.connection._dataRecv(test_utils.createRequest(stanza)); var view = _converse.chatboxviews.get('jdev@conference.jabber.org'); var $chat_content = view.$el.find('.chat-content'); - expect($chat_content.find('.chat-info').length).toBe(1); - expect($chat_content.find('.chat-info').text()).toBe('Topic set by ralphm to: '+text); + expect($chat_content.find('.chat-info:last').text()).toBe('Topic set by ralphm to: '+text); })); it("escapes the subject before rendering it, to avoid JS-injection attacks", mock.initConverse(function (_converse) { @@ -1047,8 +1116,7 @@ var view = _converse.chatboxviews.get('jdev@conference.jabber.org'); view.setChatRoomSubject('ralphm', subject); var $chat_content = view.$el.find('.chat-content'); - expect($chat_content.find('.chat-info').length).toBe(1); - expect($chat_content.find('.chat-info').text()).toBe('Topic set by ralphm to: '+subject); + expect($chat_content.find('.chat-info:last').text()).toBe('Topic set by ralphm to: '+subject); })); it("informs users if their nicknames has been changed.", mock.initConverse(function (_converse) { @@ -1114,8 +1182,9 @@ expect($occupants.children().length).toBe(1); expect($occupants.children().first(0).text()).toBe("oldnick"); - expect($chat_content.find('div.chat-info').length).toBe(1); - expect($chat_content.find('div.chat-info').html()).toBe(__(_converse.muc.new_nickname_messages["210"], "oldnick")); + expect($chat_content.find('div.chat-info').length).toBe(2); + expect($chat_content.find('div.chat-info:first').html()).toBe("oldnick has joined the room"); + expect($chat_content.find('div.chat-info:last').html()).toBe(__(_converse.muc.new_nickname_messages["210"], "oldnick")); presence = $pres().attrs({ from:'lounge@localhost/oldnick', @@ -1134,7 +1203,7 @@ .c('status').attrs({code:'110'}).nodeTree; _converse.connection._dataRecv(test_utils.createRequest(presence)); - expect($chat_content.find('div.chat-info').length).toBe(2); + expect($chat_content.find('div.chat-info').length).toBe(3); expect($chat_content.find('div.chat-info').last().html()).toBe(__(_converse.muc.new_nickname_messages["303"], "newnick")); $occupants = view.$('.occupant-list'); @@ -1154,8 +1223,9 @@ .c('status').attrs({code:'110'}).nodeTree; _converse.connection._dataRecv(test_utils.createRequest(presence)); - expect($chat_content.find('div.chat-info').length).toBe(2); - expect($chat_content.find('div.chat-info').last().html()).toBe(__(_converse.muc.new_nickname_messages["303"], "newnick")); + expect($chat_content.find('div.chat-info').length).toBe(4); + expect($chat_content.find('div.chat-info').get(2).textContent).toBe(__(_converse.muc.new_nickname_messages["303"], "newnick")); + expect($chat_content.find('div.chat-info').last().html()).toBe("newnick has joined the room"); $occupants = view.$('.occupant-list'); expect($occupants.children().length).toBe(1); expect($occupants.children().first(0).text()).toBe("newnick"); @@ -1400,7 +1470,7 @@ describe("Each chat room can take special commands", function () { - it("to set the room subject", mock.initConverse(function (_converse) { + it("to set the room topic", mock.initConverse(function (_converse) { var sent_stanza; test_utils.openChatRoom(_converse, 'lounge', 'localhost', 'dummy'); var view = _converse.chatboxviews.get('lounge@localhost'); diff --git a/spec/transcripts.js b/spec/transcripts.js index d33c44502..42511a987 100644 --- a/spec/transcripts.js +++ b/spec/transcripts.js @@ -50,10 +50,10 @@ it("can be used to replay conversations", mock.initConverse(function (_converse) { /* - test_utils.openChatRoom("discuss", 'conference.conversejs.org', 'jc'); - test_utils.openChatRoom("dummy", 'rooms.localhost', 'jc'); - test_utils.openChatRoom("prosody", 'conference.prosody.im', 'jc'); + test_utils.openChatRoom(_converse, "dummy", 'rooms.localhost', 'jc'); + test_utils.openChatRoom(_converse, "prosody", 'conference.prosody.im', 'jc'); */ + test_utils.openChatRoom(_converse, "discuss", 'conference.conversejs.org', 'ee'); spyOn(_converse, 'areDesktopNotificationsEnabled').andReturn(true); _.each(transcripts, function (transcript) { var text = transcript(); diff --git a/src/converse-muc.js b/src/converse-muc.js index d17e321f8..424adbf3a 100755 --- a/src/converse-muc.js +++ b/src/converse-muc.js @@ -73,6 +73,13 @@ Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig"); Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user"); + var ROOMSTATUS = { + CONNECTED: 0, + CONNECTING: 1, + DISCONNECTED: 2, + ENTERED: 3 + }; + converse.plugins.add('converse-muc', { /* Optional dependencies are other plugins which might be * overridden or relied upon, if they exist, otherwise they're ignored. @@ -274,6 +281,7 @@ muc_history_max_stanzas: undefined, muc_instant_rooms: true, muc_nickname_from_jid: false, + muc_show_join_leave: true, visible_toolbar_buttons: { 'toggle_occupants': true }, @@ -287,7 +295,7 @@ return _converse.chatboxviews.showChat( _.extend({ 'affiliation': null, - 'connection_status': Strophe.Status.DISCONNECTED, + 'connection_status': ROOMSTATUS.DISCONNECTED, 'description': '', 'features_fetched': false, 'hidden': false, @@ -349,9 +357,9 @@ // Which for some reason doesn't work. // So working around that fact here: this.$el.find('.chat-content').on('scroll', this.markScrolled.bind(this)); - + this.registerHandlers(); - if (this.model.get('connection_status') !== Strophe.Status.CONNECTED) { + if (this.model.get('connection_status') !== ROOMSTATUS.ENTERED) { this.getRoomFeatures().always(function () { that.join(); that.fetchMessages(); @@ -443,7 +451,7 @@ }, afterConnected: function () { - if (this.model.get('connection_status') === Strophe.Status.CONNECTED) { + if (this.model.get('connection_status') === ROOMSTATUS.ENTERED) { this.setChatState(_converse.ACTIVE); this.scrollDown(); this.focus(); @@ -665,7 +673,7 @@ members, _.partial(this.sendAffiliationIQ, this.model.get('jid'), affiliation) ); - return $.when.apply($, promises); + return $.when.apply($, promises); }, setAffiliations: function (members, onSuccess, onError) { @@ -807,7 +815,7 @@ * as taken from the 'chat_state' attribute of the chat box. * See XEP-0085 Chat State Notifications. */ - if (this.model.get('connection_status') !== Strophe.Status.CONNECTED) { + if (this.model.get('connection_status') !== ROOMSTATUS.ENTERED) { return; } var chat_state = this.model.get('chat_state'); @@ -1102,7 +1110,7 @@ if (!nick) { return this.checkForReservedNick(); } - if (this.model.get('connection_status') === Strophe.Status.CONNECTED) { + if (this.model.get('connection_status') === ROOMSTATUS.ENTERED) { // We have restored a chat room from session storage, // so we don't send out a presence stanza again. return this; @@ -1115,13 +1123,13 @@ if (password) { stanza.cnode(Strophe.xmlElement("password", [], password)); } - this.model.save('connection_status', Strophe.Status.CONNECTING); + this.model.save('connection_status', ROOMSTATUS.CONNECTING); _converse.connection.send(stanza); return this; }, cleanup: function () { - this.model.save('connection_status', Strophe.Status.DISCONNECTED); + this.model.save('connection_status', ROOMSTATUS.DISCONNECTED); this.removeHandlers(); _converse.ChatBoxView.prototype.close.apply(this, arguments); }, @@ -1137,7 +1145,7 @@ this.occupantsview.model.reset(); this.occupantsview.model.browserStorage._clear(); if (!_converse.connection.connected || - this.model.get('connection_status') === Strophe.Status.DISCONNECTED) { + this.model.get('connection_status') === ROOMSTATUS.DISCONNECTED) { // Don't send out a stanza if we're not connected. this.cleanup(); return; @@ -1568,26 +1576,23 @@ * current user. * (XMLElement) stanza: The original stanza received. */ - var code = stat.getAttribute('code'), - from_nick; - if (is_self && code === "210") { - from_nick = Strophe.unescapeNode(Strophe.getResourceFromJid(stanza.getAttribute('from'))); - return __(_converse.muc.new_nickname_messages[code], from_nick); - } else if (is_self && code === "303") { - return __( - _converse.muc.new_nickname_messages[code], - stanza.querySelector('x item').getAttribute('nick') - ); - } else if (!is_self && (code in _converse.muc.action_info_messages)) { - from_nick = Strophe.unescapeNode(Strophe.getResourceFromJid(stanza.getAttribute('from'))); - return __(_converse.muc.action_info_messages[code], from_nick); - } else if (code in _converse.muc.info_messages) { + var code = stat.getAttribute('code'), nick; + if (code === '110') { return; } + if (code in _converse.muc.info_messages) { return _converse.muc.info_messages[code]; - } else if (code !== '110') { - if (stat.textContent) { - // Sometimes the status contains human readable text and not a code. - return stat.textContent; + } + if (!is_self) { + if (code in _converse.muc.action_info_messages) { + nick = Strophe.getResourceFromJid(stanza.getAttribute('from')); + return __(_converse.muc.action_info_messages[code], nick); } + } else if (code in _converse.muc.new_nickname_messages) { + if (is_self && code === "210") { + nick = Strophe.getResourceFromJid(stanza.getAttribute('from')); + } else if (is_self && code === "303") { + nick = stanza.querySelector('x item').getAttribute('nick'); + } + return __(_converse.muc.new_nickname_messages[code], nick); } return; }, @@ -1622,9 +1627,11 @@ // 1. Get notification messages based on the elements. var statuses = x.querySelectorAll('status'); var mapper = _.partial(this.getMessageFromStatus, _, stanza, is_self); - var notification = { - 'messages': _.reject(_.map(statuses, mapper), _.isUndefined), - }; + var notification = {}; + var messages = _.reject(_.map(statuses, mapper), _.isUndefined); + if (messages.length) { + notification.messages = messages; + } // 2. Get disconnection messages based on the elements var codes = _.invokeMap(statuses, Element.prototype.getAttribute, 'code'); var disconnection_codes = _.intersection(codes, _.keys(_converse.muc.disconnect_messages)); @@ -1666,7 +1673,7 @@ if (notification.reason) { this.showDisconnectMessage(__(___('The reason given is: "%1$s".'), notification.reason)); } - this.model.save('connection_status', Strophe.Status.DISCONNECTED); + this.model.save('connection_status', ROOMSTATUS.DISCONNECTED); return; } _.each(notification.messages, function (message) { @@ -1680,6 +1687,25 @@ } }, + getJoinLeaveMessages: function (stanza) { + /* Parse the given stanza and return notification messages + * for join/leave events. + */ + // XXX: some mangling required to make the returned + // result look like the structure returned by + // parseXUserElement. Not nice... + var nick = Strophe.getResourceFromJid(stanza.getAttribute('from')); + if (stanza.getAttribute('type') === 'unavailable') { + var stat = stanza.querySelector('status'); + if (!_.isNull(stat) && stat.textContent) { + return [{'messages': [__(nick+' has left the room. "'+stat.textContent+'"')]}]; + } else { + return [{'messages': [__(nick+' has left the room')]}]; + } + } + return [{'messages': [__(nick+' has joined the room')]}]; + }, + showStatusMessages: function (stanza) { /* Check for status codes and communicate their purpose to the user. * See: http://xmpp.org/registrar/mucstatus.html @@ -1688,12 +1714,17 @@ * (XMLElement) stanza: The message or presence stanza * containing the status codes. */ - var is_self = stanza.querySelectorAll("status[code='110']").length; var elements = sizzle('x[xmlns="'+Strophe.NS.MUC_USER+'"]', stanza); - var notifications = _.map( - elements, - _.partial(this.parseXUserElement.bind(this), _, stanza, is_self) - ); + var is_self = stanza.querySelectorAll("status[code='110']").length; + var iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self); + var notifications = _.reject(_.map(elements, iteratee), _.isEmpty); + if (_.isEmpty(notifications) && + _converse.muc_show_join_leave && + stanza.nodeName === 'presence' && + this.model.get('connection_status') === ROOMSTATUS.ENTERED + ) { + notifications = this.getJoinLeaveMessages(stanza); + } _.each(notifications, this.displayNotificationsforUser.bind(this)); return stanza; }, @@ -1767,11 +1798,10 @@ * (XMLElement) pres: The stanza */ if (pres.getAttribute('type') === 'error') { - this.model.save('connection_status', Strophe.Status.DISCONNECTED); + this.model.save('connection_status', ROOMSTATUS.DISCONNECTED); this.showErrorMessage(pres); return true; } - var show_status_messages = true; var is_self = pres.querySelector("status[code='110']"); var locked_room = pres.querySelector("status[code='201']"); if (is_self) { @@ -1784,15 +1814,14 @@ } else { this.configureChatRoom(); if (!this.model.get('auto_configure')) { - // We don't show status messages if the - // configuration form is being shown. - show_status_messages = false; + return; } } } + this.model.save('connection_status', ROOMSTATUS.ENTERED); } if (!locked_room && !this.model.get('features_fetched') && - this.model.get('connection_status') !== Strophe.Status.CONNECTED) { + this.model.get('connection_status') !== ROOMSTATUS.CONNECTED) { // The features for this room weren't fetched yet, perhaps // because it's a new room without locking (in which // case Prosody doesn't send a 201 status). @@ -1800,12 +1829,11 @@ // so a good time to fetch the features. this.getRoomFeatures(); } - if (show_status_messages) { - this.hideSpinner().showStatusMessages(pres); - } + this.hideSpinner().showStatusMessages(pres); this.occupantsview.updateOccupantsOnPresence(pres); - if (this.model.get('role') !== 'none') { - this.model.save('connection_status', Strophe.Status.CONNECTED); + if (this.model.get('role') !== 'none' && + this.model.get('connection_status') === ROOMSTATUS.CONNECTING) { + this.model.save('connection_status', ROOMSTATUS.CONNECTED); } return true; }, @@ -2425,10 +2453,7 @@ 'box_id': b64_sha1(room_jid), 'password': $x.attr('password') }); - if (!_.includes( - [Strophe.Status.CONNECTING, Strophe.Status.CONNECTED], - chatroom.get('connection_status')) - ) { + if (chatroom.get('connection_status') === ROOMSTATUS.DISCONNECTED) { _converse.chatboxviews.get(room_jid).join(); } } @@ -2549,7 +2574,7 @@ */ _converse.chatboxviews.each(function (view) { if (view.model.get('type') === 'chatroom') { - view.model.save('connection_status', Strophe.Status.DISCONNECTED); + view.model.save('connection_status', ROOMSTATUS.DISCONNECTED); view.join(); } }); @@ -2563,7 +2588,7 @@ */ _converse.chatboxes.each(function (model) { if (model.get('type') === 'chatroom') { - model.save('connection_status', Strophe.Status.DISCONNECTED); + model.save('connection_status', ROOMSTATUS.DISCONNECTED); } }); };