converse.js
Version:
Browser based XMPP chat client
1,019 lines (938 loc) • 41.3 kB
JavaScript
import { filesize } from 'filesize';
import pick from 'lodash-es/pick';
import debounce from 'lodash-es/debounce.js';
import { getOpenPromise } from '@converse/openpromise';
import { Model } from '@converse/skeletor';
import log from '@converse/log';
import { initStorage } from '../utils/storage.js';
import * as constants from './constants.js';
import converse from './api/public.js';
import api from './api/index.js';
import { isNewMessage } from '../plugins/chat/utils.js';
import _converse from './_converse.js';
import * as errors from './errors.js';
import { sendMarker, sendReceiptStanza, sendRetractionMessage } from './actions.js';
import { parseMessage } from '../plugins/chat/parsers';
const { Strophe, stx, u } = converse.env;
/**
* Adds a messages collection to a model and various methods related to sending
* and receiving chat messages.
*
* This model should be UX-agnostic, except when it comes to the rendering of
* messages. So there's no assumption of uniformity with regards to UI elements
* represented by this object.
*
* @template {import('./types').ModelExtender} T
* @param {T} BaseModel
*/
export default function ModelWithMessages(BaseModel) {
/**
* @typedef {import('../plugins/chat/model').default} ChatBox
* @typedef {import('../plugins/muc/muc').default} MUC
* @typedef {import('../plugins/muc/parsers').MUCMessageAttributes} MUCMessageAttributes
* @typedef {import('../shared/types').MessageAttributes} MessageAttributes
* @typedef {import('./message').default} BaseMessage
* @typedef {import('strophe.js').Builder} Builder
*/
return class ModelWithMessages extends BaseModel {
/** @param {...any} args */
constructor(...args) {
super(args[0], args[1]);
this.disable_mam = false;
}
async initialize() {
super.initialize();
this.initUI();
this.initMessages();
this.initNotifications();
this.ui.on('change:scrolled', () => this.onScrolledChanged());
}
initNotifications() {
this.notifications = new Model();
}
initUI() {
this.ui = new Model();
}
/**
* @returns {string}
*/
getDisplayName() {
return this.get('jid');
}
canPostMessages() {
// Can be overridden in subclasses.
return true;
}
/**
* Queue the creation of a message, to make sure that we don't run
* into a race condition whereby we're creating a new message
* before the collection has been fetched.
* @param {Object} attrs
* @param {Object} options
*/
async createMessage(attrs, options) {
attrs.time = attrs.time || new Date().toISOString();
await this.messages.fetched;
return this.messages.create(attrs, options);
}
getMessagesCacheKey() {
return `converse.messages-${this.get('jid')}-${_converse.session.get('bare_jid')}`;
}
getMessagesCollection() {
return new _converse.exports.Messages();
}
getNotificationsText() {
const { __ } = _converse;
if (this.notifications?.get('chat_state') === constants.COMPOSING) {
return __('%1$s is typing', this.getDisplayName());
} else if (this.notifications?.get('chat_state') === constants.PAUSED) {
return __('%1$s has stopped typing', this.getDisplayName());
} else if (this.notifications?.get('chat_state') === constants.GONE) {
return __('%1$s has gone away', this.getDisplayName());
} else {
return '';
}
}
initMessages() {
this.messages = this.getMessagesCollection();
this.messages.fetched = getOpenPromise();
this.messages.chatbox = this;
initStorage(this.messages, this.getMessagesCacheKey());
this.listenTo(this.messages, 'add', (m) => this.onMessageAdded(m));
this.listenTo(this.messages, 'change:upload', (m) => this.onMessageUploadChanged(m));
this.listenTo(this.messages, 'change:correcting', (m) => this.onMessageCorrecting(m));
}
fetchMessages() {
if (this.messages.fetched_flag) {
log.info(`Not re-fetching messages for ${this.get('jid')}`);
return;
}
this.messages.fetched_flag = true;
const resolve = this.messages.fetched.resolve;
this.messages.fetch({
add: true,
success: () => {
this.afterMessagesFetched();
resolve();
},
error: () => {
this.afterMessagesFetched();
resolve();
},
});
return this.messages.fetched;
}
afterMessagesFetched() {
this.pruneHistoryWhenScrolledDown();
/**
* Triggered whenever a {@link ModelWithMessages}
* has fetched its messages from the local cache.
* @event _converse#afterMessagesFetched
* @type {ModelWithMessages}
* @example _converse.api.listen.on('afterMessagesFetched', (model) => { ... });
*/
api.trigger('afterMessagesFetched', this);
}
/**
* @param {MessageAttributes|Error} _attrs_or_error
*/
async onMessage(_attrs_or_error) {
throw new errors.MethodNotImplementedError('onMessage is not implemented');
}
/**
* @param {BaseMessage} message
* @param {MessageAttributes} attrs
* @returns {object}
*/
getUpdatedMessageAttributes(message, attrs) {
if (!attrs.error_type && message.get('error_type') === 'Decryption') {
// Looks like we have a failed decrypted message stored, and now
// we have a properly decrypted version of the same message.
// See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
return Object.assign({}, attrs, {
error_condition: undefined,
error_message: undefined,
error_text: undefined,
error_type: undefined,
is_archived: attrs.is_archived,
is_ephemeral: false,
is_error: false,
});
} else {
return {
is_archived: attrs.is_archived,
time: attrs.time ? attrs.time : message.get('time'),
};
}
}
/**
* @param {BaseMessage} message
* @param {MessageAttributes} attrs
*/
updateMessage(message, attrs) {
const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
new_attrs && message.save(new_attrs);
}
/**
* Determines whether the given attributes of an incoming message
* represent a XEP-0308 correction and, if so, handles it appropriately.
* @param {MessageAttributes|MUCMessageAttributes} attrs - Attributes representing a received
* message, as returned by {@link parseMessage}
* @returns {Promise<BaseMessage|void>} Returns the corrected
* message or `undefined` if not applicable.
*/
async handleCorrection(attrs) {
if (!attrs.replace_id || !attrs.from) {
return;
}
let query;
if (attrs.type === 'groupchat') {
const { occupant_id, replace_id } = /** @type {MUCMessageAttributes} */ (attrs);
query = occupant_id
? ({ attributes: m }) => m.msgid === replace_id && m.occupant_id == occupant_id
: ({ attributes: m }) =>
m.msgid === attrs.replace_id && m.from === attrs.from && m.occupant_id == null;
} else {
query = ({ attributes: m }) =>
m.msgid === attrs.replace_id && m.from === attrs.from && m.occupant_id == null;
}
const message = this.messages.models.find(query);
if (!message) {
attrs['older_versions'] = {};
return await this.createMessage(attrs); // eslint-disable-line no-return-await
}
const older_versions = message.get('older_versions') || {};
if (attrs.time < message.get('time') && message.get('edited')) {
// This is an older message which has been corrected afterwards
older_versions[attrs.time] = attrs['message'];
message.save({ 'older_versions': older_versions });
} else {
// This is a correction of an earlier message we already received
if (Object.keys(older_versions).length) {
older_versions[message.get('edited')] = message.getMessageText();
} else {
older_versions[message.get('time')] = message.getMessageText();
}
attrs = Object.assign(attrs, { older_versions });
delete attrs['msgid']; // We want to keep the msgid of the original message
delete attrs['id']; // Delete id, otherwise a new cache entry gets created
attrs['time'] = message.get('time');
message.save(attrs);
}
return message;
}
/**
* Queue an incoming `chat` message stanza for processing.
* @param {MessageAttributes} attrs - A promise which resolves to the message attributes
*/
queueMessage(attrs) {
this.msg_chain = (this.msg_chain || this.messages.fetched)
.then(() => this.onMessage(attrs))
.catch((e) => log.error(e));
return this.msg_chain;
}
/**
* @param {MessageAttributes} [_attrs]
* @return {Promise<MessageAttributes>}
*/
async getOutgoingMessageAttributes(_attrs) {
throw new errors.MethodNotImplementedError('getOutgoingMessageAttributes is not implemented');
}
/**
* Responsible for sending off a text message inside an ongoing chat conversation.
* @param {Object} [attrs] - A map of attributes to be saved on the message
* @returns {Promise<BaseMessage>}
* @example
* const chat = api.chats.get('buddy1@example.org');
* chat.sendMessage({'body': 'hello world'});
*/
async sendMessage(attrs) {
await converse.emojis?.initialized_promise;
if (!this.canPostMessages()) {
log.warn('sendMessage was called but canPostMessages is false');
return;
}
attrs = await this.getOutgoingMessageAttributes(attrs);
let message = this.messages.findWhere('correcting');
if (message) {
const older_versions = message.get('older_versions') || {};
const edited_time = message.get('edited') || message.get('time');
older_versions[edited_time] = message.getMessageText();
message.save({
...['body', 'is_only_emojis', 'media_urls', 'references', 'is_encrypted'].reduce((obj, k) => {
if (attrs.hasOwnProperty(k)) obj[k] = attrs[k];
return obj;
}, {}),
...{
correcting: false,
edited: new Date().toISOString(),
message: attrs.body,
ogp_metadata: [],
older_versions,
origin_id: u.getUniqueId(),
plaintext: attrs.is_encrypted ? attrs.message : undefined,
received: undefined,
},
});
} else {
this.setEditable(attrs, new Date().toISOString());
message = await this.createMessage(attrs);
}
try {
const stanza = await this.createMessageStanza(message);
api.send(stanza);
} catch (e) {
message.destroy();
log.error(e);
return;
}
/**
* Triggered when a message is being sent out
* @event _converse#sendMessage
* @type {Object}
* @param {Object} data
* @property {(ChatBox|MUC)} data.chatbox
* @property {(BaseMessage)} data.message
*/
api.trigger('sendMessage', { 'chatbox': this, message });
return message;
}
/**
* Retract one of your messages in this chat
* @param {BaseMessage} message - The message which we're retracting.
*/
retractOwnMessage(message) {
const retraction_id = u.getUniqueId();
sendRetractionMessage(this.get('jid'), message, retraction_id);
message.save({
'retracted': new Date().toISOString(),
'retracted_id': message.get('origin_id'),
'retraction_id': retraction_id,
'is_ephemeral': true,
'editable': false,
});
}
/**
* @param {File[]} files'
*/
async sendFiles(files) {
const { __, session } = _converse;
const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, session.get('domain'));
const item = result.pop();
if (!item) {
this.createMessage({
'message': __('Sorry, looks like file upload is not supported by your server.'),
'type': 'error',
'is_ephemeral': true,
});
return;
}
const data = item.dataforms
.where({ 'FORM_TYPE': { 'value': Strophe.NS.HTTPUPLOAD, 'type': 'hidden' } })
.pop();
const max_file_size = parseInt((data?.attributes || {})['max-file-size']?.value, 10);
const slot_request_url = item?.id;
if (!slot_request_url) {
this.createMessage({
'message': __('Sorry, looks like file upload is not supported by your server.'),
'type': 'error',
'is_ephemeral': true,
});
return;
}
Array.from(files).forEach(async (file) => {
/**
* *Hook* which allows plugins to transform files before they'll be
* uploaded. The main use-case is to encrypt the files.
* @event _converse#beforeFileUpload
* @param {ChatBox|MUC} chat - The chat from which this file will be uploaded.
* @param {File} file - The file that will be uploaded
*/
file = await api.hook('beforeFileUpload', this, file);
if (!isNaN(max_file_size) && file.size > max_file_size) {
const size = filesize(max_file_size);
const message = Array.isArray(size)
? __('The size of your file, %1$s, exceeds the maximum allowed by your server.', file.name)
: __(
'The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
file.name,
size
);
return this.createMessage({
message,
type: 'error',
is_ephemeral: true,
});
} else {
const initial_attrs = await this.getOutgoingMessageAttributes();
const attrs = Object.assign(initial_attrs, {
'file': true,
'progress': 0,
'slot_request_url': slot_request_url,
});
this.setEditable(attrs, new Date().toISOString());
const message = await this.createMessage(attrs, { 'silent': true });
message.file = file;
this.messages.trigger('add', message);
message.getRequestSlotURL();
}
});
}
/**
* Responsible for setting the editable attribute of messages.
* If api.settings.get('allow_message_corrections') is "last", then only the last
* message sent from me will be editable. If set to "all" all messages
* will be editable. Otherwise no messages will be editable.
* @param {Object} attrs An object containing message attributes.
* @param {String} send_time - time when the message was sent
*/
setEditable(attrs, send_time) {
if (attrs.is_headline || u.isEmptyMessage(attrs) || attrs.sender !== 'me') {
return;
}
if (api.settings.get('allow_message_corrections') === 'all') {
attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
} else if (api.settings.get('allow_message_corrections') === 'last' && send_time > this.get('time_sent')) {
this.set({ 'time_sent': send_time });
this.messages.findWhere({ 'editable': true })?.save({ 'editable': false });
attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
}
}
/**
* Mutator for setting the chat state of this chat session.
* Handles clearing of any chat state notification timeouts and
* setting new ones if necessary.
* Timeouts are set when the state being set is COMPOSING or PAUSED.
* After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
* See XEP-0085 Chat State Notifications.
* @param {string} state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
* @param {object} [options]
*/
setChatState(state, options) {
if (this.chat_state_timeout !== undefined) {
clearTimeout(this.chat_state_timeout);
delete this.chat_state_timeout;
}
if (state === constants.COMPOSING) {
this.chat_state_timeout = setTimeout(
this.setChatState.bind(this),
_converse.TIMEOUTS.PAUSED,
constants.PAUSED
);
} else if (state === constants.PAUSED) {
this.chat_state_timeout = setTimeout(
this.setChatState.bind(this),
_converse.TIMEOUTS.INACTIVE,
constants.INACTIVE
);
}
this.set('chat_state', state, options);
return this;
}
/**
* @param {BaseMessage} message
*/
onMessageAdded(message) {
if (
api.settings.get('prune_messages_above') &&
(api.settings.get('pruning_behavior') === 'scrolled' || !this.ui.get('scrolled')) &&
!u.isEmptyMessage(message)
) {
this.debouncedPruneHistory();
}
}
/**
* @param {BaseMessage} message
*/
async onMessageUploadChanged(message) {
if (message.get('upload') === constants.SUCCESS) {
const attrs = {
'body': message.get('body'),
'spoiler_hint': message.get('spoiler_hint'),
'oob_url': message.get('oob_url'),
};
await this.sendMessage(attrs);
message.destroy();
}
}
/**
* @param {BaseMessage} message
*/
onMessageCorrecting(message) {
if (message.get('correcting')) {
this.save({ correcting: message.get('id'), draft: u.prefixMentions(message) });
} else {
this.save({ correcting: undefined, draft: undefined });
}
}
onScrolledChanged() {
if (!this.ui.get('scrolled')) {
this.clearUnreadMsgCounter();
this.pruneHistoryWhenScrolledDown();
}
}
pruneHistoryWhenScrolledDown() {
if (
api.settings.get('prune_messages_above') &&
api.settings.get('pruning_behavior') === 'unscrolled' &&
!this.ui.get('scrolled')
) {
this.debouncedPruneHistory();
}
}
/**
* @param {MessageAttributes} attrs
* @returns {Promise<boolean>}
*/
shouldShowErrorMessage(attrs) {
const msg = this.getMessageReferencedByError(attrs);
if (!msg && attrs.chat_state) {
// If the error refers to a message not included in our store,
// and it has a chat state tag, we assume that this was a
// CSI message (which we don't store).
// See https://github.com/conversejs/converse.js/issues/1317
return;
}
// Gets overridden
// Return promise because subclasses need to return promises
return Promise.resolve(true);
}
async clearMessages() {
try {
await this.messages.clearStore();
} catch (e) {
this.messages.trigger('reset');
log.error(e);
} finally {
// No point in fetching messages from the cache if it's been cleared.
// Make sure to resolve the fetched promise to avoid freezes.
this.messages.fetched.resolve();
}
}
editEarlierMessage() {
let message;
let idx = this.messages.findLastIndex('correcting');
if (idx >= 0) {
this.messages.at(idx).save('correcting', false);
while (idx > 0) {
idx -= 1;
const candidate = this.messages.at(idx);
if (candidate.get('editable')) {
message = candidate;
break;
}
}
}
message =
message ||
this.messages
.filter({ sender: 'me' })
.reverse()
.find((m) => m.get('editable'));
message?.save('correcting', true);
}
editLaterMessage() {
let message;
let idx = this.messages.findLastIndex('correcting');
if (idx >= 0) {
this.messages.at(idx).save('correcting', false);
while (idx < this.messages.length - 1) {
idx += 1;
const candidate = this.messages.at(idx);
if (candidate.get('editable')) {
message = candidate;
message.save('correcting', true);
break;
}
}
}
return message;
}
/**
* Used by sub-classes to indicate wether a message is a chat
* message, as opposed to error or info messages.
* @param {BaseMessage} _message
* @returns {boolean}
*/
isChatMessage(_message) {
throw new errors.MethodNotImplementedError();
}
/** @returns {BaseMessage} */
getOldestMessage() {
for (let i = 0; i < this.messages.length; i++) {
const message = this.messages.at(i);
if (this.isChatMessage(message)) {
return message;
}
}
}
/** @returns {BaseMessage} */
getMostRecentMessage() {
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages.at(i);
if (this.isChatMessage(message)) {
return message;
}
}
}
/**
* Given an error `<message>` stanza's attributes, find the saved message model which is
* referenced by that error.
* @param {object} attrs
*/
getMessageReferencedByError(attrs) {
const id = attrs.msgid;
return id && this.messages.models.find((m) => [m.get('msgid'), m.get('retraction_id')].includes(id));
}
/**
* Looks whether we already have a retraction for this
* incoming message. If so, it's considered "dangling" because it
* probably hasn't been applied to anything yet, given that the
* relevant message is only coming in now.
* @param {object} attrs - Attributes representing a received
* message, as returned by {@link parseMessage}
* @returns {BaseMessage|null}
*/
findDanglingRetraction(attrs) {
if (!attrs.origin_id || !this.messages.length) {
return null;
}
// Only look for dangling retractions if there are newer
// messages than this one, since retractions come after.
if (this.messages.last().get('time') > attrs.time) {
// Search from latest backwards
const messages = Array.from(this.messages.models);
messages.reverse();
return messages.find(
({ attributes }) =>
attributes.retracted_id === attrs.origin_id &&
attributes.from === attrs.from &&
!attributes.moderated_by
);
}
return null;
}
/**
* Returns an already cached message (if it exists) based on the
* passed in attributes map.
* @param {object} attrs - Attributes representing a received
* message, as returned by {@link parseMessage}
* @returns {BaseMessage}
*/
getDuplicateMessage(attrs) {
const queries = [
...this.getStanzaIdQueryAttrs(attrs),
this.getOriginIdQueryAttrs(attrs),
this.getMessageBodyQueryAttrs(attrs),
].filter((s) => s);
return this.messages.models.find(
/** @param {BaseMessage} m */
(m) => queries.find((q) => Object.keys(q).every((k) => m.get(k) === q[k]))
);
}
/**
* @param {object} attrs - Attributes representing a received
*/
getOriginIdQueryAttrs(attrs) {
return attrs.origin_id && { origin_id: attrs.origin_id, from: attrs.from };
}
/**
* @param {object} attrs - Attributes representing a received
*/
getStanzaIdQueryAttrs(attrs) {
const keys = Object.keys(attrs).filter((k) => k.startsWith('stanza_id '));
return keys.map((key) => {
const by_jid = key.replace(/^stanza_id /, '');
const query = {};
query[`stanza_id ${by_jid}`] = attrs[key];
return query;
});
}
/**
* @param {object} attrs - Attributes representing a received
*/
getMessageBodyQueryAttrs(attrs) {
if (attrs.msgid) {
const query = {
from: attrs.from,
msgid: attrs.msgid,
};
// XXX: Need to take XEP-428 <fallback> into consideration
if (!attrs.is_encrypted && attrs.body) {
// We can't match the message if it's a reflected
// encrypted message (e.g. via MAM or in a MUC)
query['body'] = attrs.body;
}
return query;
}
}
/**
* Given the passed in message object, send a XEP-0333 chat marker.
* @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.
*/
async sendMarkerForMessage(msg, type = 'displayed', force = false) {
if (!msg || msg?.get('type') === 'groupchat' || !api.settings.get('send_chat_markers').includes(type)) {
return;
}
// Don't send chat markers to contacts that are not subscribed
// to our presence.
const contact = await api.contacts.get(this.get('jid'));
const subscription = contact?.get('subscription');
if (!contact || subscription === 'none' || subscription === 'to') {
return;
}
if (msg?.get('is_markable') || force) {
const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
}
}
/**
* Given a newly received {@link BaseMessage} instance,
* update the unread counter if necessary.
* @param {BaseMessage} message
*/
handleUnreadMessage(message) {
if (!message?.get('body')) {
return;
}
if (isNewMessage(message)) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.ui.set('scrolled', false);
} else if (this.isHidden()) {
this.incrementUnreadMsgsCounter(message);
} else {
this.sendMarkerForMessage(message);
}
}
}
/**
* @param {BaseMessage} message
* @param {MessageAttributes} attrs
*/
async getErrorAttributesForMessage(message, attrs) {
const { __ } = _converse;
const new_attrs = {
editable: false,
error: attrs.error,
error_condition: attrs.error_condition,
error_text: attrs.error_text,
error_type: attrs.error_type,
is_error: true,
};
if (attrs.msgid === message.get('retraction_id')) {
// The error message refers to a retraction
new_attrs.retraction_id = undefined;
if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("You're not allowed to retract your message.");
} 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 = __("You're not allowed to send a message.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
}
}
/**
* *Hook* which allows plugins to add application-specific attributes
* @event _converse#getErrorAttributesForMessage
*/
return await api.hook('getErrorAttributesForMessage', attrs, new_attrs);
}
/**
* @param {Element} stanza
*/
async handleErrorMessageStanza(stanza) {
const attrs_or_error = await parseMessage(stanza);
if (u.isErrorObject(attrs_or_error)) {
const { stanza, message } = /** @type {errors.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 message = this.getMessageReferencedByError(attrs);
if (message) {
const new_attrs = await this.getErrorAttributesForMessage(message, attrs);
message.save(new_attrs);
} else {
this.createMessage(attrs);
}
}
/**
* @param {BaseMessage} message
*/
incrementUnreadMsgsCounter(message) {
const settings = {
'num_unread': this.get('num_unread') + 1,
};
if (this.get('num_unread') === 0) {
settings['first_unread_id'] = message.get('id');
}
this.save(settings);
}
clearUnreadMsgCounter() {
if (this.get('num_unread') > 0) {
this.sendMarkerForMessage(this.messages.last());
}
u.safeSave(this, { num_unread: 0 });
}
/**
* Handles message retraction based on the passed in attributes.
* @param {MessageAttributes} attrs - Attributes representing a received
* message, as returned by {@link parseMessage}
* @returns {Promise<Boolean>} Returns `true` or `false` depending on
* whether a message was retracted or not.
*/
async handleRetraction(attrs) {
const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id', 'editable'];
if (attrs.retracted) {
if (attrs.is_tombstone) return false;
for (const m of this.messages.models) {
if (m.get('from') !== attrs.from) continue;
if (m.get('origin_id') === attrs.retracted_id || m.get('msgid') === attrs.retracted_id) {
m.save(pick(attrs, RETRACTION_ATTRIBUTES));
return true;
}
}
attrs['dangling_retraction'] = true;
await this.createMessage(attrs);
return true;
} else {
// Check if we have dangling retraction
const message = this.findDanglingRetraction(attrs);
if (message) {
const retraction_attrs = pick(message.attributes, RETRACTION_ATTRIBUTES);
const new_attrs = Object.assign({ dangling_retraction: false }, attrs, retraction_attrs);
delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
message.save(new_attrs);
return true;
}
}
return false;
}
/**
* @param {MessageAttributes} attrs
*/
handleReceipt(attrs) {
if (attrs.sender === 'them') {
if (attrs.is_valid_receipt_request) {
sendReceiptStanza(attrs.from, attrs.msgid);
} else if (attrs.receipt_id) {
const message = this.messages.findWhere({ 'msgid': attrs.receipt_id });
if (message && !message.get('received')) {
message.save({ 'received': new Date().toISOString() });
}
return true;
}
}
return false;
}
/**
* Given a {@link BaseMessage} return the XML stanza that represents it.
* @method ChatBox#createMessageStanza
* @param {BaseMessage} message - The message object
*/
async createMessageStanza(message) {
const {
body,
edited,
is_encrypted,
is_spoiler,
msgid,
oob_url,
origin_id,
references,
spoiler_hint,
type,
} = message.attributes;
const stanza = stx`
<message xmlns="jabber:client"
from="${message.get('type') === 'groupchat' ? api.connection.get().jid : message.get('from')}"
to="${message.get('to') || this.get('jid')}"
type="${this.get('message_type')}"
id="${(edited && u.getUniqueId()) || msgid}">
${body ? stx`<body>${body}</body>` : ''}
<active xmlns="${Strophe.NS.CHATSTATES}"/>
${type === 'chat' ? stx`<request xmlns="${Strophe.NS.RECEIPTS}"></request>` : ''}
${!is_encrypted && oob_url ? stx`<x xmlns="${Strophe.NS.OUTOFBAND}"><url>${oob_url}</url></x>` : ''}
${!is_encrypted && is_spoiler ? stx`<spoiler xmlns="${Strophe.NS.SPOILER}">${spoiler_hint ?? ''}</spoiler>` : ''}
${
!is_encrypted
? references?.map(
(ref) => stx`<reference xmlns="${Strophe.NS.REFERENCE}"
begin="${ref.begin}"
end="${ref.end}"
type="${ref.type}"
uri="${ref.uri}"></reference>`
)
: ''
}
${edited ? stx`<replace xmlns="${Strophe.NS.MESSAGE_CORRECT}" id="${msgid}"></replace>` : ''}
${origin_id ? stx`<origin-id xmlns="${Strophe.NS.SID}" id="${origin_id}"></origin-id>` : ''}
</message>`;
/**
* *Hook* which allows plugins to update an outgoing message stanza
* @event _converse#createMessageStanza
* @param {ChatBox|MUC} chat - The chat from
* which this message stanza is being sent.
* @param {Object} data - BaseMessage data
* @param {BaseMessage} data.message
* The message object from which the stanza is created and which gets persisted to storage.
* @param {Builder} data.stanza
* The stanza that will be sent out, as a Strophe.Builder object.
* You can use the Strophe.Builder functions to extend the stanza.
* See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
*/
const data = await api.hook('createMessageStanza', this, { message, stanza });
return data.stanza;
}
/**
* Prunes the message history to ensure it does not exceed the maximum
* number of messages specified in the settings.
*/
pruneHistory() {
const max_history = api.settings.get('prune_messages_above');
if (max_history && typeof max_history === 'number') {
if (this.messages.length > max_history) {
const non_empty_messages = this.messages.filter((m) => !u.isEmptyMessage(m));
if (non_empty_messages.length > max_history) {
while (non_empty_messages.length > max_history) {
non_empty_messages.shift().destroy();
}
/**
* Triggered once the message history has been pruned, i.e.
* once older messages have been removed to keep the
* number of messages below the value set in `prune_messages_above`.
*/
this.trigger('historyPruned');
}
}
}
}
debouncedPruneHistory = debounce(() => this.pruneHistory(), 500, { maxWait: 2000 });
isScrolledUp() {
return this.ui.get('scrolled');
}
/**
* Indicates whether the chat is hidden and therefore
* whether a newly received message will be visible to the user or not.
* @returns {boolean}
*/
isHidden() {
return this.get('hidden') || this.isScrolledUp() || document.hidden;
}
};
}