UNPKG

converse.js

Version:
1,351 lines (1,240 loc) 111 kB
/** * @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];