converse.js
Version:
Browser based XMPP chat client
1,351 lines (1,240 loc) • 111 kB
JavaScript
/**
* @module:headless-plugins-muc-muc
*/
import debounce from 'lodash-es/debounce';
import pick from 'lodash-es/pick';
import sizzle from 'sizzle';
import { getOpenPromise } from '@converse/openpromise';
import { Model } from '@converse/skeletor';
import log from '@converse/log';
import p from '../../utils/parse-helpers';
import _converse from '../../shared/_converse.js';
import api from '../../shared/api/index.js';
import converse from '../../shared/api/public.js';
import {
ROOMSTATUS,
OWNER_COMMANDS,
ADMIN_COMMANDS,
MODERATOR_COMMANDS,
VISITOR_COMMANDS,
ACTION_INFO_CODES,
NEW_NICK_CODES,
DISCONNECT_CODES,
} from './constants.js';
import {
ACTIVE,
CHATROOMS_TYPE,
COMPOSING,
GONE,
INACTIVE,
METADATA_ATTRIBUTES,
PAUSED,
PRES_SHOW_VALUES,
} from '../../shared/constants.js';
import { Strophe, Stanza, $build } from 'strophe.js';
import { TimeoutError, ItemNotFoundError, StanzaError } from '../../shared/errors.js';
import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js';
import { initStorage, createStore } from '../../utils/storage.js';
import { isArchived, parseErrorStanza } from '../../shared/parsers.js';
import { getUniqueId } from '../../utils/index.js';
import { safeSave } from '../../utils/init.js';
import { isUniView } from '../../utils/session.js';
import { parseMUCMessage, parseMUCPresence } from './parsers.js';
import { sendMarker } from '../../shared/actions.js';
import ChatBoxBase from '../../shared/chatbox';
import ColorAwareModel from '../../shared/color';
import ModelWithMessages from '../../shared/model-with-messages';
import ModelWithVCard from '../../shared/model-with-vcard';
import { shouldCreateGroupchatMessage, isInfoVisible } from './utils.js';
import MUCSession from './session';
const { u, stx } = converse.env;
/**
* Represents a groupchat conversation.
*/
class MUC extends ModelWithVCard(ModelWithMessages(ColorAwareModel(ChatBoxBase))) {
/**
* @typedef {import('../../shared/message.js').default} BaseMessage
* @typedef {import('./message.js').default} MUCMessage
* @typedef {import('./occupant.js').default} MUCOccupant
* @typedef {import('./types').NonOutcastAffiliation} NonOutcastAffiliation
* @typedef {import('./types').MemberListItem} MemberListItem
* @typedef {import('../../shared/types').MessageAttributes} MessageAttributes
* @typedef {import('./types').MUCMessageAttributes} MUCMessageAttributes
* @typedef {import('./types').MUCPresenceAttributes} MUCPresenceAttributes
* @typedef {module:shared.converse.UserMessage} UserMessage
* @typedef {import('strophe.js').Builder} Builder
* @typedef {import('../../shared/errors').StanzaParseError} StanzaParseError
*/
defaults() {
/** @type {import('./types').DefaultMUCAttributes} */
return {
'bookmarked': false,
'chat_state': undefined,
'has_activity': false, // XEP-437
'hidden': isUniView() && !api.settings.get('singleton'),
'hidden_occupants': !!api.settings.get('hide_muc_participants'),
'message_type': 'groupchat',
'name': '',
// 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
// 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,
'num_unread': 0,
'roomconfig': {},
'time_opened': this.get('time_opened') || new Date().getTime(),
'time_sent': new Date(0).toISOString(),
'type': CHATROOMS_TYPE,
};
}
async initialize() {
super.initialize();
this.initialized = getOpenPromise();
this.debouncedRejoin = debounce(this.rejoin, 250);
this.initOccupants();
this.initDiscoModels(); // sendChatState depends on this.features
this.registerHandlers();
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);
this.listenTo(this.occupants, 'add', this.onOccupantAdded);
this.listenTo(this.occupants, 'remove', this.onOccupantRemoved);
this.listenTo(this.occupants, 'change:presence', this.onOccupantPresenceChanged);
this.listenTo(this.occupants, 'change:affiliation', this.createAffiliationChangeMessage);
this.listenTo(this.occupants, 'change:role', this.createRoleChangeMessage);
const restored = await this.restoreFromCache();
if (!restored) {
await this.join();
}
/**
* Triggered once a {@link MUC} has been created and initialized.
* @event _converse#chatRoomInitialized
* @type { MUC }
* @example _converse.api.listen.on('chatRoomInitialized', model => { ... });
*/
await api.trigger('chatRoomInitialized', this, { synchronous: true });
this.initialized.resolve();
}
isEntered() {
return this.session?.get('connection_status') === ROOMSTATUS.ENTERED;
}
/**
* Checks whether this MUC qualifies for subscribing to XEP-0437 Room Activity Indicators (RAI)
* @returns {Boolean}
*/
isRAICandidate() {
return this.get('hidden') && api.settings.get('muc_subscribe_to_rai') && this.getOwnAffiliation() !== 'none';
}
/**
* Checks whether we're still joined and if so, restores the MUC state from cache.
* @returns {Promise<boolean>} Returns `true` if we're still joined, otherwise returns `false`.
*/
async restoreFromCache() {
if (this.isEntered()) {
await this.fetchOccupants().catch(/** @param {Error} e */ (e) => log.error(e));
if (this.isRAICandidate()) {
this.session.save('connection_status', ROOMSTATUS.DISCONNECTED);
this.enableRAI();
return true;
} else if (await this.isJoined()) {
await new Promise((r) => this.config.fetch({ 'success': r, 'error': r }));
await new Promise((r) => this.features.fetch({ 'success': r, 'error': r }));
await this.fetchMessages().catch(/** @param {Error} e */ (e) => log.error(e));
return true;
}
}
this.session.save('connection_status', ROOMSTATUS.DISCONNECTED);
this.clearOccupantsCache();
return false;
}
/**
* Join the MUC
* @param {String} [nick] - The user's nickname
* @param {String} [password] - Optional password, if required by the groupchat.
* Will fall back to the `password` value stored in the room
* model (if available).
* @returns {Promise<void>}
*/
async join(nick, password) {
if (this.isEntered()) {
// We have restored a groupchat from session storage,
// so we don't send out a presence stanza again.
return;
}
// Set this early, so we don't rejoin in onHiddenChange
this.session.save('connection_status', ROOMSTATUS.CONNECTING);
const is_new = (await this.refreshDiscoInfo()) instanceof ItemNotFoundError;
nick = await this.getAndPersistNickname(nick);
if (!nick) {
safeSave(this.session, { 'connection_status': ROOMSTATUS.NICKNAME_REQUIRED });
if (!is_new && api.settings.get('muc_show_logs_before_join')) {
await this.fetchMessages();
}
return;
}
api.send(await this.constructJoinPresence(password, is_new));
if (is_new) await this.refreshDiscoInfo();
}
/**
* Clear stale cache and re-join a MUC we've been in before.
*/
rejoin() {
this.session.save('connection_status', ROOMSTATUS.DISCONNECTED);
this.registerHandlers();
this.clearOccupantsCache();
return this.join();
}
/**
* @param {string} password
* @param {boolean} is_new
*/
async constructJoinPresence(password, is_new) {
const maxstanzas = is_new || this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas');
password = password || this.get('password');
const { profile } = _converse.state;
const show = profile.get('show');
const status_message = profile.get('status_message');
const stanza = stx`
<presence xmlns="jabber:client"
id="${getUniqueId()}"
from="${api.connection.get().jid}"
to="${this.getRoomJIDAndNick()}">
<x xmlns="${Strophe.NS.MUC}">
<history maxstanzas="${maxstanzas}"/>
${password ? stx`<password>${password}</password>` : ''}
</x>
${PRES_SHOW_VALUES.includes(show) ? stx`<show>${show}</show>` : ''}
${status_message ? stx`<status>${status_message}</status>` : ''}
</presence>`;
/**
* *Hook* which allows plugins to update an outgoing MUC join presence stanza
* @event _converse#constructedMUCPresence
* @type {Element} The stanza which will be sent out
*/
return await api.hook('constructedMUCPresence', this, stanza);
}
clearOccupantsCache() {
if (this.occupants.length) {
// Remove non-members when reconnecting
this.occupants.filter((o) => !o.isMember()).forEach((o) => o.destroy());
} else {
// Looks like we haven't restored occupants from cache, so we clear it.
this.occupants.clearStore();
}
}
/**
* Given the passed in MUC message, send a XEP-0333 chat marker.
* @async
* @param {BaseMessage} msg
* @param {('received'|'displayed'|'acknowledged')} [type='displayed']
* @param {boolean} [force=false] - Whether a marker should be sent for the
* message, even if it didn't include a `markable` element.
*/
sendMarkerForMessage(msg, type = 'displayed', force = false) {
if (!msg || !api.settings.get('send_chat_markers').includes(type) || msg?.get('type') !== 'groupchat') {
return;
}
if (msg?.get('is_markable') || force) {
const key = `stanza_id ${this.get('jid')}`;
const id = msg.get(key);
if (!id) {
log.error(`Can't send marker for message without stanza ID: ${key}`);
return Promise.resolve();
}
const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
sendMarker(from_jid, id, type, msg.get('type'));
}
return Promise.resolve();
}
/**
* Finds the last eligible message and then sends a XEP-0333 chat marker for it.
* @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
* @param {Boolean} force - Whether a marker should be sent for the
* message, even if it didn't include a `markable` element.
*/
sendMarkerForLastMessage(type = 'displayed', force = false) {
const msgs = Array.from(this.messages.models);
msgs.reverse();
const msg = msgs.find((m) => m.get('sender') === 'them' && (force || m.get('is_markable')));
msg && this.sendMarkerForMessage(msg, type, force);
}
/**
* Ensures that the user is subscribed to XEP-0437 Room Activity Indicators
* if `muc_subscribe_to_rai` is set to `true`.
* Only affiliated users can subscribe to RAI, but this method doesn't
* check whether the current user is affiliated because it's intended to be
* called after the MUC has been left and we don't have that information anymore.
*/
enableRAI() {
if (api.settings.get('muc_subscribe_to_rai')) {
const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
api.user.presence.send({ to: muc_domain }, $build('rai', { 'xmlns': Strophe.NS.RAI }));
}
}
/**
* Handler that gets called when the 'hidden' flag is toggled.
*/
async onHiddenChange() {
const roomstatus = ROOMSTATUS;
const conn_status = this.session.get('connection_status');
if (this.get('hidden')) {
if (conn_status === roomstatus.ENTERED) {
this.setChatState(INACTIVE);
if (this.isRAICandidate()) {
this.sendMarkerForLastMessage('received', true);
await this.leave();
this.enableRAI();
}
}
} else {
await this.initialized;
if (conn_status === roomstatus.DISCONNECTED) this.rejoin();
this.clearUnreadMsgCounter();
}
}
/**
* @param {MUCOccupant} occupant
*/
onOccupantAdded(occupant) {
if (
isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
this.session.get('connection_status') === ROOMSTATUS.ENTERED &&
occupant.get('presence') === 'online'
) {
this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
}
}
/**
* @param {MUCOccupant} occupant
*/
onOccupantRemoved(occupant) {
if (
isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED) &&
this.isEntered() &&
occupant.get('presence') === 'online'
) {
this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
}
}
/**
* @param {MUCOccupant} occupant
*/
onOccupantPresenceChanged(occupant) {
if (occupant.get('states').includes('303')) {
return;
}
if (occupant.get('presence') === 'offline' && isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED)) {
this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
} else if (occupant.get('presence') === 'online' && isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED)) {
this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
}
}
async onRoomEntered() {
await this.occupants.fetchMembers();
if (api.settings.get('clear_messages_on_reconnection')) {
await this.clearMessages();
} else {
await this.fetchMessages();
}
/**
* Triggered when the user has entered a new MUC
* @event _converse#enteredNewRoom
* @type {MUC}
* @example _converse.api.listen.on('enteredNewRoom', model => { ... });
*/
api.trigger('enteredNewRoom', this);
if (
api.settings.get('auto_register_muc_nickname') &&
(await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
) {
this.registerNickname();
}
}
async onConnectionStatusChanged() {
if (this.isEntered()) {
if (this.isRAICandidate()) {
try {
await this.leave();
} catch (e) {
log.error(e);
}
this.enableRAI();
} else {
await this.onRoomEntered();
}
}
}
async onReconnection() {
await this.rejoin();
this.announceReconnection();
}
getMessagesCollection() {
return new _converse.exports.MUCMessages();
}
restoreSession() {
const bare_jid = _converse.session.get('bare_jid');
const id = `muc.session-${bare_jid}-${this.get('jid')}`;
this.session = new MUCSession({ id });
initStorage(this.session, id, 'session');
return new Promise((r) => this.session.fetch({ 'success': r, 'error': r }));
}
initDiscoModels() {
const bare_jid = _converse.session.get('bare_jid');
let id = `converse.muc-features-${bare_jid}-${this.get('jid')}`;
this.features = new Model(
Object.assign(
{ id },
converse.ROOM_FEATURES.reduce((acc, feature) => {
acc[feature] = false;
return acc;
}, {})
)
);
this.features.browserStorage = createStore(id, 'session');
this.features.listenTo(_converse, 'beforeLogout', () => this.features.browserStorage.flush());
id = `converse.muc-config-${bare_jid}-${this.get('jid')}`;
this.config = new Model({ id });
this.config.browserStorage = createStore(id, 'session');
this.config.listenTo(_converse, 'beforeLogout', () => this.config.browserStorage.flush());
}
initOccupants() {
this.occupants = new _converse.exports.MUCOccupants();
const bare_jid = _converse.session.get('bare_jid');
const id = `converse.occupants-${bare_jid}${this.get('jid')}`;
this.occupants.browserStorage = createStore(id, 'session');
this.occupants.chatroom = this;
this.occupants.listenTo(_converse, 'beforeLogout', () => this.occupants.browserStorage.flush());
}
fetchOccupants() {
this.occupants.fetched = new Promise((resolve) => {
this.occupants.fetch({
'add': true,
'silent': true,
'success': resolve,
'error': resolve,
});
});
return this.occupants.fetched;
}
/**
* If a user's affiliation has been changed, a <presence> stanza is sent
* out, but if the user is not in a room, a <message> stanza MAY be sent
* out. This handler handles such message stanzas. See "Example 176" in
* XEP-0045.
* @param {Element} stanza
* @returns {void}
*/
handleAffiliationChangedMessage(stanza) {
if (stanza.querySelector('body')) {
// If there's a body, we don't treat it as an affiliation change message.
return;
}
const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
if (item) {
const from = stanza.getAttribute('from');
const jid = item.getAttribute('jid');
const data = {
from,
states: [],
jid: Strophe.getBareJidFromJid(jid),
resource: Strophe.getResourceFromJid(jid),
};
const affiliation = item.getAttribute('affiliation');
if (affiliation) {
data.affiliation = affiliation;
}
const role = item.getAttribute('role');
if (role) {
data.role = role;
}
const occupant = this.occupants.findOccupant({ jid: data.jid });
if (occupant) {
occupant.save(data);
} else {
this.occupants.create(data);
}
}
}
/**
* @param {Element} stanza
*/
async handleErrorMessageStanza(stanza) {
const { __ } = _converse;
const attrs_or_error = await parseMUCMessage(stanza, this);
if (u.isErrorObject(attrs_or_error)) {
const { stanza, message } = /** @type {StanzaParseError} */ (attrs_or_error);
if (stanza) log.error(stanza);
return log.error(message);
}
const attrs = /** @type {MessageAttributes} */ (attrs_or_error);
if (!(await this.shouldShowErrorMessage(attrs))) {
return;
}
const nick = Strophe.getResourceFromJid(attrs.from);
const occupant = nick ? this.getOccupant(nick) : null;
const model = occupant ? occupant : this;
const message = model.getMessageReferencedByError(attrs);
if (message) {
const new_attrs = {
error: attrs.error,
error_condition: attrs.error_condition,
error_text: attrs.error_text,
error_type: attrs.error_type,
editable: false,
};
if (attrs.msgid === message.get('retraction_id')) {
// The error message refers to a retraction
new_attrs.retracted = undefined;
new_attrs.retraction_id = undefined;
new_attrs.retracted_id = undefined;
if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("You're not allowed to retract your message.");
} else if (attrs.error_condition === 'not-acceptable') {
new_attrs.error = __(
"Your retraction was not delivered because you're not present in the groupchat."
);
} else {
new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
}
}
} else if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
} else if (attrs.error_condition === 'not-acceptable') {
new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
}
}
message.save(new_attrs);
} else {
model.createMessage(attrs);
}
}
/**
* Handles incoming message stanzas from the service that hosts this MUC
* @param {Element} stanza
*/
handleMessageFromMUCHost(stanza) {
if (this.isEntered()) {
// We're not interested in activity indicators when already joined to the room
return;
}
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
});
}
}
/**
* Handles XEP-0452 MUC Mention Notification messages
* @param {Element} stanza
*/
handleForwardedMentions(stanza) {
if (this.isEntered()) {
// Avoid counting mentions twice
return;
}
const msgs = sizzle(
`mentions[xmlns="${Strophe.NS.MENTIONS}"] forwarded[xmlns="${Strophe.NS.FORWARD}"] message[type="groupchat"]`,
stanza
);
const muc_jid = this.get('jid');
const mentions = msgs.filter((m) => Strophe.getBareJidFromJid(m.getAttribute('from')) === muc_jid);
if (mentions.length) {
this.save({
'has_activity': true,
'num_unread': this.get('num_unread') + mentions.length,
});
mentions.forEach(
/** @param {Element} stanza */ async (stanza) => {
const attrs = await parseMUCMessage(stanza, this);
const data = { stanza, attrs, 'chatbox': this };
api.trigger('message', data);
}
);
}
}
/**
* Parses an incoming message stanza and queues it for processing.
* @param {Builder|Element} stanza
*/
async handleMessageStanza(stanza) {
stanza = /** @type {Builder} */ (stanza).tree?.() ?? /** @type {Element} */ (stanza);
const type = stanza.getAttribute('type');
if (type === 'error') {
return this.handleErrorMessageStanza(stanza);
}
if (type === 'groupchat') {
if (isArchived(stanza)) {
// MAM messages are handled in converse-mam.
// We shouldn't get MAM messages here because
// they shouldn't have a `type` attribute.
return log.warn(`Received a MAM message with type "groupchat"`);
}
} else if (!type) {
return this.handleForwardedMentions(stanza);
}
let attrs_or_error;
try {
attrs_or_error = await parseMUCMessage(stanza, this);
} catch (e) {
return log.error(e);
}
if (u.isErrorObject(attrs_or_error)) {
const { stanza, message } = /** @type {StanzaParseError} */ (attrs_or_error);
if (stanza) log.error(stanza);
return log.error(message);
}
const attrs = /** @type {MUCMessageAttributes} */ (attrs_or_error);
if (attrs.type === 'groupchat') {
attrs.codes.forEach((code) => this.createInfoMessage(code));
this.fetchFeaturesIfConfigurationChanged(attrs);
}
const data = /** @type {import('./types').MUCMessageEventData} */ ({
stanza,
attrs,
chatbox: this,
});
/**
* Triggered when a groupchat message stanza has been received and parsed.
* @event _converse#message
* @type {object}
* @property {import('./types').MUCMessageEventData} data
*/
api.trigger('message', data);
return attrs && this.queueMessage(attrs);
}
/**
* Register presence and message handlers relevant to this groupchat
*/
registerHandlers() {
const muc_jid = this.get('jid');
const muc_domain = Strophe.getDomainFromJid(muc_jid);
this.removeHandlers();
const connection = api.connection.get();
this.presence_handler = connection.addHandler(
/** @param {Element} stanza */ (stanza) => {
this.onPresence(stanza);
return true;
},
null,
'presence',
null,
null,
muc_jid,
{ 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
);
this.domain_presence_handler = connection.addHandler(
/** @param {Element} stanza */ (stanza) => {
this.onPresenceFromMUCHost(stanza);
return true;
},
null,
'presence',
null,
null,
muc_domain
);
this.message_handler = connection.addHandler(
/** @param {Element} stanza */ (stanza) => {
this.handleMessageStanza(stanza);
return true;
},
null,
'message',
null,
null,
muc_jid,
{ 'matchBareFromJid': true }
);
this.domain_message_handler = connection.addHandler(
/** @param {Element} stanza */ (stanza) => {
this.handleMessageFromMUCHost(stanza);
return true;
},
null,
'message',
null,
null,
muc_domain
);
this.affiliation_message_handler = connection.addHandler(
/** @param {Element} stanza */
(stanza) => {
this.handleAffiliationChangedMessage(stanza);
return true;
},
Strophe.NS.MUC_USER,
'message',
null,
null,
muc_jid
);
}
removeHandlers() {
const connection = api.connection.get();
// Remove the presence and message handlers that were
// registered for this groupchat.
if (this.message_handler) {
connection?.deleteHandler(this.message_handler);
delete this.message_handler;
}
if (this.domain_message_handler) {
connection?.deleteHandler(this.domain_message_handler);
delete this.domain_message_handler;
}
if (this.presence_handler) {
connection?.deleteHandler(this.presence_handler);
delete this.presence_handler;
}
if (this.domain_presence_handler) {
connection?.deleteHandler(this.domain_presence_handler);
delete this.domain_presence_handler;
}
if (this.affiliation_message_handler) {
connection?.deleteHandler(this.affiliation_message_handler);
delete this.affiliation_message_handler;
}
return this;
}
invitesAllowed() {
return (
api.settings.get('allow_muc_invitations') &&
(this.features.get('open') || this.getOwnAffiliation() === 'owner')
);
}
getDisplayName() {
const name = this.get('name');
if (name) {
return name.trim();
} else if (api.settings.get('locked_muc_domain') === 'hidden') {
return Strophe.getNodeFromJid(this.get('jid'));
} else {
return this.get('jid');
}
}
/**
* Sends a message stanza to the XMPP server and expects a reflection
* or error message within a specific timeout period.
* @param {Builder|Element } message
* @returns { Promise<Element>|Promise<TimeoutError> } Returns a promise
* which resolves with the reflected message stanza or with an error stanza or
* {@link TimeoutError}.
*/
sendTimedMessage(message) {
const el = message instanceof Element ? message : message.tree();
let id = el.getAttribute('id');
if (!id) {
// inject id if not found
id = getUniqueId('sendIQ');
el.setAttribute('id', id);
}
const promise = getOpenPromise();
const timeout = api.settings.get('stanza_timeout');
const connection = api.connection.get();
const timeoutHandler = connection.addTimedHandler(timeout, () => {
connection.deleteHandler(handler);
const err = new TimeoutError('Timeout Error: No response from server');
promise.resolve(err);
return false;
});
const handler = connection.addHandler(
/** @param {Element} stanza */
(stanza) => {
timeoutHandler && connection.deleteTimedHandler(timeoutHandler);
promise.resolve(stanza);
},
null,
'message',
['error', 'groupchat'],
id
);
api.send(el);
return promise;
}
/**
* Retract one of your messages in this groupchat
* @param {BaseMessage} message - The message which we're retracting.
*/
async retractOwnMessage(message) {
const __ = _converse.__;
const editable = message.get('editable');
const retraction_id = getUniqueId();
const id = message.get('id');
const stanza = stx`
<message id="${retraction_id}"
to="${this.get('jid')}"
type="groupchat"
xmlns="jabber:client">
<retract id="${id}" xmlns="${Strophe.NS.RETRACT}"/>
<body>/me retracted a message</body>
<store xmlns="${Strophe.NS.HINTS}"/>
<fallback xmlns="${Strophe.NS.FALLBACK}" for="${Strophe.NS.RETRACT}" />
</message>`;
// Optimistic save
message.set({
retracted: new Date().toISOString(),
retracted_id: id,
retraction_id: retraction_id,
editable: false,
});
const result = await this.sendTimedMessage(stanza);
if (u.isErrorStanza(result)) {
log.error(result);
} else if (result instanceof TimeoutError) {
log.error(result);
message.save({
editable,
error_type: 'timeout',
error: __('A timeout happened while trying to retract your message.'),
retracted: undefined,
retracted_id: undefined,
retraction_id: undefined,
});
}
}
/**
* Retract someone else's message in this groupchat.
* @param {MUCMessage} message - The message which we're retracting.
* @param {string} [reason] - The reason for retracting the message.
* @example
* const room = await api.rooms.get(jid);
* const message = room.messages.findWhere({'body': 'Get rich quick!'});
* room.retractOtherMessage(message, 'spam');
*/
async retractOtherMessage(message, reason) {
const editable = message.get('editable');
const bare_jid = _converse.session.get('bare_jid');
// Optimistic save
message.save({
moderated: 'retracted',
moderated_by: bare_jid,
moderated_id: message.get('msgid'),
moderation_reason: reason,
editable: false,
});
const result = await this.sendRetractionIQ(message, reason);
if (result === null || u.isErrorStanza(result)) {
// Undo the save if something went wrong
message.save({
editable,
moderated: undefined,
moderated_by: undefined,
moderated_id: undefined,
moderation_reason: undefined,
});
}
return result;
}
/**
* Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
* @param {MUCMessage} message - The message which we're retracting.
* @param {string} [reason] - The reason for retracting the message.
*/
sendRetractionIQ(message, reason) {
const iq = stx`
<iq to="${this.get('jid')}" type="set" xmlns="jabber:client">
<moderate id="${message.get(`stanza_id ${this.get('jid')}`)}" xmlns="${Strophe.NS.MODERATE}">
<retract xmlns="${Strophe.NS.RETRACT}"/>
${reason ? stx`<reason>${reason}</reason>` : ''}
</moderate>
</iq>`;
return api.sendIQ(iq, null, false);
}
/**
* Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
* to be confused with the {@link MUC#destroy}
* method, which simply removes the room from the local browser storage cache.
* @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 iq = stx`
<iq to="${this.get('jid')}" type="set" xmlns="jabber:client">
<query xmlns="${Strophe.NS.MUC_OWNER}">
<destroy ${new_jid ? Stanza.unsafeXML(`jid="${Strophe.xmlescape(new_jid)}"`) : ''}>
${reason ? stx`<reason>${reason}</reason>` : ''}
</destroy>
</query>
</iq>`;
return api.sendIQ(iq);
}
/**
* Leave the groupchat by sending an unavailable presence stanza, and then
* tear down the features and disco collections so that they'll be
* recreated if/when we rejoin.
* @param {string} [exit_msg] - Message to indicate your reason for leaving
*/
async leave(exit_msg) {
api.user.presence.send({
type: 'unavailable',
to: this.getRoomJIDAndNick(),
status: exit_msg,
});
safeSave(this.session, { connection_status: ROOMSTATUS.DISCONNECTED });
// Delete the features model
if (this.features) {
await new Promise((resolve) =>
this.features.destroy({
success: resolve,
error: (_, e) => {
log.error(e);
resolve();
},
})
);
}
// Delete disco entity
const disco_entity = _converse.state.disco_entities?.get(this.get('jid'));
if (disco_entity) {
await new Promise((resolve) =>
disco_entity.destroy({
success: resolve,
error: (_, e) => {
log.error(e);
resolve();
},
})
);
}
}
/**
* @typedef {Object} CloseEvent
* @property {string} name
* @param {CloseEvent} [ev]
*/
async close(ev) {
const { ENTERED, CLOSING } = ROOMSTATUS;
const was_entered = this.session.get('connection_status') === ENTERED;
safeSave(this.session, { connection_status: CLOSING });
was_entered && this.sendMarkerForLastMessage('received', true);
await this.leave();
this.occupants.clearStore();
const is_closed_by_user = ev?.name !== 'closeAllChatBoxes';
if (is_closed_by_user) {
await this.unregisterNickname();
if (api.settings.get('muc_clear_messages_on_leave')) {
this.clearMessages();
}
/**
* Triggered when the user leaves a MUC
* @event _converse#leaveRoom
* @type {MUC}
* @example _converse.api.listen.on('leaveRoom', model => { ... });
*/
api.trigger('leaveRoom', this);
}
// Delete the session model
await new Promise((success) =>
this.session.destroy({
success,
error: (_, e) => {
log.error(e);
success();
},
})
);
return super.close();
}
canModerateMessages() {
const self = this.getOwnOccupant();
return self && self.isModerator() && api.disco.supports(Strophe.NS.MODERATE, this.get('jid'));
}
canPostMessages() {
return this.isEntered() && !(this.features.get('moderated') && this.getOwnRole() === 'visitor');
}
/**
* @param {import('../../shared/message').default} message
*/
isChatMessage(message) {
return message.get('type') === this.get('message_type');
}
/**
* Return an array of unique nicknames based on all occupants and messages in this MUC.
* @returns {String[]}
*/
getAllKnownNicknames() {
return [
...new Set([...this.occupants.map((o) => o.get('nick')), ...this.messages.map((m) => m.get('nick'))]),
].filter((n) => n);
}
getAllKnownNicknamesRegex() {
const longNickString = this.getAllKnownNicknames()
.map((n) => p.escapeRegexString(n))
.join('|');
return RegExp(`(?:\\p{P}|\\p{Z}|^)@(${longNickString})(?![\\w@-])`, 'uig');
}
/**
* @param {string} jid
*/
getOccupantByJID(jid) {
return this.occupants.findOccupant({ jid });
}
/**
* @param {string} nick
*/
getOccupantByNickname(nick) {
return this.occupants.findOccupant({ nick });
}
/**
* @param {string} nick
*/
getReferenceURIFromNickname(nick) {
const muc_jid = this.get('jid');
const occupant = this.getOccupant(nick);
const uri = (this.features.get('nonanonymous') && occupant?.get('jid')) || `${muc_jid}/${nick}`;
return encodeURI(`xmpp:${uri}`);
}
/**
* Given a text message, look for `@` mentions and turn them into
* XEP-0372 references
* @param { String } text
*/
parseTextForReferences(text) {
const mentions_regex = /(\p{P}|\p{Z}|^)([@][\w_-]+(?:\.\w+)*)/giu;
if (!text || !mentions_regex.test(text)) {
return [text, []];
}
const getMatchingNickname = p.findFirstMatchInArray(this.getAllKnownNicknames());
const matchToReference = (match) => {
let at_sign_index = match[0].indexOf('@');
if (match[0][at_sign_index + 1] === '@') {
// edge-case
at_sign_index += 1;
}
const begin = match.index + at_sign_index;
const end = begin + match[0].length - at_sign_index;
const value = getMatchingNickname(match[1]);
const type = 'mention';
const uri = this.getReferenceURIFromNickname(value);
return { begin, end, value, type, uri };
};
const regex = this.getAllKnownNicknamesRegex();
const mentions = [...text.matchAll(regex)].filter((m) => !m[0].startsWith('/'));
const references = mentions.map(matchToReference);
const [updated_message, updated_references] = p.reduceTextFromReferences(text, references);
return [updated_message, updated_references];
}
/**
* @param {MessageAttributes} [attrs] - A map of attributes to be saved on the message
*/
async getOutgoingMessageAttributes(attrs) {
const is_spoiler = this.get('composing_spoiler');
let text = '',
references;
if (attrs?.body) {
[text, references] = this.parseTextForReferences(attrs.body);
}
const origin_id = getUniqueId();
const body = text ? u.shortnamesToUnicode(text) : undefined;
attrs = Object.assign(
{},
attrs,
{
body,
is_spoiler,
origin_id,
references,
id: origin_id,
msgid: origin_id,
from: `${this.get('jid')}/${this.get('nick')}`,
fullname: this.get('nick'),
message: body,
nick: this.get('nick'),
sender: 'me',
type: 'groupchat',
original_text: text,
},
await u.getMediaURLsMetadata(text)
);
/**
* *Hook* which allows plugins to update the attributes of an outgoing
* message.
* @event _converse#getOutgoingMessageAttributes
*/
attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
return attrs;
}
/**
* 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 MUC}.
*/
sendChatState() {
if (
!api.settings.get('send_chat_state_notifications') ||
!this.get('chat_state') ||
!this.isEntered() ||
(this.features.get('moderated') && this.getOwnRole() === 'visitor')
) {
return;
}
const allowed = api.settings.get('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 === GONE) return; // <gone/> is not applicable within MUC context
api.send(stx`
<message to="${this.get('jid')}" type="groupchat" xmlns="jabber:client">
${chat_state === INACTIVE ? stx`<inactive xmlns="${Strophe.NS.CHATSTATES}"/>` : ''}
${chat_state === ACTIVE ? stx`<active xmlns="${Strophe.NS.CHATSTATES}"/>` : ''}
${chat_state === COMPOSING ? stx`<composing xmlns="${Strophe.NS.CHATSTATES}"/>` : ''}
${chat_state === PAUSED ? stx`<paused xmlns="${Strophe.NS.CHATSTATES}"/>` : ''}
<no-store xmlns="${Strophe.NS.HINTS}"/>
<no-permanent-store xmlns="${Strophe.NS.HINTS}"/>
</message>`);
}
/**
* Send a direct invitation as per XEP-0249
* @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 }]);
}
const invitation = stx`
<message xmlns="jabber:client" to="${recipient}" id="${getUniqueId()}">
<x xmlns="jabber:x:conference"
jid="${this.get('jid')}"
${this.get('password') ? Stanza.unsafeXML(`password="${Strophe.xmlescape(this.get('password'))}"`) : ''}
${reason ? Stanza.unsafeXML(`reason="${Strophe.xmlescape(reason)}"`) : ''} />
</message>`;
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 {MUC} 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 => { ... });
*/
api.trigger('roomInviteSent', {
room: this,
recipient,
reason,
});
}
/**
* Refresh the disco identity, features and fields for this {@link MUC}.
* *features* are stored on the features {@link Model} attribute on this {@link MUC}.
* *fields* are stored on the config {@link Model} attribute on this {@link MUC}.
* @returns {Promise}
*/
async refreshDiscoInfo() {
const result = await api.disco.refresh(this.get('jid'));
if (result instanceof StanzaError) {
return result;
}
return this.getDiscoInfo().catch((e) => log.error(e));
}
/**
* Fetch the *extended* MUC info from the server and cache it locally
* https://xmpp.org/extensions/xep-0045.html#disco-roominfo
* @returns {Promise}
*/
async getDiscoInfo() {
const identity = await api.disco.getIdentity('conference', 'text', this.get('jid'));
if (identity?.get('name')) {
this.save({ name: identity.get('name') });
} else {
log.error(`No identity or name found for ${this.get('jid')}`);
}
await this.getDiscoInfoFields();
await this.getDiscoInfoFeatures();
}
/**
* Fetch the *extended* MUC info fields from the server and store them locally
* in the `config` {@link Model} attribute.
* See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
* @returns {Promise}
*/
async getDiscoInfoFields() {
const fields = await api.disco.getFields(this.get('jid'));
const config = fields.reduce((config, f) => {
const name = f.get('var');
if (name === 'muc#roomconfig_roomname') {
config['roomname'] = f.get('value');
}
if (name?.startsWith('muc#roominfo_')) {
config[name.replace('muc#roominfo_', '')] = f.get('value');
}
return config;
}, {});
this.config.save(config);
if (config['roomname']) this.save({ name: config['roomname'] });
}
/**
* Use converse-disco to populate the features {@link Model} which
* is stored as an attibute on this {@link MUC}.
* The results may be cached. If you want to force fetching the features from the
* server, call {@link MUC#refreshDiscoInfo} instead.
* @returns {Promise}
*/
async getDiscoInfoFeatures() {
const features = await api.disco.getFeatures(this.get('jid'));
const attrs = converse.ROOM_FEATURES.reduce(
(acc, feature) => {
acc[feature] = false;
return acc;
},
{ '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;
} else {
attrs[fieldname] = true;
}
return;
}
attrs[fieldname.replace('muc_', '')] = true;
});
this.features.save(attrs);
}
/**
* Given a <field> element, return a copy with a <value> child if
* we can find a value for it in this rooms config.
* @param {Element} field
* @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];