b4dafcc45b
- 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
1398 lines
62 KiB
JavaScript
1398 lines
62 KiB
JavaScript
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 server’s 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 ************************/
|
||
|
||
}
|
||
});
|