xmpp.chapril.org-conversejs/src/headless/converse-chat.js
JC Brand b4dafcc45b Add support for XEP-0424 and XEP-0425
- Add support for switching ephemerality after message creation
- Move more methods from ChatBox and ChatRoom to utils/stanza.js
- Rename 'ephemeral' to 'is_ephemeral' since it's a boolean
2019-11-22 13:50:36 +01:00

1398 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.

import { get, isObject, isString, pick } from "lodash";
import converse from "./converse-core";
import filesize from "filesize";
import log from "./log";
import stanza_utils from "./utils/stanza";
const { $msg, Backbone, 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 } = this;
const { __ } = _converse;
// Configuration values for this plugin
// ====================================
// Refer to docs/source/configuration.rst for explanations of these
// configuration settings.
_converse.api.settings.update({
'auto_join_private_chats': [],
'clear_messages_on_reconnection': false,
'filter_by_resource': false,
'allow_message_corrections': 'all',
'send_chat_state_notifications': true
});
const ModelWithContact = Backbone.Model.extend({
initialize () {
this.rosterContactAdded = u.getResolveablePromise();
},
async setRosterContact (jid) {
const contact = await _converse.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 () {
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();
await _converse.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;
}
},
safeDestroy () {
try {
this.destroy()
} catch (e) {
log.error(e);
}
},
isOnlyChatStateNotification () {
return u.isOnlyChatStateNotification(this);
},
isEphemeral () {
return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this);
},
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.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 _converse.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 = _converse.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(_converse.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)}`});
if (_converse.vcards) {
this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
}
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);
this.initMessages();
await this.fetchMessages();
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) {
_converse.api.send(this.createMessageStanza(message));
}
});
},
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 => { ... });
*/
_converse.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 onMessage (stanza, original_stanza, from_jid) {
const message = await this.getDuplicateMessage(stanza);
if (message) {
this.updateMessage(message, original_stanza);
} else if (
!this.handleReceipt (stanza, from_jid) &&
!this.handleChatMarker(stanza, from_jid)
) {
const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
if (this.handleRetraction(attrs)) {
return;
}
this.setEditable(attrs, attrs.time, stanza);
if (attrs['chat_state'] ||
attrs['retracted'] || // Retraction received *before* the message
!u.isEmptyMessage(attrs)
) {
const msg = this.handleCorrection(attrs) || this.messages.create(attrs);
this.incrementUnreadMsgCounter(msg);
}
}
},
async clearMessages () {
try {
await Promise.all(this.messages.models.map(m => m.destroy()));
this.messages.reset();
} 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 (_converse.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 => { ... });
*/
_converse.api.trigger('chatReconnected', this);
},
onReconnection () {
if (_converse.clear_messages_on_reconnection) {
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 = _converse.auto_join_private_chats.concat(room_jids);
if (_converse.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');
}
},
createMessageFromError (error) {
if (error instanceof _converse.TimeoutError) {
const msg = this.messages.create({'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, stanza) { // eslint-disable-line no-unused-vars
return {
'is_archived': stanza_utils.isArchived(stanza),
}
},
updateMessage (message, stanza) {
// Overridden in converse-muc and converse-mam
const attrs = this.getUpdatedMessageAttributes(message, stanza);
if (attrs) {
message.save(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;
},
/**
* @private
* @method _converse.ChatBox#shouldShowErrorMessage
* @returns {boolean}
*/
shouldShowErrorMessage (stanza) {
const id = stanza.getAttribute('id');
if (id) {
const msgs = this.messages.where({'msgid': id});
const referenced_msgs = msgs.filter(m => m.get('type') !== 'error');
if (!referenced_msgs.length && stanza.querySelector('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;
}
const dupes = msgs.filter(m => m.get('type') === 'error');
if (dupes.length) {
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 stanza_utils.getMessageAttributesFromStanza}
* @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 stanza_utils.getMessageAttributesFromStanza}
* @returns { Boolean } Returns `true` or `false` depending on
* whether a message was retracted or not.
*/
handleRetraction (attrs) {
const RETRACTION_ATTRIBUTES = ['retracted', 'retracted_id'];
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;
this.messages.create(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 stanza_utils.getMessageAttributesFromStanza}
* @returns { _converse.Message|undefined } Returns the corrected
* message or `undefined` if not applicable.
*/
handleCorrection (attrs) {
if (!attrs.replaced_id || !attrs.from) {
return;
}
const message = this.messages.findWhere({'msgid': attrs.replaced_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;
},
async getDuplicateMessage (stanza) {
return this.findDuplicateFromOriginID(stanza) ||
await this.findDuplicateFromStanzaID(stanza) ||
this.findDuplicateFromMessage(stanza);
},
findDuplicateFromOriginID (stanza) {
const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
if (!origin_id) {
return null;
}
return this.messages.findWhere({
'origin_id': origin_id.getAttribute('id'),
'from': stanza.getAttribute('from')
});
},
async findDuplicateFromStanzaID (stanza) {
const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
if (!stanza_id) {
return false;
}
const by_jid = stanza_id.getAttribute('by');
if (!(await _converse.api.disco.supports(Strophe.NS.SID, by_jid))) {
return false;
}
const query = {};
query[`stanza_id ${by_jid}`] = stanza_id.getAttribute('id');
return this.messages.findWhere(query);
},
findDuplicateFromMessage (stanza) {
const text = stanza_utils.getMessageBody(stanza) || undefined;
if (!text) { return false; }
const id = stanza.getAttribute('id');
if (!id) { return false; }
return this.messages.findWhere({
'message': text,
'from': stanza.getAttribute('from'),
'msgid': id
});
},
/**
* 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);
},
sendMarker(to_jid, id, type) {
const stanza = $msg({
'from': _converse.connection.jid,
'id': u.getUniqueId(),
'to': to_jid,
'type': 'chat',
}).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
_converse.api.send(stanza);
},
handleChatMarker (stanza, from_jid) {
const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to'));
if (to_bare_jid !== _converse.bare_jid) {
return false;
}
const markers = sizzle(`[xmlns="${Strophe.NS.MARKERS}"]`, stanza);
if (markers.length === 0) {
return false;
} else if (markers.length > 1) {
log.error('handleChatMarker: Ignoring incoming stanza with multiple message markers');
log.error(stanza);
return false;
} else {
const marker = markers.pop();
if (marker.nodeName === 'markable') {
if (this.contact && !u.isMAMMessage(stanza) && !u.isCarbonMessage(stanza)) {
this.sendMarker(from_jid, stanza.getAttribute('id'), 'received');
}
return false;
} else {
const msgid = marker && marker.getAttribute('id'),
message = msgid && this.messages.findWhere({msgid}),
field_name = `marker_${marker.nodeName}`;
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();
_converse.api.send(receipt_stanza);
},
handleReceipt (stanza, from_jid) {
const is_me = Strophe.getBareJidFromJid(from_jid) === _converse.bare_jid;
const requests_receipt = sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop() !== undefined;
if (requests_receipt && !is_me && !u.isCarbonMessage(stanza)) {
this.sendReceiptStanza(from_jid, stanza.getAttribute('id'));
}
const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to'));
if (to_bare_jid === _converse.bare_jid) {
const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
if (receipt) {
const msgid = receipt && receipt.getAttribute('id'),
message = msgid && this.messages.findWhere({msgid});
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_single_emoji': 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 _converse.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, stanza) {
if (stanza && u.isHeadlineMessage(_converse, stanza)) {
return;
}
if (u.isEmptyMessage(attrs) || attrs.sender !== 'me') {
return;
}
if (_converse.allow_message_corrections === 'all') {
attrs.editable = !(attrs.file || 'oob_url' in attrs);
} else if ((_converse.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 || 'oob_url' in attrs);
}
},
/**
* Responsible for sending off a text message inside an ongoing chat conversation.
* @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 = _converse.api.chats.get('buddy1@example.com');
* chat.sendMessage('hello world');
*/
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_single_emoji': attrs.message ? u.isOnlyEmojis(attrs.message) : false,
'origin_id': u.getUniqueId(),
'received': undefined
});
} else {
this.setEditable(attrs, (new Date()).toISOString());
message = this.messages.create(attrs);
}
_converse.api.send(this.createMessageStanza(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 (_converse.send_chat_state_notifications && this.get('chat_state')) {
const allowed = _converse.send_chat_state_notifications;
if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
return;
}
_converse.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 _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
const item = result.pop();
if (!item) {
this.messages.create({
'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(),
max_file_size = window.parseInt(get(data, 'attributes.max-file-size.value')),
slot_request_url = get(item, 'id');
if (!slot_request_url) {
this.messages.create({
'message': __("Sorry, looks like file upload is not supported by your server."),
'type': 'error',
'is_ephemeral': true
});
return;
}
Array.from(files).forEach(file => {
if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
return this.messages.create({
'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 = this.messages.create(attrs, {'silent': true});
message.file = file;
this.messages.trigger('add', message);
message.getRequestSlotURL();
}
});
},
/**
* Parses a passed in message stanza and returns an object of attributes.
* @private
* @method _converse.ChatBox#getMessageAttributesFromStanza
* @param { XMLElement } stanza - The message stanza
* @param { XMLElement } original_stanza - The original stanza, that contains the
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns { Object }
*/
getMessageAttributesFromStanza (stanza, original_stanza) {
// XXX: Eventually we want to get rid of this pass-through
// method but currently we still need it because converse-omemo
// overrides it.
return stanza_utils.getMessageAttributesFromStanza(stanza, original_stanza, this, _converse);
},
maybeShow () {
// Returns the chatbox
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 || !message.get('message')) {
return;
}
if (utils.isNewMessage(message) && this.isHidden()) {
this.save({'num_unread': this.get('num_unread') + 1});
_converse.incrementMsgCounter();
}
},
clearUnreadMsgCounter () {
u.safeSave(this, {'num_unread': 0});
},
isScrolledUp () {
return this.get('scrolled', true);
}
});
function rejectMessage (stanza, text) {
// Reject an incoming message by replying with an error message of type "cancel".
_converse.api.send(
$msg({
'to': stanza.getAttribute('from'),
'type': 'error',
'id': stanza.getAttribute('id')
}).c('error', {'type': 'cancel'})
.c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up()
.c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text)
);
log.warn(`Rejecting message stanza with the following reason: ${text}`);
log.warn(stanza);
}
async function handleErrorMessage (stanza) {
const from_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
return;
}
const chatbox = await _converse.api.chatboxes.get(from_jid);
if (!chatbox) {
return;
}
const should_show = await chatbox.shouldShowErrorMessage(stanza);
if (!should_show) {
return;
}
const attrs = await chatbox.getMessageAttributesFromStanza(stanza, stanza);
await chatbox.messages.create(attrs);
}
/**
* Handler method for all incoming single-user chat "message" stanzas.
* @private
* @method _converse#handleMessageStanza
* @param { XMLElement } stanza - The incoming message stanza
*/
_converse.handleMessageStanza = async function (stanza) {
const original_stanza = stanza;
let to_jid = stanza.getAttribute('to');
const to_resource = Strophe.getResourceFromJid(to_jid);
if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
return log.info(`onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`);
} else if (utils.isHeadlineMessage(_converse, stanza)) {
// XXX: Prosody sends headline messages with the
// wrong type ('chat'), so we need to filter them out here.
return log.info(`onMessage: Ignoring incoming headline message from JID: ${stanza.getAttribute('from')}`);
}
const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
if (bare_forward) {
return rejectMessage(
stanza,
'Forwarded messages not part of an encapsulating protocol are not supported'
);
}
let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
if (u.isCarbonMessage(stanza)) {
if (from_jid === _converse.bare_jid) {
const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
stanza = sizzle(selector, stanza).pop();
to_jid = stanza.getAttribute('to');
from_jid = stanza.getAttribute('from');
} else {
// Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
return rejectMessage(stanza, 'Rejecting carbon from invalid JID');
}
}
if (u.isMAMMessage(stanza)) {
if (from_jid === _converse.bare_jid) {
const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
stanza = sizzle(selector, stanza).pop();
to_jid = stanza.getAttribute('to');
from_jid = stanza.getAttribute('from');
} else {
return log.warn(`onMessage: Ignoring alleged MAM message from ${stanza.getAttribute('from')}`);
}
}
const from_bare_jid = Strophe.getBareJidFromJid(from_jid);
const is_me = from_bare_jid === _converse.bare_jid;
if (is_me && to_jid === null) {
return log.error(`Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`);
}
const contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid;
const contact = await _converse.api.contacts.get(contact_jid);
if (contact === undefined && !_converse.allow_non_roster_messaging) {
log.error(`Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`);
return log.error(stanza);
}
// Get chat box, but only create when the message has something to show to the user
const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length > 0;
const roster_nick = get(contact, 'attributes.nickname');
const chatbox = await _converse.api.chats.get(contact_jid, {'nickname': roster_nick}, has_body);
chatbox && await chatbox.onMessage(stanza, original_stanza, from_jid);
/**
* Triggered when a message stanza is been received and processed.
* @event _converse#message
* @type { object }
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox
* @property { XMLElement } stanza
* @example _converse.api.listen.on('message', obj => { ... });
*/
_converse.api.trigger('message', {'stanza': original_stanza, 'chatbox': chatbox});
}
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.
_converse.auto_join_private_chats.forEach(jid => {
if (_converse.chatboxes.where({'jid': jid}).length) {
return;
}
if (isString(jid)) {
_converse.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(() => { ... });
*/
_converse.api.trigger('privateChatsAutoJoined');
}
/************************ BEGIN Route Handlers ************************/
function openChat (jid) {
if (!utils.isValidJID(jid)) {
return log.warn(`Invalid JID "${jid}" provided in URL fragment`);
}
_converse.api.chats.open(jid);
}
_converse.router.route('converse/chat?jid=:jid', openChat);
/************************ END Route Handlers ************************/
/************************ BEGIN Event Handlers ************************/
_converse.api.listen.on('chatBoxesFetched', autoJoinChats);
_converse.api.listen.on('presencesInitialized', () => registerMessageHandlers());
_converse.api.listen.on('clearSession', () => {
if (_converse.shouldClearCache()) {
_converse.chatboxes.filter(c => c.messages && c.messages.clearSession({'silent': true}));
}
});
/************************ END Event Handlers ************************/
/************************ BEGIN API ************************/
Object.assign(_converse.api, {
/**
* The "chats" namespace (used for one-on-one chats)
*
* @namespace _converse.api.chats
* @memberOf _converse.api
*/
chats: {
/**
* @method _converse.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 && !get(attrs, 'fullname')) {
const contact = await _converse.api.contacts.get(jids);
attrs.fullname = get(contact, 'attributes.fullname');
}
const chatbox = _converse.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 _converse.api.contacts.get(jids);
attrs.fullname = get(contact, 'attributes.fullname');
return _converse.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 _converse.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!
* _converse.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!
* _converse.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 _converse.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 => _converse.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 _converse.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 _converse.api.chats.get('buddy@example.com');
*
* @example
* // To return an array of chats, provide an array of JIDs:
* const models = await _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
*
* @example
* // To return all open chats, call the method without any parameters::
* const models = await _converse.api.chats.get();
*
*/
async get (jids, attrs={}, create=false) {
async function _get (jid) {
let model = await _converse.api.chatboxes.get(jid);
if (!model && create) {
model = await _converse.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 _converse.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 ************************/
}
});