converse.js
Version:
Browser based XMPP chat client
1,135 lines (1,047 loc) • 108 kB
JavaScript
// Converse.js
// https://conversejs.org
//
// Copyright (c) 2013-2019, the Converse.js developers
// Licensed under the Mozilla Public License (MPLv2)
//
/**
* @module converse-muc
* @description
* Implements the non-view logic for XEP-0045 Multi-User Chat
*/
import "./converse-disco";
import "./converse-emoji";
import "./utils/muc";
import BrowserStorage from "backbone.browserStorage";
import converse from "./converse-core";
import u from "./utils/form";
const MUC_ROLE_WEIGHTS = {
'moderator': 1,
'participant': 2,
'visitor': 3,
'none': 2,
};
const { Strophe, Backbone, $iq, $build, $msg, $pres, sizzle, _ } = converse.env;
// Add Strophe Namespaces
Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner");
Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register");
Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
converse.MUC_NICK_CHANGED_CODE = "303";
converse.ROOM_FEATURES = [
'passwordprotected', 'unsecured', 'hidden',
'publicroom', 'membersonly', 'open', 'persistent',
'temporary', 'nonanonymous', 'semianonymous',
'moderated', 'unmoderated', 'mam_enabled'
];
// No longer used in code, but useful as reference.
//
// const ROOM_FEATURES_MAP = {
// 'passwordprotected': 'unsecured',
// 'unsecured': 'passwordprotected',
// 'hidden': 'publicroom',
// 'publicroom': 'hidden',
// 'membersonly': 'open',
// 'open': 'membersonly',
// 'persistent': 'temporary',
// 'temporary': 'persistent',
// 'nonanonymous': 'semianonymous',
// 'semianonymous': 'nonanonymous',
// 'moderated': 'unmoderated',
// 'unmoderated': 'moderated'
// };
converse.ROOMSTATUS = {
CONNECTED: 0,
CONNECTING: 1,
NICKNAME_REQUIRED: 2,
PASSWORD_REQUIRED: 3,
DISCONNECTED: 4,
ENTERED: 5,
DESTROYED: 6
};
converse.plugins.add('converse-muc', {
/* Optional dependencies are other plugins which might be
* overridden or relied upon, and therefore need to be loaded before
* this plugin. They are called "optional" because they might not be
* available, in which case any overrides applicable to them will be
* ignored.
*
* It's possible however to make optional dependencies non-optional.
* If the setting "strict_plugin_dependencies" is set to true,
* an error will be raised if the plugin is not found.
*
* NB: These plugins need to have already been loaded via require.js.
*/
dependencies: ["converse-chatboxes", "converse-disco", "converse-controlbox"],
overrides: {
ChatBoxes: {
model (attrs, options) {
const { _converse } = this.__super__;
if (attrs.type == _converse.CHATROOMS_TYPE) {
return new _converse.ChatRoom(attrs, options);
} else {
return this.__super__.model.apply(this, arguments);
}
},
}
},
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_muc': true,
'allow_muc_invitations': true,
'auto_join_on_invite': false,
'auto_join_rooms': [],
'auto_register_muc_nickname': false,
'locked_muc_domain': false,
'muc_domain': undefined,
'muc_fetch_members': true,
'muc_history_max_stanzas': undefined,
'muc_instant_rooms': true,
'muc_nickname_from_jid': false
});
_converse.api.promises.add(['roomsAutoJoined']);
if (_converse.locked_muc_domain && !_.isString(_converse.muc_domain)) {
throw new Error("Config Error: it makes no sense to set locked_muc_domain "+
"to true when muc_domain is not set");
}
function ___ (str) {
/* This is part of a hack to get gettext to scan strings to be
* translated. Strings we cannot send to the function above because
* they require variable interpolation and we don't yet have the
* variables at scan time.
*/
return str;
}
/* https://xmpp.org/extensions/xep-0045.html
* ----------------------------------------
* 100 message Entering a groupchat Inform user that any occupant is allowed to see the user's full JID
* 101 message (out of band) Affiliation change Inform user that his or her affiliation changed while not in the groupchat
* 102 message Configuration change Inform occupants that groupchat now shows unavailable members
* 103 message Configuration change Inform occupants that groupchat now does not show unavailable members
* 104 message Configuration change Inform occupants that a non-privacy-related groupchat configuration change has occurred
* 110 presence Any groupchat presence Inform user that presence refers to one of its own groupchat occupants
* 170 message or initial presence Configuration change Inform occupants that groupchat logging is now enabled
* 171 message Configuration change Inform occupants that groupchat logging is now disabled
* 172 message Configuration change Inform occupants that the groupchat is now non-anonymous
* 173 message Configuration change Inform occupants that the groupchat is now semi-anonymous
* 174 message Configuration change Inform occupants that the groupchat is now fully-anonymous
* 201 presence Entering a groupchat Inform user that a new groupchat has been created
* 210 presence Entering a groupchat Inform user that the service has assigned or modified the occupant's roomnick
* 301 presence Removal from groupchat Inform user that he or she has been banned from the groupchat
* 303 presence Exiting a groupchat Inform all occupants of new groupchat nickname
* 307 presence Removal from groupchat Inform user that he or she has been kicked from the groupchat
* 321 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because of an affiliation change
* 322 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
* 332 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because of a system shutdown
*/
_converse.muc = {
info_messages: {
100: __('This groupchat is not anonymous'),
102: __('This groupchat now shows unavailable members'),
103: __('This groupchat does not show unavailable members'),
104: __('The groupchat configuration has changed'),
170: __('Groupchat logging is now enabled'),
171: __('Groupchat logging is now disabled'),
172: __('This groupchat is now no longer anonymous'),
173: __('This groupchat is now semi-anonymous'),
174: __('This groupchat is now fully-anonymous'),
201: __('A new groupchat has been created')
},
new_nickname_messages: {
// XXX: Note the triple underscore function and not double underscore.
210: ___('Your nickname has been automatically set to %1$s'),
303: ___('Your nickname has been changed to %1$s')
},
disconnect_messages: {
301: __('You have been banned from this groupchat'),
307: __('You have been kicked from this groupchat'),
321: __("You have been removed from this groupchat because of an affiliation change"),
322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
332: __("You have been removed from this groupchat because the service hosting it is being shut down")
},
action_info_messages: {
// XXX: Note the triple underscore function and not double underscore.
301: ___("%1$s has been banned"),
303: ___("%1$s's nickname has changed"),
307: ___("%1$s has been kicked out"),
321: ___("%1$s has been removed because of an affiliation change"),
322: ___("%1$s has been removed for not being a member")
}
}
async function openRoom (jid) {
if (!u.isValidMUCJID(jid)) {
return _converse.log(
`Invalid JID "${jid}" provided in URL fragment`,
Strophe.LogLevel.WARN
);
}
await _converse.api.waitUntil('roomsAutoJoined');
if (_converse.allow_bookmarks) {
await _converse.api.waitUntil('bookmarksInitialized');
}
_converse.api.rooms.open(jid);
}
_converse.router.route('converse/room?jid=:jid', openRoom);
_converse.getDefaultMUCNickname = function () {
// XXX: if anything changes here, update the docs for the
// locked_muc_nickname setting.
if (!_converse.xmppstatus) {
throw new Error(
"Can't call _converse.getDefaultMUCNickname before the statusInitialized has been fired.");
}
const nick = _converse.xmppstatus.getNickname();
if (nick) {
return nick;
} else if (_converse.muc_nickname_from_jid) {
return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
}
}
function openChatRoom (jid, settings) {
/* Opens a groupchat, making sure that certain attributes
* are correct, for example that the "type" is set to
* "chatroom".
*/
settings.type = _converse.CHATROOMS_TYPE;
settings.id = jid;
const chatbox = _converse.chatboxes.getChatBox(jid, settings, true);
chatbox.maybeShow(true);
return chatbox;
}
/**
* Represents a MUC message
* @class
* @namespace _converse.ChatRoomMessage
* @memberOf _converse
*/
_converse.ChatRoomMessage = _converse.Message.extend({
initialize () {
if (this.get('file')) {
this.on('change:put', this.uploadFile, this);
}
if (this.isEphemeral()) {
window.setTimeout(this.safeDestroy.bind(this), 10000);
} else {
this.setOccupant();
this.setVCard();
}
},
onOccupantRemoved () {
this.stopListening(this.occupant);
delete this.occupant;
const chatbox = _.get(this, 'collection.chatbox');
if (!chatbox) {
return _converse.log(
`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
Strophe.LogLevel.ERROR
);
}
this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
},
onOccupantAdded (occupant) {
if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) {
this.occupant = occupant;
this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
const chatbox = _.get(this, 'collection.chatbox');
if (!chatbox) {
return _converse.log(
`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
Strophe.LogLevel.ERROR
);
}
this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
}
},
setOccupant () {
if (this.get('type') !== 'groupchat') { return; }
const chatbox = _.get(this, 'collection.chatbox');
if (!chatbox) {
return _converse.log(
`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
Strophe.LogLevel.ERROR
);
}
const nick = Strophe.getResourceFromJid(this.get('from'));
this.occupant = chatbox.occupants.findWhere({'nick': nick});
if (this.occupant) {
this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
} else {
this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
}
},
getVCardForChatroomOccupant () {
const chatbox = _.get(this, 'collection.chatbox');
const nick = Strophe.getResourceFromJid(this.get('from'));
if (chatbox && chatbox.get('nick') === nick) {
return _converse.xmppstatus.vcard;
} else {
let vcard;
if (this.get('vcard_jid')) {
vcard = _converse.vcards.findWhere({'jid': this.get('vcard_jid')});
}
if (!vcard) {
let jid;
if (this.occupant && this.occupant.get('jid')) {
jid = this.occupant.get('jid');
this.save({'vcard_jid': jid}, {'silent': true});
} else {
jid = this.get('from');
}
if (jid) {
vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
} else {
_converse.log(
`Could not assign VCard for message because no JID found! msgid: ${this.get('msgid')}`,
Strophe.LogLevel.ERROR
);
return;
}
}
return vcard;
}
},
setVCard () {
if (!_converse.vcards) {
// VCards aren't supported
return;
}
if (['error', 'info'].includes(this.get('type'))) {
return;
} else {
this.vcard = this.getVCardForChatroomOccupant();
}
},
});
/**
* Collection which stores MUC messages
* @class
* @namespace _converse.ChatRoomMessages
* @memberOf _converse
*/
_converse.ChatRoomMessages = _converse.Collection.extend({
model: _converse.ChatRoomMessage,
comparator: 'time'
});
/**
* Represents an open/ongoing groupchat conversation.
* @class
* @namespace _converse.ChatRoom
* @memberOf _converse
*/
_converse.ChatRoom = _converse.ChatBox.extend({
messagesCollection: _converse.ChatRoomMessages,
defaults () {
return {
// For group chats, we distinguish between generally unread
// messages and those ones that specifically mention the
// user.
//
// To keep things simple, we reuse `num_unread` from
// _converse.ChatBox to indicate unread messages which
// mention the user and `num_unread_general` to indicate
// generally unread messages (which *includes* mentions!).
'num_unread_general': 0,
'bookmarked': false,
'chat_state': undefined,
'connection_status': converse.ROOMSTATUS.DISCONNECTED,
'description': '',
'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode),
'message_type': 'groupchat',
'name': '',
'num_unread': 0,
'roomconfig': {},
'time_sent': (new Date(0)).toISOString(),
'time_opened': this.get('time_opened') || (new Date()).getTime(),
'type': _converse.CHATROOMS_TYPE
}
},
async initialize() {
if (_converse.vcards) {
this.vcard = _converse.vcards.findWhere({'jid': this.get('jid')}) ||
_converse.vcards.create({'jid': this.get('jid')});
}
this.set('box_id', `box-${btoa(this.get('jid'))}`);
this.initFeatures(); // sendChatState depends on this.features
this.on('change:chat_state', this.sendChatState, this);
this.on('change:connection_status', this.onConnectionStatusChanged, this);
this.initMessages();
this.registerHandlers();
await this.initOccupants();
await this.fetchMessages();
this.enterRoom();
},
async enterRoom () {
const conn_status = this.get('connection_status');
_converse.log(
`${this.get('jid')} initialized with connection_status ${conn_status}`,
Strophe.LogLevel.DEBUG
);
if (conn_status !== converse.ROOMSTATUS.ENTERED) {
// We're not restoring a room from cache, so let's clear the potentially stale cache.
this.removeNonMembers();
await this.refreshRoomFeatures();
if (_converse.clear_messages_on_reconnection) {
this.clearMessages();
}
if (!u.isPersistableModel(this)) {
// XXX: Happens during tests, nothing to do if this
// is a hanging chatbox (i.e. not in the collection anymore).
return;
}
this.join();
} else if (!(await this.rejoinIfNecessary())) {
// We've restored the room from cache and we're still joined.
this.features.fetch();
}
},
async onConnectionStatusChanged () {
if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
if (_converse.muc_fetch_members) {
await this.occupants.fetchMembers();
}
/**
* Triggered when the user has entered a new MUC
* @event _converse#enteredNewRoom
* @type { _converse.ChatRoom}
* @example _converse.api.listen.on('enteredNewRoom', model => { ... });
*/
_converse.api.trigger('enteredNewRoom', this);
if (_converse.auto_register_muc_nickname &&
await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'))) {
this.registerNickname()
}
}
},
removeNonMembers () {
const non_members = this.occupants.filter(o => !o.isMember());
if (non_members.length) {
non_members.forEach(o => o.destroy());
}
},
async onReconnection () {
this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
this.registerHandlers();
await this.enterRoom();
this.announceReconnection();
},
initFeatures () {
const id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
this.features = new Backbone.Model(
_.assign({id}, _.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse)))
);
this.features.browserStorage = new BrowserStorage.session(id);
},
initOccupants () {
this.occupants = new _converse.ChatRoomOccupants();
this.occupants.browserStorage = new BrowserStorage.session(
`converse.occupants-${_converse.bare_jid}${this.get('jid')}`
);
this.occupants.chatroom = this;
this.occupants.fetched = new Promise(resolve => {
this.occupants.fetch({
'add': true,
'silent': true,
'success': resolve,
'error': resolve
});
});
return this.occupants.fetched;
},
registerHandlers () {
// Register presence and message handlers for this groupchat
const room_jid = this.get('jid');
this.removeHandlers();
this.presence_handler = _converse.connection.addHandler(stanza => {
this.onPresence(stanza);
return true;
},
null, 'presence', null, null, room_jid,
{'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
);
this.message_handler = _converse.connection.addHandler(stanza => {
if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) {
// MAM messages are handled in converse-mam.
// We shouldn't get MAM messages here because
// they shouldn't have a `type` attribute.
_converse.log(`Received a MAM message with type "chat".`, Strophe.LogLevel.WARN);
return true;
}
this.onMessage(stanza);
return true;
}, null, 'message', 'groupchat', null, room_jid,
{'matchBareFromJid': true}
);
},
removeHandlers () {
/* Remove the presence and message handlers that were
* registered for this groupchat.
*/
if (this.message_handler) {
_converse.connection.deleteHandler(this.message_handler);
delete this.message_handler;
}
if (this.presence_handler) {
_converse.connection.deleteHandler(this.presence_handler);
delete this.presence_handler;
}
return this;
},
getDisplayName () {
const name = this.get('name');
if (name) {
return name;
} else if (_converse.locked_muc_domain === 'hidden') {
return Strophe.getNodeFromJid(this.get('jid'));
} else {
return this.get('jid');
}
},
/**
* Join the groupchat.
* @private
* @method _converse.ChatRoom#join
* @param { String } nick - The user's nickname
* @param { String } [password] - Optional password, if required by the groupchat.
*/
async join (nick, password) {
if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
// We have restored a groupchat from session storage,
// so we don't send out a presence stanza again.
return this;
}
nick = await this.getAndPersistNickname(nick);
if (!nick) {
u.safeSave(this, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
return this;
}
const stanza = $pres({
'from': _converse.connection.jid,
'to': this.getRoomJIDAndNick()
}).c("x", {'xmlns': Strophe.NS.MUC})
.c("history", {'maxstanzas': this.features.get('mam_enabled') ? 0 : _converse.muc_history_max_stanzas}).up();
if (password) {
stanza.cnode(Strophe.xmlElement("password", [], password));
}
this.save('connection_status', converse.ROOMSTATUS.CONNECTING);
_converse.api.send(stanza);
return this;
},
/**
* Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
* to be confused with the {@link _converse.ChatRoom#destroy}
* method, which simply removes the room from the local browser storage cache.
* @private
* @method _converse.ChatRoom#sendDestroyIQ
* @param { string } [reason] - The reason for destroying the groupchat
* @param { string } [new_jid] - The JID of the new groupchat which
* replaces this one.
*/
sendDestroyIQ (reason, new_jid) {
const destroy = $build("destroy");
if (new_jid) {
destroy.attrs({'jid': new_jid});
}
const iq = $iq({
'to': this.get('jid'),
'type': "set"
}).c("query", {'xmlns': Strophe.NS.MUC_OWNER}).cnode(destroy.node);
if (reason && reason.length > 0) {
iq.c("reason", reason);
}
return _converse.api.sendIQ(iq);
},
/**
* Leave the groupchat.
* @private
* @method _converse.ChatRoom#leave
* @param { string } [exit_msg] - Message to indicate your reason for leaving
*/
leave (exit_msg) {
this.features.destroy();
this.occupants.browserStorage._clear();
this.occupants.reset();
if (_converse.disco_entities) {
const disco_entity = _converse.disco_entities.get(this.get('jid'));
if (disco_entity) {
disco_entity.destroy();
}
}
if (_converse.connection.connected) {
this.sendUnavailablePresence(exit_msg);
}
u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
this.removeHandlers();
},
close () {
try {
this.features.destroy();
this.features.browserStorage._clear();
} catch (e) {
_converse.log(e, Strophe.LogLevel.ERROR);
}
return _converse.ChatBox.prototype.close.call(this);
},
sendUnavailablePresence (exit_msg) {
const presence = $pres({
type: "unavailable",
from: _converse.connection.jid,
to: this.getRoomJIDAndNick()
});
if (exit_msg !== null) {
presence.c("status", exit_msg);
}
_converse.connection.sendPresence(presence);
},
getReferenceForMention (mention, index) {
const longest_match = u.getLongestSubstring(
mention,
this.occupants.map(o => o.getDisplayName())
);
if (!longest_match) {
return null;
}
if ((mention[longest_match.length] || '').match(/[A-Za-zäëïöüâêîôûáéíóúàèìòùÄËÏÖÜÂÊÎÔÛÁÉÍÓÚÀÈÌÒÙ0-9]/i)) {
// avoid false positives, i.e. mentions that have
// further alphabetical characters than our longest
// match.
return null;
}
const occupant = this.occupants.findOccupant({'nick': longest_match}) ||
this.occupants.findOccupant({'jid': longest_match});
if (!occupant) {
return null;
}
const obj = {
'begin': index,
'end': index + longest_match.length,
'value': longest_match,
'type': 'mention'
};
if (occupant.get('jid')) {
obj.uri = `xmpp:${occupant.get('jid')}`;
} else {
obj.uri = `xmpp:${this.get('jid')}/${occupant.get('nick')}`;
}
return obj;
},
extractReference (text, index) {
for (let i=index; i<text.length; i++) {
if (text[i] === '@' && (i === 0 || text[i - 1] === ' ')) {
const match = text.slice(i+1),
ref = this.getReferenceForMention(match, i);
if (ref) {
return [text.slice(0, i) + match, ref, i]
}
}
}
return;
},
parseTextForReferences (text) {
const refs = [];
let index = 0;
while (index < (text || '').length) {
const result = this.extractReference(text, index);
if (result) {
text = result[0]; // @ gets filtered out
refs.push(result[1]);
index = result[2];
} else {
break;
}
}
return [text, refs];
},
getOutgoingMessageAttributes (text, spoiler_hint) {
const is_spoiler = this.get('composing_spoiler');
var references;
[text, references] = this.parseTextForReferences(text);
const origin_id = _converse.connection.getUniqueId();
return {
'id': origin_id,
'msgid': origin_id,
'origin_id': origin_id,
'from': `${this.get('jid')}/${this.get('nick')}`,
'fullname': this.get('nick'),
'is_single_emoji': text ? u.isOnlyEmojis(text) : false,
'is_spoiler': is_spoiler,
'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
'nick': this.get('nick'),
'references': references,
'sender': 'me',
'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
'type': 'groupchat'
};
},
/**
* Utility method to construct the JID for the current user
* as occupant of the groupchat.
*
* @returns {string} - The groupchat JID with the user's nickname added at the end.
* @example groupchat@conference.example.org/nickname
*/
getRoomJIDAndNick () {
const nick = this.get('nick');
const jid = Strophe.getBareJidFromJid(this.get('jid'));
return jid + (nick !== null ? `/${nick}` : "");
},
/**
* Sends a message with the current XEP-0085 chat state of the user
* as taken from the `chat_state` attribute of the {@link _converse.ChatRoom}.
* @private
* @method _converse.ChatRoom#sendChatState
*/
sendChatState () {
if (!_converse.send_chat_state_notifications ||
!this.get('chat_state') ||
this.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
this.features.get('moderated') && this.getOwnRole() === 'visitor') {
return;
}
const allowed = _converse.send_chat_state_notifications;
if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
return;
}
const chat_state = this.get('chat_state');
if (chat_state === _converse.GONE) {
// <gone/> is not applicable within MUC context
return;
}
_converse.api.send(
$msg({'to':this.get('jid'), 'type': 'groupchat'})
.c(chat_state, {'xmlns': Strophe.NS.CHATSTATES}).up()
.c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
.c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
);
},
/**
* Send a direct invitation as per XEP-0249
* @private
* @method _converse.ChatRoom#directInvite
* @param { String } recipient - JID of the person being invited
* @param { String } [reason] - Reason for the invitation
*/
directInvite (recipient, reason) {
if (this.features.get('membersonly')) {
// When inviting to a members-only groupchat, we first add
// the person to the member list by giving them an
// affiliation of 'member' otherwise they won't be able to join.
this.updateMemberLists([{'jid': recipient, 'affiliation': 'member', 'reason': reason}]);
}
const attrs = {
'xmlns': 'jabber:x:conference',
'jid': this.get('jid')
};
if (reason !== null) { attrs.reason = reason; }
if (this.get('password')) { attrs.password = this.get('password'); }
const invitation = $msg({
'from': _converse.connection.jid,
'to': recipient,
'id': _converse.connection.getUniqueId()
}).c('x', attrs);
_converse.api.send(invitation);
/**
* After the user has sent out a direct invitation (as per XEP-0249),
* to a roster contact, asking them to join a room.
* @event _converse#chatBoxMaximized
* @type { object }
* @property { _converse.ChatRoom } room
* @property { string } recipient - The JID of the person being invited
* @property { string } reason - The original reason for the invitation
* @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
*/
_converse.api.trigger('roomInviteSent', {
'room': this,
'recipient': recipient,
'reason': reason
});
},
async refreshRoomFeatures () {
await _converse.api.disco.refreshFeatures(this.get('jid'));
return this.getRoomFeatures();
},
async getRoomFeatures () {
let identity;
try {
identity = await _converse.api.disco.getIdentity('conference', 'text', this.get('jid'));
} catch (e) {
// Getting the identity probably failed because this room doesn't exist yet.
return _converse.log(e, Strophe.LogLevel.ERROR);
}
const fields = await _converse.api.disco.getFields(this.get('jid'));
this.save({
'name': identity && identity.get('name'),
'description': _.get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value')
});
const features = await _converse.api.disco.getFeatures(this.get('jid'));
const attrs = Object.assign(
_.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse)),
{'fetched': (new Date()).toISOString()}
);
features.each(feature => {
const fieldname = feature.get('var');
if (!fieldname.startsWith('muc_')) {
if (fieldname === Strophe.NS.MAM) {
attrs.mam_enabled = true;
}
return;
}
attrs[fieldname.replace('muc_', '')] = true;
});
this.features.save(attrs);
},
/**
* Send IQ stanzas to the server to set an affiliation for
* the provided JIDs.
* See: https://xmpp.org/extensions/xep-0045.html#modifymember
*
* Prosody doesn't accept multiple JIDs' affiliations
* being set in one IQ stanza, so as a workaround we send
* a separate stanza for each JID.
* Related ticket: https://issues.prosody.im/345
*
* @private
* @method _converse.ChatRoom#setAffiliation
* @param { string } affiliation - The affiliation
* @param { object } members - A map of jids, affiliations and
* optionally reasons. Only those entries with the
* same affiliation as being currently set will be considered.
* @returns { Promise } A promise which resolves and fails depending on the XMPP server response.
*/
setAffiliation (affiliation, members) {
members = members.filter(m => m.affiliation === undefined || m.affiliation === affiliation);
return Promise.all(members.map(m => this.sendAffiliationIQ(affiliation, m)));
},
/**
* Submit the groupchat configuration form by sending an IQ
* stanza to the server.
* @private
* @method _converse.ChatRoom#saveConfiguration
* @param { HTMLElement } form - The configuration form DOM element.
* If no form is provided, the default configuration
* values will be used.
* @returns { Promise<XMLElement> }
* Returns a promise which resolves once the XMPP server
* has return a response IQ.
*/
saveConfiguration (form) {
const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [];
const configArray = inputs.map(u.webForm2xForm);
return this.sendConfiguration(configArray);
},
/**
* Given a <field> element, return a copy with a <value> child if
* we can find a value for it in this rooms config.
* @private
* @method _converse.ChatRoom#addFieldValue
* @returns { Element }
*/
addFieldValue (field) {
const type = field.getAttribute('type');
if (type === 'fixed') {
return field;
}
const fieldname = field.getAttribute('var').replace('muc#roomconfig_', '');
const config = this.get('roomconfig');
if (fieldname in config) {
let values;
switch (type) {
case 'boolean':
values = [config[fieldname] ? 1 : 0];
break;
case 'list-multi':
values = config[fieldname];
break;
default:
values= [config[fieldname]];
}
field.innerHTML = values.map(v => $build('value').t(v)).join('');
}
return field;
},
/**
* Automatically configure the groupchat based on this model's
* 'roomconfig' data.
* @private
* @method _converse.ChatRoom#autoConfigureChatRoom
* @returns { Promise<XMLElement> }
* Returns a promise which resolves once a response IQ has
* been received.
*/
async autoConfigureChatRoom () {
const stanza = await this.fetchRoomConfiguration();
const fields = sizzle('field', stanza);
const configArray = fields.map(f => this.addFieldValue(f))
if (configArray.length) {
return this.sendConfiguration(configArray);
}
},
/**
* Send an IQ stanza to fetch the groupchat configuration data.
* Returns a promise which resolves once the response IQ
* has been received.
* @private
* @method _converse.ChatRoom#fetchRoomConfiguration
* @returns { Promise<XMLElement> }
*/
fetchRoomConfiguration () {
return _converse.api.sendIQ(
$iq({'to': this.get('jid'), 'type': "get"})
.c("query", {xmlns: Strophe.NS.MUC_OWNER})
);
},
/**
* Sends an IQ stanza with the groupchat configuration.
* @private
* @method _converse.ChatRoom#sendConfiguration
* @param { Array } config - The groupchat configuration
* @returns { Promise<XMLElement> } - A promise which resolves with
* the `result` stanza received from the XMPP server.
*/
sendConfiguration (config=[]) {
const iq = $iq({to: this.get('jid'), type: "set"})
.c("query", {xmlns: Strophe.NS.MUC_OWNER})
.c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
config.forEach(node => iq.cnode(node).up());
return _converse.api.sendIQ(iq);
},
/**
* Returns the `role` which the current user has in this MUC
* @private
* @method _converse.ChatRoom#getOwnRole
* @returns { ('none'|'visitor'|'participant'|'moderator') }
*/
getOwnRole () {
return _.get(this.getOwnOccupant(), 'attributes.role');
},
/**
* Returns the `affiliation` which the current user has in this MUC
* @private
* @method _converse.ChatRoom#getOwnAffiliation
* @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
*/
getOwnAffiliation () {
return _.get(this.getOwnOccupant(), 'attributes.affiliation');
},
/**
* Get the {@link _converse.ChatRoomOccupant} instance which
* represents the current user.
* @private
* @method _converse.ChatRoom#getOwnOccupant
* @returns { _converse.ChatRoomOccupant }
*/
getOwnOccupant () {
return this.occupants.findWhere({'jid': _converse.bare_jid});
},
/**
* Parse the presence stanza for the current user's affiliation and
* role and save them on the relevant {@link _converse.ChatRoomOccupant}
* instance.
* @private
* @method _converse.ChatRoom#saveAffiliationAndRole
* @param { XMLElement } pres - A <presence> stanza.
*/
saveAffiliationAndRole (pres) {
const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop();
const is_self = (pres.querySelector("status[code='110']") !== null);
if (is_self && item) {
const affiliation = item.getAttribute('affiliation');
const role = item.getAttribute('role');
const changes = {};
if (affiliation) {
changes['affiliation'] = affiliation;
}
if (role) {
changes['role'] = role;
}
if (!_.isEmpty(changes)) {
this.getOwnOccupant().save(changes);
}
}
},
/**
* Send an IQ stanza specifying an affiliation change.
* @private
* @method _converse.ChatRoom#
* @param { String } affiliation: affiliation
* (could also be stored on the member object).
* @param { Object } member: Map containing the member's jid and
* optionally a reason and affiliation.
*/
sendAffiliationIQ (affiliation, member) {
const iq = $iq({to: this.get('jid'), type: "set"})
.c("query", {xmlns: Strophe.NS.MUC_ADMIN})
.c("item", {
'affiliation': member.affiliation || affiliation,
'nick': member.nick,
'jid': member.jid
});
if (member.reason !== undefined) {
iq.c("reason", member.reason);
}
return _converse.api.sendIQ(iq);
},
/**
* Send IQ stanzas to the server to modify affiliations for users in this groupchat.
*
* See: https://xmpp.org/extensions/xep-0045.html#modifymember
* @private
* @method _converse.ChatRoom#setAffiliations
* @param { Object[] } members
* @param { string } members[].jid - The JID of the user whose affiliation will change
* @param { Array } members[].affiliation - The new affiliation for this user
* @param { string } [members[].reason] - An optional reason for the affiliation change
* @returns { Promise }
*/
setAffiliations (members) {
const affiliations = _.uniq(members.map(m => m.affiliation));
return Promise.all(affiliations.map(a => this.setAffiliation(a, members)));
},
/**
* Send an IQ stanza to modify an occupant's role
* @private
* @method _converse.ChatRoom#setRole
* @param { _converse.ChatRoomOccupant } occupant
* @param { String } role
* @param { String } reason
* @param { function } onSuccess - callback for a succesful response
* @param { function } onError - callback for an error response
*/
setRole (occupant, role, reason, onSuccess, onError) {
const item = $build("item", {
'nick': occupant.get('nick'),
role
});
const iq = $iq({
'to': this.get('jid'),
'type': 'set'
}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
if (reason !== null) {
iq.c("reason", reason);
}
return _converse.api.sendIQ(iq).then(onSuccess).catch(onError);
},
/**
* @private
* @method _converse.ChatRoom#getOccupant
* @param { String } nick_or_jid - The nickname or JID of the occupant to be returned
* @returns { _converse.ChatRoomOccupant }
*/
getOccupant (nick_or_jid) {
return (u.isValidJID(nick_or_jid) &&
this.occupants.findWhere({'jid': nick_or_jid})) ||
this.occupants.findWhere({'nick': nick_or_jid});
},
/**
* Sends an IQ stanza to the server, asking it for the relevant affiliation list .
* Returns an array of {@link MemberListItem} objects, representing occupants
* that have the given affiliation.
* See: https://xmpp.org/extensions/xep-0045.html#modifymember