diff --git a/spec/muc.js b/spec/muc.js index 16fdc9f1b..518372b94 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -145,10 +145,8 @@ chatroomview.close(); _converse.muc_instant_rooms = false; - var sendIQ = _converse.connection.sendIQ; + const sendIQ = _converse.connection.sendIQ; spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { - sent_IQ = iq; - sent_IQ_els.push(iq.nodeTree); IQ_id = sendIQ.bind(this)(iq, callback, errback); }); // Test with configuration @@ -199,15 +197,18 @@ .c('status', {code:'201'}); _converse.connection._dataRecv(test_utils.createRequest(presence)); expect(_converse.connection.sendIQ).toHaveBeenCalled(); - expect(sent_IQ.toLocaleString()).toBe( - ``+ - `` - ); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const iq = IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop(); + expect(Strophe.serialize(iq)).toBe( + ``+ + ``); + const node = u.toStanza(` + from="room@conference.example.org" id="${iq.getAttribute('id')}"> Configuration for room@conference.example.org @@ -244,10 +245,8 @@ spyOn(chatroomview.model, 'sendConfiguration').and.callThrough(); _converse.connection._dataRecv(test_utils.createRequest(node)); await test_utils.waitUntil(() => chatroomview.model.sendConfiguration.calls.count() === 1); - var sent_stanza = sent_IQ_els.pop(); - while (sent_stanza.getAttribute('type') !== 'set') { - sent_stanza = sent_IQ_els.pop(); - } + + const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop(); expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent).toBe('Room'); expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent).toBe('Welcome to this groupchat'); expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent).toBe('1'); @@ -372,17 +371,19 @@ .c('status').attrs({code:'201'}).nodeTree; _converse.connection._dataRecv(test_utils.createRequest(presence)); - var info_text = view.el.querySelector('.chat-content .chat-info').textContent; + const info_text = view.el.querySelector('.chat-content .chat-info').textContent; expect(info_text).toBe('A new groupchat has been created'); + // An instant room is created by saving the default configuratoin. // /* * * */ - expect(sent_IQ.toLocaleString()).toBe( - ``+ + const iq = IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop(); + expect(Strophe.serialize(iq)).toBe( + ``+ ``+ ``); done(); diff --git a/src/headless/converse-bookmarks.js b/src/headless/converse-bookmarks.js new file mode 100644 index 000000000..88087207f --- /dev/null +++ b/src/headless/converse-bookmarks.js @@ -0,0 +1,293 @@ +// Converse.js (A browser based XMPP chat client) +// https://conversejs.org +// +// Copyright (c) 2012-2017, Jan-Carel Brand +// Licensed under the Mozilla Public License (MPLv2) +// +/*global define */ + +/* This is a Converse.js plugin which adds views for bookmarks specified + * in XEP-0048. + */ + +import converse from "@converse/headless/converse-core"; +import muc from "@converse/headless/converse-muc"; + +const { Backbone, Promise, Strophe, $iq, sizzle, _ } = converse.env; +const u = converse.env.utils; + + +converse.plugins.add('converse-bookmarks', { + + /* Plugin dependencies are other plugins which might be + * overridden or relied upon, and therefore need to be loaded before + * this plugin. + * + * If the setting "strict_plugin_dependencies" is set to true, + * an error will be raised if the plugin is not found. By default it's + * false, which means these plugins are only loaded opportunistically. + * + * NB: These plugins need to have already been loaded via require.js. + */ + dependencies: ["converse-chatboxes", "converse-muc"], + + overrides: { + // Overrides mentioned here will be picked up by converse.js's + // plugin architecture they will replace existing methods on the + // relevant objects or classes. + // + // New functions which don't exist yet can also be added. + + ChatRoom: { + + getAndPersistNickname(nick) { + const { _converse } = this.__super__; + nick = nick || _converse.getNicknameFromBookmark(this.get('jid')); + return this.__super__.getAndPersistNickname.call(this, nick); + }, + + } + }, + + initialize () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + const { _converse } = this, + { __ } = _converse; + + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + _converse.api.settings.update({ + allow_bookmarks: true, + allow_public_bookmarks: false + }); + + /** + * Check if the user has a bookmark with a saved nickanme + * for this groupchat and return it. + * @private + * @method _converse#getNicknameFromBookmark + */ + _converse.getNicknameFromBookmark = function (jid) { + if (_converse.bookmarks || !_converse.allow_bookmarks) { + return null; + } + const bookmark = _converse.bookmarks.findWhere({'jid': jid}); + if (bookmark) { + return bookmark.get('nick'); + } + } + + _converse.Bookmark = Backbone.Model; + + _converse.Bookmarks = Backbone.Collection.extend({ + model: _converse.Bookmark, + comparator: (item) => item.get('name').toLowerCase(), + + initialize () { + this.on('add', _.flow(this.openBookmarkedRoom, this.markRoomAsBookmarked)); + this.on('remove', this.markRoomAsUnbookmarked, this); + this.on('remove', this.sendBookmarkStanza, this); + + const storage = _converse.config.get('storage'), + cache_key = `converse.room-bookmarks${_converse.bare_jid}`; + this.fetched_flag = cache_key+'fetched'; + this.browserStorage = new Backbone.BrowserStorage[storage](cache_key); + }, + + openBookmarkedRoom (bookmark) { + if ( _converse.muc_respect_autojoin && bookmark.get('autojoin')) { + const groupchat = _converse.api.rooms.create(bookmark.get('jid'), bookmark.get('nick')); + groupchat.maybeShow(); + } + return bookmark; + }, + + fetchBookmarks () { + const deferred = u.getResolveablePromise(); + if (this.browserStorage.records.length > 0) { + this.fetch({ + 'success': () => deferred.resolve(), + 'error': () => deferred.resolve() + }); + } else if (! window.sessionStorage.getItem(this.fetched_flag)) { + // There aren't any cached bookmarks and the + // `fetched_flag` is off, so we query the XMPP server. + // If nothing is returned from the XMPP server, we set + // the `fetched_flag` to avoid calling the server again. + this.fetchBookmarksFromServer(deferred); + } else { + deferred.resolve(); + } + return deferred; + }, + + createBookmark (options) { + this.create(options); + this.sendBookmarkStanza().catch(iq => this.onBookmarkError(iq, options)); + }, + + sendBookmarkStanza () { + const stanza = $iq({ + 'type': 'set', + 'from': _converse.connection.jid, + }) + .c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('publish', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns':'storage:bookmarks'}); + this.each(model => { + stanza.c('conference', { + 'name': model.get('name'), + 'autojoin': model.get('autojoin'), + 'jid': model.get('jid'), + }).c('nick').t(model.get('nick')).up().up(); + }); + stanza.up().up().up(); + stanza.c('publish-options') + .c('x', {'xmlns': Strophe.NS.XFORM, 'type':'submit'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('http://jabber.org/protocol/pubsub#publish-options').up().up() + .c('field', {'var':'pubsub#persist_items'}) + .c('value').t('true').up().up() + .c('field', {'var':'pubsub#access_model'}) + .c('value').t('whitelist'); + return _converse.api.sendIQ(stanza); + }, + + onBookmarkError (iq, options) { + _converse.log("Error while trying to add bookmark", Strophe.LogLevel.ERROR); + _converse.log(iq); + _converse.api.alert.show( + Strophe.LogLevel.ERROR, + __('Error'), [__("Sorry, something went wrong while trying to save your bookmark.")] + ) + this.findWhere({'jid': options.jid}).destroy(); + }, + + fetchBookmarksFromServer (deferred) { + const stanza = $iq({ + 'from': _converse.connection.jid, + 'type': 'get', + }).c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('items', {'node': 'storage:bookmarks'}); + _converse.api.sendIQ(stanza) + .then((iq) => this.onBookmarksReceived(deferred, iq)) + .catch((iq) => this.onBookmarksReceivedError(deferred, iq) + ); + }, + + markRoomAsBookmarked (bookmark) { + const groupchat = _converse.chatboxes.get(bookmark.get('jid')); + if (!_.isUndefined(groupchat)) { + groupchat.save('bookmarked', true); + } + }, + + markRoomAsUnbookmarked (bookmark) { + const groupchat = _converse.chatboxes.get(bookmark.get('jid')); + if (!_.isUndefined(groupchat)) { + groupchat.save('bookmarked', false); + } + }, + + createBookmarksFromStanza (stanza) { + const bookmarks = sizzle( + `items[node="storage:bookmarks"] item storage[xmlns="storage:bookmarks"] conference`, + stanza + ); + _.forEach(bookmarks, (bookmark) => { + const jid = bookmark.getAttribute('jid'); + this.create({ + 'jid': jid, + 'name': bookmark.getAttribute('name') || jid, + 'autojoin': bookmark.getAttribute('autojoin') === 'true', + 'nick': _.get(bookmark.querySelector('nick'), 'textContent') + }); + }); + }, + + onBookmarksReceived (deferred, iq) { + this.createBookmarksFromStanza(iq); + if (!_.isUndefined(deferred)) { + return deferred.resolve(); + } + }, + + onBookmarksReceivedError (deferred, iq) { + window.sessionStorage.setItem(this.fetched_flag, true); + _converse.log('Error while fetching bookmarks', Strophe.LogLevel.ERROR); + _converse.log(iq.outerHTML, Strophe.LogLevel.DEBUG); + if (!_.isNil(deferred)) { + if (iq.querySelector('error[type="cancel"] item-not-found')) { + // Not an exception, the user simply doesn't have + // any bookmarks. + return deferred.resolve(); + } else { + return deferred.reject(new Error("Could not fetch bookmarks")); + } + } + } + }); + + _converse.BookmarksList = Backbone.Model.extend({ + defaults: { + "toggle-state": _converse.OPENED + } + }); + + _converse.checkBookmarksSupport = async function () { + const identity = await _converse.api.disco.getIdentity('pubsub', 'pep', _converse.bare_jid); + if (_converse.allow_public_bookmarks) { + return !!identity; + } else { + return _converse.api.disco.supports(Strophe.NS.PUBSUB+'#publish-options', _converse.bare_jid); + } + } + + const initBookmarks = async function () { + if (!_converse.allow_bookmarks) { + return; + } + const supported = await _converse.checkBookmarksSupport(); + if (supported) { + _converse.bookmarks = new _converse.Bookmarks(); + await _converse.bookmarks.fetchBookmarks(); + /** + * Triggered once the _converse.Bookmarks collection + * has been created and cached bookmarks have been fetched. + * @event _converse#bookmarksInitialized + * @example _converse.api.listen.on('bookmarksInitialized', () => { ... }); + */ + _converse.api.trigger('bookmarksInitialized'); + } + } + + _converse.api.listen.on('clearSession', () => { + if (!_.isUndefined(_converse.bookmarks)) { + _converse.bookmarks.reset(); + _converse.bookmarks.browserStorage._clear(); + window.sessionStorage.removeItem(_converse.bookmarks.fetched_flag); + } + }); + + _converse.api.listen.on('reconnected', initBookmarks); + + _converse.api.listen.on('connected', async () => { + // Add a handler for bookmarks pushed from other connected clients + _converse.connection.addHandler(message => { + if (sizzle('event[xmlns="'+Strophe.NS.PUBSUB+'#event"] items[node="storage:bookmarks"]', message).length) { + _converse.api.waitUntil('bookmarksInitialized') + .then(() => _converse.bookmarks.createBookmarksFromStanza(message)) + .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL)); + } + }, null, 'message', 'headline', null, _converse.bare_jid); + + await Promise.all([_converse.api.waitUntil('chatBoxesFetched')]); + initBookmarks(); + }); + } +}); diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 3978311af..c5b50fcf5 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -221,13 +221,15 @@ converse.plugins.add('converse-muc', { }, async onConnectionStatusChanged () { - if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED && - _converse.auto_register_muc_nickname && - !this.get('reserved_nick') && - await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'))) { - + if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) { this.occupants.fetchMembers(); - this.registerNickname() + + if (_converse.auto_register_muc_nickname && + !this.get('reserved_nick') && + await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'))) { + + this.registerNickname() + } } }, @@ -664,7 +666,7 @@ converse.plugins.add('converse-muc', { * Given a element, return a copy with a child if * we can find a value for it in this rooms config. * @private - * @method _converse.ChatRoom#autoConfigureChatRoom + * @method _converse.ChatRoom#addFieldValue * @returns { Element } */ addFieldValue (field) {