Add option to auto-register your nickname to a room

See https://xmpp.org/extensions/xep-0045.html#register
This commit is contained in:
JC Brand 2018-09-04 11:39:27 +02:00
parent 764686dd19
commit 2df9b24211
12 changed files with 258 additions and 31 deletions

View File

@ -111,7 +111,7 @@
"no-bitwise": "off",
"no-caller": "error",
"no-console": "off",
"no-catch-shadow": "error",
"no-catch-shadow": "off",
"no-cond-assign": [
"error",
"except-parens"

View File

@ -2,6 +2,7 @@
## 4.0.1 (Unreleased)
- New configuration setting [auto_register_muc_nickname](https://conversejs.org/docs/html/configuration.html#auto-register-muc-nickname)
- #1182 MUC occupants without nick or JID created
- #1184 Notification error when message has no body
@ -30,7 +31,7 @@
If the device is not trusted, sessionStorage is used and all user data is deleted from the browser cache upon logout.
If the device is trusted, localStorage is used and user data is cached indefinitely.
- Initial support for [XEP-0357 Push Notifications](https://xmpp.org/extensions/xep-0357.html), specifically registering an "App Server".
- Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configurations.html#oauth-providers) setting)
- Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configuration.html#oauth-providers) setting)
### Bugfixes
@ -61,7 +62,7 @@
## Configuration changes
- Removed the `storage` configuration setting, use [trusted](https://conversejs.org/docs/html/configurations.html#trusted) instead.
- Removed the `storage` configuration setting, use [trusted](https://conversejs.org/docs/html/configuration.html#trusted) instead.
- Removed the `use_vcards` configuration setting, instead VCards are always used.
- Removed the `xhr_custom_status` and `xhr_custom_status_url` configuration
settings. If you relied on these settings, you can instead listen for the
@ -71,8 +72,8 @@
- `xhr_user_search_url` has to include the `?` character now in favor of more flexibility. See example in the documentation.
- The data returned from the `xhr_user_search_url` must now include the user's
`jid` instead of just an `id`.
- New configuration settings [nickname](https://conversejs.org/docs/html/configurations.html#nickname)
and [auto_join_private_chats](https://conversejs.org/docs/html/configurations.html#auto-join-private-chats).
- New configuration settings [nickname](https://conversejs.org/docs/html/configuration.html#nickname)
and [auto_join_private_chats](https://conversejs.org/docs/html/configuration.html#auto-join-private-chats).
## Architectural changes
@ -133,7 +134,7 @@
- Listen for new room bookmarks pushed from the user's PEP service.
- Simplified the [embedded](https://conversejs.org/demo/embedded.html) usecase.
- No need to manually blacklist or whitelist any plugins.
- Relies on the [view_mode](https://conversejs.org/docs/html/configurations.html#view-mode) being set to `'embedded'`.
- Relies on the [view_mode](https://conversejs.org/docs/html/configuration.html#view-mode) being set to `'embedded'`.
- The main `converse.js` build can be used for the embedded usecase.
- Maintain MUC session upon page reload
@ -142,9 +143,9 @@
### Configuration settings
- `auto_reconnect` is now set to `true` by default.
- New configuration setting [allow_public_bookmarks](https://conversejs.org/docs/html/configurations.html#allow-public-bookmarks)
- New configuration setting [root](https://conversejs.org/docs/html/configurations.html#root)
- The [view_mode](https://conversejs.org/docs/html/configurations.html#view-mode) setting now has a new possible value: `embedded`
- New configuration setting [allow_public_bookmarks](https://conversejs.org/docs/html/configuration.html#allow-public-bookmarks)
- New configuration setting [root](https://conversejs.org/docs/html/configuration.html#root)
- The [view_mode](https://conversejs.org/docs/html/configuration.html#view-mode) setting now has a new possible value: `embedded`
### Translation updates
- Chinese (Traditional), French, German, Portuguese (Brazil), Russian, Ukrainian
@ -173,7 +174,7 @@
### UI/UX changes
- Add new configuration option
[show_message_load_animation](https://conversejs.org/docs/html/configurations.html#show-message-load-animation)
[show_message_load_animation](https://conversejs.org/docs/html/configuration.html#show-message-load-animation)
with a default value of `false`. The message load animations (added in 3.3.0)
cause slowness and performance issues in Firefox, so they're now disabled by default.
@ -210,7 +211,7 @@
and private chats with a URL fragment such as `#converse/chat?jid=user@domain`
- #828 Add routing for the `#converse/login` and `#converse/register` URL
fragments, which will render the registration and login forms respectively.
- New configuration setting [view_mode](https://conversejs.org/docs/html/configurations.html#view-mode)
- New configuration setting [view_mode](https://conversejs.org/docs/html/configuration.html#view-mode)
This removes the need for separate `inverse.js` and `converse-mobile.js`
builds. Instead the `converse.js` build is now used with `view_mode` set to
`fullscreen` and `mobile` respectively.
@ -248,7 +249,7 @@
- Converse.js no longer includes all the translations in its build. Instead,
only the currently relevant translation is requested. This results in a much
smaller filesize but means that the translations you want to provide need to
be available. See the [locales_url](https://conversejs.org/docs/html/configurations.html#locales-url)
be available. See the [locales_url](https://conversejs.org/docs/html/configuration.html#locales-url)
configuration setting for more info.
- The translation machinery has now been moved to a separate module in `src/i18n.js`.
- jQuery has been completely removed as a dependency (still used in tests though).
@ -277,10 +278,10 @@
### New configuration settings
* The `visible_toolbar_buttons.emoticons` configuration option is now changed to `visible_toolbar_buttons.emoji`.
* [use_emojione](https://conversejs.org/docs/html/configurations.html#use-emojione)
* [use_emojione](https://conversejs.org/docs/html/configuration.html#use-emojione)
is used to determine whether Emojione should be used to render emojis,
otherwise rendering falls back to native browser or OS support.
* [emojione_image_path](https://conversejs.org/docs/html/configurations.html#emojione-image-path)
* [emojione_image_path](https://conversejs.org/docs/html/configuration.html#emojione-image-path)
is used to specify from where Emojione will load images for rendering emojis.
### New events
@ -334,7 +335,7 @@ More info here: https://github.com/LeaVerou/awesomplete/pull/17082
### New configuration settings
- New setting for `converse-bookmarks`:
[hide_open_bookmarks](https://conversejs.org/docs/html/configurations.html#hide-open-bookmarks)
[hide_open_bookmarks](https://conversejs.org/docs/html/configuration.html#hide-open-bookmarks)
It is meant to be set to `true` when using `converse-roomslist` so that open
rooms aren't listed twice (in the rooms list and the bookmarks list).
[jcbrand]
@ -388,13 +389,13 @@ More info here: https://github.com/LeaVerou/awesomplete/pull/17082
- #628 Fixes the bug in displaying chat status during private chat. [saganshul]
- #628 Changes the message displayed while typing from a different resource of the same user. [smitbose]
- #675 Time format made configurable.
See [time_format](https://conversejs.org/docs/html/configurations.html#time-format)
See [time_format](https://conversejs.org/docs/html/configuration.html#time-format)
[smitbose]
- #682 Add "Send" button to input box in chat dialog window.
See [show_send_button](https://conversejs.org/docs/html/configurations.html#show-send-button)
See [show_send_button](https://conversejs.org/docs/html/configuration.html#show-send-button)
[saganshul]
- #704 Automatic fetching of registration form when
[registration_domain](https://conversejs.org/docs/html/configurations.html#registration-domain)
[registration_domain](https://conversejs.org/docs/html/configuration.html#registration-domain)
is set. [smitbose]
- #806 The `_converse.listen` API event listeners aren't triggered. [jcbrand]
- #807 Error: Plugin "converse-dragresize" tried to override HeadlinesBoxView but it's not found. [jcbrand]

85
dist/converse.js vendored
View File

@ -62864,6 +62864,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('REGISTER', 'jabber:iq:register');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
@ -68982,10 +68983,10 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
* If so, we'll use that, otherwise we render the nickname form.
*/
this.showSpinner();
this.model.checkForReservedNick(this.onNickNameFound.bind(this), this.onNickNameNotFound.bind(this));
this.model.checkForReservedNick(this.onReservedNicknameFound.bind(this), this.onReservedNicknameNotFound.bind(this));
},
onNickNameFound(iq) {
onReservedNicknameFound(iq) {
/* We've received an IQ response from the server which
* might contain the user's reserved nickname.
* If no nickname is found we either render a form for
@ -69005,7 +69006,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
}
},
onNickNameNotFound(message) {
onReservedNicknameNotFound(message) {
const nick = this.getDefaultNickName();
if (nick) {
@ -70032,6 +70033,7 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
allow_muc_invitations: true,
auto_join_on_invite: false,
auto_join_rooms: [],
auto_register_muc_nickname: false,
muc_domain: undefined,
muc_history_max_stanzas: undefined,
muc_instant_rooms: true,
@ -70100,12 +70102,24 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
initialize() {
this.constructor.__super__.initialize.apply(this, arguments);
this.on('change:connection_status', this.onConnectionStatusChanged, this);
this.occupants = new _converse.ChatRoomOccupants();
this.occupants.browserStorage = new Backbone.BrowserStorage.session(b64_sha1(`converse.occupants-${_converse.bare_jid}${this.get('jid')}`));
this.occupants.chatroom = this;
this.registerHandlers();
},
async onConnectionStatusChanged() {
if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED && _converse.auto_register_muc_nickname) {
debugger;
const result = await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'));
if (result.length) {
this.registerNickname();
}
}
},
registerHandlers() {
/* Register presence and message handlers for this chat
* groupchat
@ -70808,6 +70822,53 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
return this;
},
async registerNickname() {
try {
await _converse.api.sendIQ($iq({
'from': _converse.bare_jid,
'to': this.get('jid'),
'type': 'get'
}).c('query', {
'xmlns': Strophe.NS.MUC_REGISTER
}));
} catch (e) {
if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')} which does not exist.`);
} else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`You're not allowed to register in the groupchat ${this.get('jid')}`);
}
return _converse.log(e, Strophe.LogLevel.ERROR);
}
try {
await _converse.api.sendIQ($iq({
'from': _converse.bare_jid,
'to': this.get('jid'),
'type': 'set'
}).c('query', {
'xmlns': Strophe.NS.MUC_REGISTER
}).c('x', {
'xmlns': Strophe.NS.XFORM,
'type': 'submit'
}).c('field', {
'var': 'FORM_TYPE'
}).c('value').t('http://jabber.org/protocol/muc#register').up().up().c('field', {
'var': 'muc#register_roomnick'
}).c('value').t(this.get('nick')));
} catch (e) {
if (sizzle('conflict[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, it's already taken.`);
} else if (sizzle('service-unavailable[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, it doesn't support registration.`);
} else if (sizzle('bad-request[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, invalid data form supplied.`);
}
return _converse.log(e, Strophe.LogLevel.ERROR);
}
},
updateOccupantsOnPresence(pres) {
/* Given a presence stanza, update the occupant model
* based on its contents.
@ -71330,6 +71391,22 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
}
});
}
function fetchRegistrationForm(room_jid, user_jid) {
_converse.api.sendIQ($iq({
'from': user_jid,
'to': room_jid,
'type': 'get'
}).c('query', {
'xmlns': Strophe.NS.REGISTER
})).then(iq => {}).catch(iq => {
if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', iq).length) {
this.feedback.set('error', __(`Error: the groupchat ${this.model.getDisplayName()} does not exist.`));
} else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]').length) {
this.feedback.set('error', __(`Sorry, you're not allowed to registerd in this groupchat`));
}
});
}
/************************ BEGIN Event Handlers ************************/
@ -78494,6 +78571,8 @@ __e(o.info_configure) +
} ;
__p += '\n <a class="chatbox-btn show-room-details-modal fa fa-info-circle" title="' +
__e(o.info_details) +
'"></a>\n <a class="chatbox-btn show-room-registration-modal fa fa-file-signature" title="' +
__e(o.info_register) +
'"></a>\n</div>\n';
return __p
};

View File

@ -328,6 +328,16 @@ wiped from memory. This configuration can however still be useful when using
Converse in desktop apps, for example those based on `CEF <https://bitbucket.org/chromiumembedded/cef>`_
or `electron <http://electron.atom.io/>`_.
auto_register_muc_nickname
--------------------------
* Default: ``false``
Determines whether Converse should automatically register a user's nickname
when they enter a groupchat.
See here fore more details: https://xmpp.org/extensions/xep-0045.html#register
auto_subscribe
--------------

View File

@ -10,7 +10,6 @@
url('webfonts/fa-brands-400.svg#fontawesome') format('svg');
}
@font-face {
font-family: 'ConverseFontAwesomeRegular';
font-style: normal;
@ -46,6 +45,7 @@
font-family: 'ConverseFontAwesomeSolid' !important;
font-weight: 900;
}
.fab {
font-family: 'ConverseFontAwesomeBrands';
}

View File

@ -12,7 +12,7 @@
Backbone = converse.env.Backbone,
u = converse.env.utils;
return describe("ChatRooms", function () {
return describe("Chatrooms", function () {
describe("The \"rooms\" API", function () {
it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments",
@ -816,9 +816,8 @@
null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
function (done, _converse) {
var view;
var sent_IQ, IQ_id;
var sendIQ = _converse.connection.sendIQ;
let view, sent_IQ, IQ_id;
const sendIQ = _converse.connection.sendIQ;
spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
sent_IQ = iq;
IQ_id = sendIQ.bind(this)(iq, callback, errback);

44
spec/room_registration.js Normal file
View File

@ -0,0 +1,44 @@
(function (root, factory) {
define(["jasmine", "mock", "test-utils" ], factory);
} (this, function (jasmine, mock, test_utils) {
const _ = converse.env._,
$iq = converse.env.$iq,
Strophe = converse.env.Strophe,
u = converse.env.utils;
describe("The _converse.api.rooms API", function () {
it("allows you to register a user with a room",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
function (done, _converse) {
let view;
const room_jid = 'coven@chat.shakespeare.lit';
test_utils.openAndEnterChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo')
.then(() => {
view = _converse.chatboxviews.get(room_jid);
_converse.api.rooms.register(room_jid, _converse.bare_jid, 'romeo');
return test_utils.waitUntil(() => _.get(_.filter(
_converse.connection.IQ_stanzas,
iq => iq.nodeTree.querySelector(`iq[to="coven@chat.shakespeare.lit"] query[xmlns="jabber:iq:register"]`)
).pop(), 'nodeTree'));
}).then(stanza => {
expect(stanza.outerHTML)
.toBe(`<iq from="dummy@localhost" to="coven@chat.shakespeare.lit" `+
`type="get" xmlns="jabber:client" id="${stanza.getAttribute('id')}">`+
`<query xmlns="jabber:iq:register"/></iq>`);
// Room does not exist
const result = $iq({
'from': view.model.get('jid'),
'id': stanza.getAttribute('id'),
'to': _converse.bare_jid,
'type': 'error',
}).c('error', {'type': "cancel"})
.c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"})
_converse.connection._dataRecv(test_utils.createRequest(result));
done();
}).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
}));
});
}));

View File

@ -39,6 +39,7 @@
Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('REGISTER', 'jabber:iq:register');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
@ -484,7 +485,7 @@
// Waiting time of less then one second means features aren't used.
return;
}
_converse.idle_seconds = 0;
_converse.idle_seconds = 0
_converse.auto_changed_status = false; // Was the user's status changed by _converse.js?
window.addEventListener('click', _converse.onUserActivity);
window.addEventListener('focus', _converse.onUserActivity);

View File

@ -1204,12 +1204,12 @@
*/
this.showSpinner();
this.model.checkForReservedNick(
this.onNickNameFound.bind(this),
this.onNickNameNotFound.bind(this)
this.onReservedNicknameFound.bind(this),
this.onReservedNicknameNotFound.bind(this)
)
},
onNickNameFound (iq) {
onReservedNicknameFound (iq) {
/* We've received an IQ response from the server which
* might contain the user's reserved nickname.
* If no nickname is found we either render a form for
@ -1228,7 +1228,7 @@
}
},
onNickNameNotFound (message) {
onReservedNicknameNotFound (message) {
const nick = this.getDefaultNickName();
if (nick) {
this.join(nick);

View File

@ -113,6 +113,7 @@
allow_muc_invitations: true,
auto_join_on_invite: false,
auto_join_rooms: [],
auto_register_muc_nickname: false,
muc_domain: undefined,
muc_history_max_stanzas: undefined,
muc_instant_rooms: true,
@ -184,6 +185,8 @@
initialize() {
this.constructor.__super__.initialize.apply(this, arguments);
this.on('change:connection_status', this.onConnectionStatusChanged, this);
this.occupants = new _converse.ChatRoomOccupants();
this.occupants.browserStorage = new Backbone.BrowserStorage.session(
b64_sha1(`converse.occupants-${_converse.bare_jid}${this.get('jid')}`)
@ -192,6 +195,18 @@
this.registerHandlers();
},
async onConnectionStatusChanged () {
if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
_converse.auto_register_muc_nickname &&
this.get('reserved_nick')) {
const result = await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'));
if (result.length) {
this.registerNickname()
}
}
},
registerHandlers () {
/* Register presence and message handlers for this chat
* groupchat
@ -798,6 +813,45 @@
return this;
},
async registerNickname () {
try {
await _converse.api.sendIQ(
$iq({
'from': _converse.bare_jid,
'to': this.get('jid'),
'type': 'get'
}).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
);
} catch (e) {
if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')} which does not exist.`);
} else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`You're not allowed to register in the groupchat ${this.get('jid')}`);
}
return _converse.log(e, Strophe.LogLevel.ERROR);
}
try {
await _converse.api.sendIQ($iq({
'from': _converse.bare_jid,
'to': this.get('jid'),
'type': 'set'
}).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
.c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
.c('field', {'var': 'FORM_TYPE'}).c('value').t('http://jabber.org/protocol/muc#register').up().up()
.c('field', {'var': 'muc#register_roomnick'}).c('value').t(this.get('nick'))
);
} catch (e) {
if (sizzle('conflict[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, it's already taken.`);
} else if (sizzle('service-unavailable[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, it doesn't support registration.`);
} else if (sizzle('bad-request[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', e).length) {
_converse.log(`Can't register nickname ${this.get('nick')} in the groupchat ${this.get('jid')}, invalid data form supplied.`);
}
return _converse.log(e, Strophe.LogLevel.ERROR);
}
},
updateOccupantsOnPresence (pres) {
/* Given a presence stanza, update the occupant model
* based on its contents.
@ -1259,6 +1313,25 @@
});
}
function fetchRegistrationForm (room_jid, user_jid) {
_converse.api.sendIQ(
$iq({
'from': user_jid,
'to': room_jid,
'type': 'get'
}).c('query', {'xmlns': Strophe.NS.REGISTER})
).then(iq => {
}).catch(iq => {
if (sizzle('item-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]', iq).length) {
this.feedback.set('error', __(`Error: the groupchat ${this.model.getDisplayName()} does not exist.`));
} else if (sizzle('not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]').length) {
this.feedback.set('error', __(`Sorry, you're not allowed to registerd in this groupchat`));
}
});
}
/************************ BEGIN Event Handlers ************************/
_converse.on('addClientFeatures', () => {
if (_converse.allow_muc) {

View File

@ -0,0 +1,19 @@
<div class="modal fade" id="room-registration-modal" tabindex="-1" role="dialog" aria-labelledby="room-registration-modal-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="room-registration-modal-label">{{{o.display_name}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="{{{o.label_close}}}"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<form class="converse-form">
{[ if (o.feedback.get('error')) { ]} <div class="alert alert-danger" role="alert">{{{o.feedback.get('error')}}}</div> {[ } ]}
{[ if (!o.feedback.get('error')) { ]} <span class="spinner fa fa-spinner"></span> {[ } ]}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{{o.__('Close')}}}</button>
</div>
</div>
</div>
</div>

View File

@ -202,6 +202,7 @@ var specs = [
"spec/user-details-modal",
"spec/messages",
"spec/chatroom",
"spec/room_registration",
"spec/autocomplete",
"spec/minchats",
"spec/notification",