xmpp.chapril.org-conversejs/src/headless/converse-chat.js
JC Brand 5a57ded243 Add support for the XEP-0333 displayed chat marker
Credit for this work goes to @deleolajide
2020-06-01 18:01:02 +02:00

1450 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module converse-chat
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import filesize from "filesize";
import log from "./log";
import st from "./utils/stanza";
import { Collection } from "skeletor.js/src/collection";
import { Model } from 'skeletor.js/src/model.js';
import { _converse, api, converse } from "./converse-core";
import { find, isMatch, isObject, isString, pick } from "lodash";
const { $msg, Strophe, sizzle, utils } = converse.env;
const u = converse.env.utils;
converse.plugins.add('converse-chat', {
/* Optional dependencies are other plugins which might be
* overridden or relied upon, and therefore need to be loaded before
* this plugin. They are called "optional" because they might not be
* available, in which case any overrides applicable to them will be
* ignored.
*
* It's possible however to make optional dependencies non-optional.
* If the setting "strict_plugin_dependencies" is set to true,
* an error will be raised if the plugin is not found.
*
* NB: These plugins need to have already been loaded via require.js.
*/
dependencies: ["converse-chatboxes", "converse-disco"],
initialize () {
/* The initialize function gets called as soon as the plugin is
* loaded by converse.js's plugin machinery.
*/
const { __ } = _converse;
// Configuration values for this plugin
// ====================================
// Refer to docs/source/configuration.rst for explanations of these
// configuration settings.
api.settings.update({
'allow_message_corrections': 'all',
'allow_message_retraction': 'all',
'auto_join_private_chats': [],
'clear_messages_on_reconnection': false,
'filter_by_resource': false,
'send_chat_state_notifications': true
});
const ModelWithContact = Model.extend({
initialize () {
this.rosterContactAdded = u.getResolveablePromise();
},
async setRosterContact (jid) {
const contact = await api.contacts.get(jid);
if (contact) {
this.contact = contact;
this.set('nickname', contact.get('nickname'));
this.rosterContactAdded.resolve();
}
}
});
/**
* Represents a non-MUC message. These can be either `chat` messages or
* `headline` messages.
* @class
* @namespace _converse.Message
* @memberOf _converse
* @example const msg = new _converse.Message({'message': 'hello world!'});
*/
_converse.Message = ModelWithContact.extend({
defaults () {
return {
'msgid': u.getUniqueId(),
'time': (new Date()).toISOString(),
'is_ephemeral': false
};
},
async initialize () {
if (!this.checkValidity()) { return; }
this.initialized = u.getResolveablePromise();
if (this.get('type') === 'chat') {
ModelWithContact.prototype.initialize.apply(this, arguments);
this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
}
if (this.get('file')) {
this.on('change:put', this.uploadFile, this);
}
this.setTimerForEphemeralMessage();
/**
* Triggered once a {@link _converse.Message} has been created and initialized.
* @event _converse#messageInitialized
* @type { _converse.Message}
* @example _converse.api.listen.on('messageInitialized', model => { ... });
*/
await api.trigger('messageInitialized', this, {'Synchronous': true});
this.initialized.resolve();
},
/**
* Sets an auto-destruct timer for this message, if it's is_ephemeral.
* @private
* @method _converse.Message#setTimerForEphemeralMessage
* @returns { Boolean } - Indicates whether the message is
* ephemeral or not, and therefore whether the timer was set or not.
*/
setTimerForEphemeralMessage () {
const setTimer = () => {
this.ephemeral_timer = window.setTimeout(this.safeDestroy.bind(this), 10000);
}
if (this.isEphemeral()) {
setTimer();
return true;
} else {
this.on('change:is_ephemeral',
() => this.isEphemeral() ? setTimer() : clearTimeout(this.ephemeral_timer)
);
return false;
}
},
checkValidity () {
if (Object.keys(this.attributes).length === 3) {
// XXX: This is an empty message with only the 3 default values.
// This seems to happen when saving a newly created message
// fails for some reason.
// TODO: This is likely fixable by setting `wait` when
// creating messages. See the wait-for-messages branch.
this.validationError = "Empty message";
this.safeDestroy();
return false;
}
return true;
},
/**
* Determines whether this messsage may be retracted by the current user.
* @private
* @method _converse.Messages#mayBeRetracted
* @returns { Boolean }
*/
mayBeRetracted () {
const is_own_message = this.get('sender') === 'me';
return is_own_message && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
},
safeDestroy () {
try {
this.destroy()
} catch (e) {
log.error(e);
}
},
isEphemeral () {
return this.get('is_ephemeral');
},
getDisplayName () {
if (this.get('type') === 'groupchat') {
return this.get('nick');
} else if (this.contact) {
return this.contact.getDisplayName();
} else if (this.vcard) {
return this.vcard.getDisplayName();
} else {
return this.get('from');
}
},
getMessageText () {
if (this.get('is_encrypted')) {
return this.get('plaintext') ||
(_converse.loglevel === 'debug' ? __('Unencryptable OMEMO message') : null);
}
return this.get('message');
},
isMeCommand () {
const text = this.getMessageText();
if (!text) {
return false;
}
return text.startsWith('/me ');
},
sendSlotRequestStanza () {
/* Send out an IQ stanza to request a file upload slot.
*
* https://xmpp.org/extensions/xep-0363.html#request
*/
if (!this.file) {
return Promise.reject(new Error("file is undefined"));
}
const iq = converse.env.$iq({
'from': _converse.jid,
'to': this.get('slot_request_url'),
'type': 'get'
}).c('request', {
'xmlns': Strophe.NS.HTTPUPLOAD,
'filename': this.file.name,
'size': this.file.size,
'content-type': this.file.type
})
return api.sendIQ(iq);
},
async getRequestSlotURL () {
let stanza;
try {
stanza = await this.sendSlotRequestStanza();
} catch (e) {
log.error(e);
return this.save({
'type': 'error',
'message': __("Sorry, could not determine upload URL."),
'is_ephemeral': true
});
}
const slot = stanza.querySelector('slot');
if (slot) {
this.save({
'get': slot.querySelector('get').getAttribute('url'),
'put': slot.querySelector('put').getAttribute('url'),
});
} else {
return this.save({
'type': 'error',
'message': __("Sorry, could not determine file upload URL."),
'is_ephemeral': true
});
}
},
uploadFile () {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
if (xhr.readyState === XMLHttpRequest.DONE) {
log.info("Status: " + xhr.status);
if (xhr.status === 200 || xhr.status === 201) {
this.save({
'upload': _converse.SUCCESS,
'oob_url': this.get('get'),
'message': this.get('get')
});
} else {
xhr.onerror();
}
}
};
xhr.upload.addEventListener("progress", (evt) => {
if (evt.lengthComputable) {
this.set('progress', evt.loaded / evt.total);
}
}, false);
xhr.onerror = () => {
let message;
if (xhr.responseText) {
message = __('Sorry, could not succesfully upload your file. Your servers response: "%1$s"', xhr.responseText)
} else {
message = __('Sorry, could not succesfully upload your file.');
}
this.save({
'type': 'error',
'upload': _converse.FAILURE,
'message': message,
'is_ephemeral': true
});
};
xhr.open('PUT', this.get('put'), true);
xhr.setRequestHeader("Content-type", this.file.type);
xhr.send(this.file);
}
});
_converse.Messages = Collection.extend({
model: _converse.Message,
comparator: 'time'
});
/**
* Represents an open/ongoing chat conversation.
*
* @class
* @namespace _converse.ChatBox
* @memberOf _converse
*/
_converse.ChatBox = ModelWithContact.extend({
messagesCollection: _converse.Messages,
defaults () {
return {
'bookmarked': false,
'chat_state': undefined,
'hidden': ['mobile', 'fullscreen'].includes(api.settings.get("view_mode")),
'message_type': 'chat',
'nickname': undefined,
'num_unread': 0,
'time_sent': (new Date(0)).toISOString(),
'time_opened': this.get('time_opened') || (new Date()).getTime(),
'type': _converse.PRIVATE_CHAT_TYPE,
'url': ''
}
},
async initialize () {
this.initialized = u.getResolveablePromise();
ModelWithContact.prototype.initialize.apply(this, arguments);
const jid = this.get('jid');
if (!jid) {
// XXX: The `validate` method will prevent this model
// from being persisted if there's no jid, but that gets
// called after model instantiation, so we have to deal
// with invalid models here also.
// This happens when the controlbox is in browser storage,
// but we're in embedded mode.
return;
}
this.set({'box_id': `box-${btoa(jid)}`});
this.initNotifications();
this.initMessages();
if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
await this.setRosterContact(jid);
}
this.on('change:chat_state', this.sendChatState, this);
await this.fetchMessages();
/**
* Triggered once a {@link _converse.ChatBox} has been created and initialized.
* @event _converse#chatBoxInitialized
* @type { _converse.ChatBox}
* @example _converse.api.listen.on('chatBoxInitialized', model => { ... });
*/
await api.trigger('chatBoxInitialized', this, {'Synchronous': true});
this.initialized.resolve();
},
getMessagesCacheKey () {
return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
},
initMessages () {
this.messages = new this.messagesCollection();
this.messages.chatbox = this;
this.messages.browserStorage = _converse.createStore(this.getMessagesCacheKey());
this.listenTo(this.messages, 'change:upload', message => {
if (message.get('upload') === _converse.SUCCESS) {
api.send(this.createMessageStanza(message));
}
});
},
initNotifications () {
this.notifications = new Model();
},
afterMessagesFetched () {
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
* `sessionStorage` but **NOT** from the server.
* @event _converse#afterMessagesFetched
* @type {_converse.ChatBox | _converse.ChatRoom}
* @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
*/
api.trigger('afterMessagesFetched', this);
},
fetchMessages () {
if (this.messages.fetched) {
log.info(`Not re-fetching messages for ${this.get('jid')}`);
return;
}
this.messages.fetched = u.getResolveablePromise();
const resolve = this.messages.fetched.resolve;
this.messages.fetch({
'add': true,
'success': () => { this.afterMessagesFetched(); resolve() },
'error': () => { this.afterMessagesFetched(); resolve() }
});
return this.messages.fetched;
},
async handleErrorMessageStanza (stanza) {
const attrs = await st.parseMessage(stanza, _converse);
if (!await this.shouldShowErrorMessage(attrs)) {
return;
}
const message = this.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,
};
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.');
}
}
message.save(new_attrs);
} else {
this.createMessage(attrs);
}
},
/**
* Queue an incoming `chat` message stanza for processing.
* @async
* @private
* @method _converse.ChatRoom#queueMessage
* @param { XMLElement } stanza - The message stanza.
*/
queueMessage (attrs) {
this.msg_chain = (this.msg_chain || this.messages.fetched);
this.msg_chain = this.msg_chain.then(() => this.onMessage(attrs));
return this.msg_chain;
},
async onMessage (attrs) {
attrs = await attrs;
if (u.isErrorObject(attrs)) {
attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message);
}
// TODO: move to OMEMO
attrs = attrs.encrypted ? await this.decrypt(attrs) : attrs;
const message = this.getDuplicateMessage(attrs);
if (message) {
this.updateMessage(message, attrs);
} else if (
!this.handleReceipt(attrs) &&
!this.handleChatMarker(attrs) &&
!(await this.handleRetraction(attrs))
) {
this.setEditable(attrs, attrs.time);
if (attrs['chat_state'] && attrs.sender === 'them') {
this.notifications.set('chat_state', attrs.chat_state);
}
if (u.shouldCreateMessage(attrs)) {
const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
this.notifications.set({'chat_state': null});
this.incrementUnreadMsgCounter(msg);
}
}
},
async clearMessages () {
try {
await this.messages.clearStore();
} catch (e) {
this.messages.trigger('reset');
log.error(e);
} finally {
delete this.messages.fetched;
}
},
async close () {
try {
await new Promise((success, reject) => {
return this.destroy({success, 'error': (m, e) => reject(e)})
});
} catch (e) {
log.error(e);
} finally {
if (api.settings.get('clear_messages_on_reconnection')) {
await this.clearMessages();
}
}
},
announceReconnection () {
/**
* Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
* @event _converse#onChatReconnected
* @type {_converse.ChatBox | _converse.ChatRoom}
* @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
*/
api.trigger('chatReconnected', this);
},
async onReconnection () {
if (api.settings.get('clear_messages_on_reconnection')) {
await this.clearMessages();
}
this.announceReconnection();
},
validate (attrs) {
if (!attrs.jid) {
return 'Ignored ChatBox without JID';
}
const room_jids = _converse.auto_join_rooms.map(s => isObject(s) ? s.jid : s);
const auto_join = api.settings.get('auto_join_private_chats').concat(room_jids);
if (api.settings.get("singleton") && !auto_join.includes(attrs.jid) && !_converse.auto_join_on_invite) {
const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;
log.warn(msg);
return msg;
}
},
getDisplayName () {
if (this.contact) {
return this.contact.getDisplayName();
} else if (this.vcard) {
return this.vcard.getDisplayName();
} else {
return this.get('jid');
}
},
async createMessageFromError (error) {
if (error instanceof _converse.TimeoutError) {
const msg = await this.createMessage({
'type': 'error',
'message': error.message,
'retry': true
});
msg.error = error;
}
},
getOldestMessage () {
for (let i=0; i<this.messages.length; i++) {
const message = this.messages.at(i);
if (message.get('type') === this.get('message_type')) {
return message;
}
}
},
getMostRecentMessage () {
for (let i=this.messages.length-1; i>=0; i--) {
const message = this.messages.at(i);
if (message.get('type') === this.get('message_type')) {
return message;
}
}
},
getUpdatedMessageAttributes (message, attrs) {
// Filter the attrs object, restricting it to only the `is_archived` key.
return (({ is_archived }) => ({ is_archived }))(attrs)
},
updateMessage (message, attrs) {
// Overridden in converse-muc and converse-mam
const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
if (attrs) {
message.save(new_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.
* @private
* @method _converse.ChatBox#setChatState
* @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
*/
setChatState (state, options) {
if (this.chat_state_timeout !== undefined) {
window.clearTimeout(this.chat_state_timeout);
delete this.chat_state_timeout;
}
if (state === _converse.COMPOSING) {
this.chat_state_timeout = window.setTimeout(
this.setChatState.bind(this),
_converse.TIMEOUTS.PAUSED,
_converse.PAUSED
);
} else if (state === _converse.PAUSED) {
this.chat_state_timeout = window.setTimeout(
this.setChatState.bind(this),
_converse.TIMEOUTS.INACTIVE,
_converse.INACTIVE
);
}
this.set('chat_state', state, options);
return this;
},
/**
* 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));
},
/**
* @private
* @method _converse.ChatBox#shouldShowErrorMessage
* @returns {boolean}
*/
shouldShowErrorMessage (attrs) {
const msg = this.getMessageReferencedByError(attrs);
if (!msg && attrs.body === null) {
// If the error refers to a message not included in our store,
// and it doesn't have a <body> 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 in ChatRoom
return true;
},
isSameUser (jid1, jid2) {
return u.isSameBareJID(jid1, jid2);
},
/**
* 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.
* @private
* @method _converse.ChatBox#findDanglingRetraction
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link st.parseMessage}
* @returns { _converse.Message }
*/
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
);
}
},
/**
* Handles message retraction based on the passed in attributes.
* @private
* @method _converse.ChatBox#handleRetraction
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link st.parseMessage}
* @returns { 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;
}
const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from});
if (!message) {
attrs['dangling_retraction'] = true;
await this.createMessage(attrs);
return true;
}
message.save(pick(attrs, RETRACTION_ATTRIBUTES));
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;
},
/**
* Determines whether the passed in message attributes represent a
* message which corrects a previously received message, or an
* older message which has already been corrected.
* In both cases, update the corrected message accordingly.
* @private
* @method _converse.ChatBox#handleCorrection
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link st.parseMessage}
* @returns { _converse.Message|undefined } Returns the corrected
* message or `undefined` if not applicable.
*/
handleCorrection (attrs) {
if (!attrs.replace_id || !attrs.from) {
return;
}
const message = this.messages.findWhere({'msgid': attrs.replace_id, 'from': attrs.from});
if (!message) {
return;
}
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
older_versions[message.get('time')] = message.get('message');
attrs = Object.assign(attrs, {'older_versions': older_versions});
delete attrs['id']; // Delete id, otherwise a new cache entry gets created
message.save(attrs);
}
return message;
},
/**
* Returns an already cached message (if it exists) based on the
* passed in attributes map.
* @private
* @method _converse.ChatBox#getDuplicateMessage
* @param { object } attrs - Attributes representing a received
* message, as returned by {@link st.parseMessage}
* @returns {Promise<_converse.Message>}
*/
getDuplicateMessage (attrs) {
const queries = [
...this.getStanzaIdQueryAttrs(attrs),
this.getOriginIdQueryAttrs(attrs),
this.getMessageBodyQueryAttrs(attrs)
].filter(s => s);
const msgs = this.messages.models;
return find(msgs, m => queries.reduce((out, q) => (out || isMatch(m.attributes, q)), false));
},
getOriginIdQueryAttrs (attrs) {
return attrs.origin_id && {'origin_id': attrs.origin_id, 'from': attrs.from};
},
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;
});
},
getMessageBodyQueryAttrs (attrs) {
if (attrs.message && attrs.msgid) {
return {
'message': attrs.message,
'from': attrs.from,
'msgid': attrs.msgid
}
}
},
/**
* Retract one of your messages in this chat
* @private
* @method _converse.ChatBoxView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.sendRetractionMessage(message)
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id'),
'retraction_id': message.get('id'),
'is_ephemeral': true,
'editable': false
});
},
/**
* Sends a message stanza to retract a message in this chat
* @private
* @method _converse.ChatBox#sendRetractionMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
sendRetractionMessage (message) {
const origin_id = message.get('origin_id');
if (!origin_id) {
throw new Error("Can't retract message without a XEP-0359 Origin ID");
}
const msg = $msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': "chat"
})
.c('store', {xmlns: Strophe.NS.HINTS}).up()
.c("apply-to", {
'id': origin_id,
'xmlns': Strophe.NS.FASTEN
}).c('retract', {xmlns: Strophe.NS.RETRACT})
return _converse.connection.send(msg);
},
sendMarkerForMessage (msg) {
if (msg?.get('is_markable')) {
const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
this.sendMarker(from_jid, msg.get('msgid'), 'displayed', msg.get('type'));
}
},
sendMarker (to_jid, id, type, msg_type) {
const stanza = $msg({
'from': _converse.connection.jid,
'id': u.getUniqueId(),
'to': to_jid,
'type': msg_type ? msg_type : 'chat'
}).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
api.send(stanza);
},
handleChatMarker (attrs) {
const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
if (to_bare_jid !== _converse.bare_jid) {
return false;
}
if (attrs.is_markable) {
if (this.contact && !attrs.is_mam && !attrs.is_carbon) {
this.sendMarker(attrs.from, attrs.msgid, 'received');
}
return false;
} else if (attrs.marker_id) {
const message = this.messages.findWhere({'msgid': attrs.marker_id});
const field_name = `marker_${attrs.marker}`;
if (message && !message.get(field_name)) {
message.save({field_name: (new Date()).toISOString()});
}
return true;
}
},
sendReceiptStanza (to_jid, id) {
const receipt_stanza = $msg({
'from': _converse.connection.jid,
'id': u.getUniqueId(),
'to': to_jid,
'type': 'chat',
}).c('received', {'xmlns': Strophe.NS.RECEIPTS, 'id': id}).up()
.c('store', {'xmlns': Strophe.NS.HINTS}).up();
api.send(receipt_stanza);
},
handleReceipt (attrs) {
if (attrs.sender === 'them') {
if (attrs.is_receipt_request) {
this.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 _converse.Message} return the XML stanza that represents it.
* @private
* @method _converse.ChatBox#createMessageStanza
* @param { _converse.Message } message - The message object
*/
createMessageStanza (message) {
const stanza = $msg({
'from': _converse.connection.jid,
'to': this.get('jid'),
'type': this.get('message_type'),
'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
}).c('body').t(message.get('message')).up()
.c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
if (message.get('type') === 'chat') {
stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
}
if (message.get('is_spoiler')) {
if (message.get('spoiler_hint')) {
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
} else {
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
}
}
(message.get('references') || []).forEach(reference => {
const attrs = {
'xmlns': Strophe.NS.REFERENCE,
'begin': reference.begin,
'end': reference.end,
'type': reference.type,
}
if (reference.uri) {
attrs.uri = reference.uri;
}
stanza.c('reference', attrs).root();
});
if (message.get('oob_url')) {
stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
}
if (message.get('edited')) {
stanza.c('replace', {
'xmlns': Strophe.NS.MESSAGE_CORRECT,
'id': message.get('msgid')
}).root();
}
if (message.get('origin_id')) {
stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
}
return stanza;
},
getOutgoingMessageAttributes (text, spoiler_hint) {
const is_spoiler = this.get('composing_spoiler');
const origin_id = u.getUniqueId();
return {
'id': origin_id,
'jid': this.get('jid'),
'nickname': this.get('nickname'),
'msgid': origin_id,
'origin_id': origin_id,
'fullname': _converse.xmppstatus.get('fullname'),
'from': _converse.bare_jid,
'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
'sender': 'me',
'time': (new Date()).toISOString(),
'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
'is_spoiler': is_spoiler,
'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
'type': this.get('message_type')
}
},
/**
* 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.
* @method _converse.ChatBox#setEditable
* @memberOf _converse.ChatBox
* @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) {
return;
}
if (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});
const msg = this.messages.findWhere({'editable': true});
if (msg) {
msg.save({'editable': false});
}
attrs.editable = !(attrs.file || attrs.retracted || 'oob_url' in attrs);
}
},
/**
* @async
* @private
* @method _converse.ChatBox#createMessage
*/
createMessage (attrs, options) {
return this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
},
/**
* Responsible for sending off a text message inside an ongoing chat conversation.
* @private
* @method _converse.ChatBox#sendMessage
* @memberOf _converse.ChatBox
* @param { String } text - The chat message text
* @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler
* @returns { _converse.Message }
* @example
* const chat = api.chats.get('buddy1@example.com');
* chat.sendMessage('hello world');
*/
async sendMessage (text, spoiler_hint) {
const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
let message = this.messages.findWhere('correcting')
if (message) {
const older_versions = message.get('older_versions') || {};
older_versions[message.get('time')] = message.get('message');
message.save({
'correcting': false,
'edited': (new Date()).toISOString(),
'message': attrs.message,
'older_versions': older_versions,
'references': attrs.references,
'is_only_emojis': attrs.is_only_emojis,
'origin_id': u.getUniqueId(),
'received': undefined
});
} else {
this.setEditable(attrs, (new Date()).toISOString());
message = await this.createMessage(attrs);
}
api.send(this.createMessageStanza(message));
/**
* Triggered when a message is being sent out
* @event _converse#sendMessage
* @type { Object }
* @param { Object } data
* @property { (_converse.ChatBox | _converse.ChatRoom) } data.chatbox
* @property { (_converse.Message | _converse.ChatRoomMessage) } data.message
*/
api.trigger('sendMessage', {'chatbox': this, message});
return message;
},
/**
* Sends a message with the current XEP-0085 chat state of the user
* as taken from the `chat_state` attribute of the {@link _converse.ChatBox}.
* @private
* @method _converse.ChatBox#sendChatState
*/
sendChatState () {
if (api.settings.get('send_chat_state_notifications') && this.get('chat_state')) {
const allowed = api.settings.get('send_chat_state_notifications');
if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
return;
}
api.send(
$msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': 'chat'
}).c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
.c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
.c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
);
}
},
async sendFiles (files) {
const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.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 = window.parseInt((data?.attributes || {})['max-file-size']?.value);
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 => {
if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
return this.createMessage({
'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
file.name, filesize(max_file_size)),
'type': 'error',
'is_ephemeral': true
});
} else {
const attrs = Object.assign(
this.getOutgoingMessageAttributes(), {
'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();
}
});
},
maybeShow () {
return this.trigger("show");
},
/**
* 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.get('minimized') ||
this.isScrolledUp() ||
_converse.windowState === 'hidden';
},
/**
* Given a newly received {@link _converse.Message} instance,
* update the unread counter if necessary.
* @private
* @param {_converse.Message} message
*/
incrementUnreadMsgCounter (message) {
if (!message?.get('body')) {
return
}
if (utils.isNewMessage(message)) {
if (this.isHidden()) {
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);
_converse.incrementMsgCounter();
} else {
this.sendMarkerForMessage(message);
}
}
},
clearUnreadMsgCounter() {
if (this.get('num_unread') > 0) {
this.sendMarkerForMessage(this.messages.last());
}
u.safeSave(this, {'num_unread': 0});
},
isScrolledUp () {
return this.get('scrolled', true);
}
});
async function handleErrorMessage (stanza) {
const from_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
return;
}
const chatbox = await api.chatboxes.get(from_jid);
chatbox?.handleErrorMessageStanza(stanza);
}
/**
* Handler method for all incoming single-user chat "message" stanzas.
* @private
* @method _converse#handleMessageStanza
* @param { MessageAttributes } attrs - The message attributes
*/
_converse.handleMessageStanza = async function (stanza) {
if (st.isServerMessage(stanza)) {
// Prosody sends headline messages with type `chat`, so we need to filter them out here.
const from = stanza.getAttribute('from');
return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
}
const attrs = await st.parseMessage(stanza, _converse);
if (u.isErrorObject(attrs)) {
attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message);
}
const has_body = !!sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length;
const chatbox = await api.chats.get(attrs.contact_jid, {'nickname': attrs.nick }, has_body);
chatbox && await chatbox.queueMessage(attrs);
/**
* Triggered when a message stanza is been received and processed.
* @event _converse#message
* @type { object }
* @property { XMLElement } stanza
* @example _converse.api.listen.on('message', obj => { ... });
*/
api.trigger('message', {'stanza': stanza});
}
function registerMessageHandlers () {
_converse.connection.addHandler(stanza => {
if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) {
// MAM messages are handled in converse-mam.
// We shouldn't get MAM messages here because
// they shouldn't have a `type` attribute.
log.warn(`Received a MAM message with type "chat".`);
return true;
}
_converse.handleMessageStanza(stanza);
return true;
}, null, 'message', 'chat');
_converse.connection.addHandler(stanza => {
// Message receipts are usually without the `type` attribute. See #1353
if (stanza.getAttribute('type') !== null) {
// TODO: currently Strophe has no way to register a handler
// for stanzas without a `type` attribute.
// We could update it to accept null to mean no attribute,
// but that would be a backward-incompatible change
return true; // Gets handled above.
}
_converse.handleMessageStanza(stanza);
return true;
}, Strophe.NS.RECEIPTS, 'message');
_converse.connection.addHandler(stanza => {
handleErrorMessage(stanza);
return true;
}, null, 'message', 'error');
}
function autoJoinChats () {
// Automatically join private chats, based on the
// "auto_join_private_chats" configuration setting.
api.settings.get('auto_join_private_chats').forEach(jid => {
if (_converse.chatboxes.where({'jid': jid}).length) {
return;
}
if (isString(jid)) {
api.chats.open(jid);
} else {
log.error('Invalid jid criteria specified for "auto_join_private_chats"');
}
});
/**
* Triggered once any private chats have been automatically joined as
* specified by the `auto_join_private_chats` setting.
* See: https://conversejs.org/docs/html/configuration.html#auto-join-private-chats
* @event _converse#privateChatsAutoJoined
* @example _converse.api.listen.on('privateChatsAutoJoined', () => { ... });
* @example _converse.api.waitUntil('privateChatsAutoJoined').then(() => { ... });
*/
api.trigger('privateChatsAutoJoined');
}
/************************ BEGIN Route Handlers ************************/
function openChat (jid) {
if (!utils.isValidJID(jid)) {
return log.warn(`Invalid JID "${jid}" provided in URL fragment`);
}
api.chats.open(jid);
}
_converse.router.route('converse/chat?jid=:jid', openChat);
/************************ END Route Handlers ************************/
/************************ BEGIN Event Handlers ************************/
api.listen.on('chatBoxesFetched', autoJoinChats);
api.listen.on('presencesInitialized', registerMessageHandlers);
api.listen.on('clearSession', () => {
if (_converse.shouldClearCache()) {
return Promise.all(_converse.chatboxes.map(c => c.messages && c.messages.clearStore({'silent': true})));
}
});
/************************ END Event Handlers ************************/
/************************ BEGIN API ************************/
Object.assign(api, {
/**
* The "chats" namespace (used for one-on-one chats)
*
* @namespace api.chats
* @memberOf api
*/
chats: {
/**
* @method api.chats.create
* @param {string|string[]} jid|jids An jid or array of jids
* @param {object} [attrs] An object containing configuration attributes.
*/
async create (jids, attrs) {
if (isString(jids)) {
if (attrs && !attrs?.fullname) {
const contact = await api.contacts.get(jids);
attrs.fullname = contact?.attributes?.fullname;
}
const chatbox = api.chats.get(jids, attrs, true);
if (!chatbox) {
log.error("Could not open chatbox for JID: "+jids);
return;
}
return chatbox;
}
if (Array.isArray(jids)) {
return Promise.all(jids.forEach(async jid => {
const contact = await api.contacts.get(jids);
attrs.fullname = contact?.attributes?.fullname;
return api.chats.get(jid, attrs, true).maybeShow();
}));
}
log.error("chats.create: You need to provide at least one JID");
return null;
},
/**
* Opens a new one-on-one chat.
*
* @method api.chats.open
* @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
* @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
* @param {Boolean} [attrs.minimized] - Should the chat be created in minimized state.
* @param {Boolean} [force=false] - By default, a minimized
* chat won't be maximized (in `overlayed` view mode) and in
* `fullscreen` view mode a newly opened chat won't replace
* another chat already in the foreground.
* Set `force` to `true` if you want to force the chat to be
* maximized or shown.
* @returns {Promise} Promise which resolves with the
* _converse.ChatBox representing the chat.
*
* @example
* // To open a single chat, provide the JID of the contact you're chatting with in that chat:
* converse.plugins.add('myplugin', {
* initialize: function() {
* const _converse = this._converse;
* // Note, buddy@example.org must be in your contacts roster!
* api.chats.open('buddy@example.com').then(chat => {
* // Now you can do something with the chat model
* });
* }
* });
*
* @example
* // To open an array of chats, provide an array of JIDs:
* converse.plugins.add('myplugin', {
* initialize: function () {
* const _converse = this._converse;
* // Note, these users must first be in your contacts roster!
* api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then(chats => {
* // Now you can do something with the chat models
* });
* }
* });
*/
async open (jids, attrs, force) {
if (isString(jids)) {
const chat = await api.chats.get(jids, attrs, true);
if (chat) {
return chat.maybeShow(force);
}
return chat;
} else if (Array.isArray(jids)) {
return Promise.all(
jids.map(j => api.chats.get(j, attrs, true).then(c => c && c.maybeShow(force)))
.filter(c => c)
);
}
const err_msg = "chats.open: You need to provide at least one JID";
log.error(err_msg);
throw new Error(err_msg);
},
/**
* Retrieves a chat or all chats.
*
* @method api.chats.get
* @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
* @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
* @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
* @returns { Promise<_converse.ChatBox> }
*
* @example
* // To return a single chat, provide the JID of the contact you're chatting with in that chat:
* const model = await api.chats.get('buddy@example.com');
*
* @example
* // To return an array of chats, provide an array of JIDs:
* const models = await api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
*
* @example
* // To return all open chats, call the method without any parameters::
* const models = await api.chats.get();
*
*/
async get (jids, attrs={}, create=false) {
async function _get (jid) {
let model = await api.chatboxes.get(jid);
if (!model && create) {
model = await api.chatboxes.create(jid, attrs, _converse.ChatBox);
} else {
model = (model && model.get('type') === _converse.PRIVATE_CHAT_TYPE) ? model : null;
if (model && Object.keys(attrs).length) {
model.save(attrs);
}
}
return model;
}
if (jids === undefined) {
const chats = await api.chatboxes.get();
return chats.filter(c => (c.get('type') === _converse.PRIVATE_CHAT_TYPE));
} else if (isString(jids)) {
return _get(jids);
}
return Promise.all(jids.map(jid => _get(jid)));
}
}
});
/************************ END API ************************/
}
});