Commit e3ebde97 authored by JC Brand's avatar JC Brand

Move converse-chat plugin into folder

parent 01e03fc6
......@@ -4482,9 +4482,9 @@
}
},
"@octokit/openapi-types": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-1.2.2.tgz",
"integrity": "sha512-vrKDLd/Rq4IE16oT+jJkDBx0r29NFkdkU8GwqVSP4RajsAvP23CMGtFhVK0pedUhAiMvG1bGnFcTC/xCKaKgmw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.0.tgz",
"integrity": "sha512-J4bfM7lf8oZvEAdpS71oTvC1ofKxfEZgU5vKVwzZKi4QPiL82udjpseJwxPid9Pu2FNmyRQOX4iEj6W1iOSnPw==",
"dev": true
},
"@octokit/plugin-enterprise-rest": {
......@@ -4628,12 +4628,12 @@
}
},
"@octokit/types": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.0.3.tgz",
"integrity": "sha512-6y0Emzp+uPpdC5QLzUY1YRklvqiZBMTOz2ByhXdmTFlc3lNv8Mi28dX1U1b4scNtFMUa3tkpjofNFJ5NqMJaZw==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.1.0.tgz",
"integrity": "sha512-bMWBmg77MQTiRkOVyf50qK3QECWOEy43rLy/6fTWZ4HEwAhNfqzMcjiBDZAowkILwTrFvzE1CpP6gD0MuPHS+A==",
"dev": true,
"requires": {
"@octokit/openapi-types": "^1.2.0",
"@octokit/openapi-types": "^2.0.0",
"@types/node": ">= 8"
}
},
......@@ -22471,9 +22471,9 @@
},
"dependencies": {
"ws": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz",
"integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==",
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.1.tgz",
"integrity": "sha512-pTsP8UAfhy3sk1lSk/O/s4tjD0CRwvMnzvwr4OKGX7ZvqZtUyx4KIJB5JWbkykPoc55tixMGgTNoh3k4FkNGFQ==",
"optional": true
}
}
......
......@@ -80,7 +80,7 @@ describe("A Chat Message", function () {
await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
// Test that pressing the down arrow cancels message correction
expect(textarea.value).toBe('');
await u.waitUntil(() => textarea.value === '')
view.onKeyDown({
target: textarea,
keyCode: 38 // Up arrow
......
......@@ -7,7 +7,7 @@ import "./plugins/bookmarks.js"; // XEP-0199 XMPP Ping
import "./plugins/bosh.js"; // XEP-0206 BOSH
import "./plugins/caps.js"; // XEP-0115 Entity Capabilities
import "./plugins/carbons.js"; // XEP-0280 Message Carbons
import "./plugins/chat.js"; // RFC-6121 Instant messaging
import "./plugins/chat/index.js"; // RFC-6121 Instant messaging
import "./plugins/chatboxes.js";
import "./plugins/disco.js"; // XEP-0030 Service discovery
import "./plugins/headlines.js"; // Support for headline messages
......
/**
* @module converse-chat
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import filesize from "filesize";
import log from "../log.js";
import st from "../utils/stanza";
import { Collection } from "@converse/skeletor/src/collection";
import { Model } from '@converse/skeletor/src/model.js';
import { _converse, api, converse } from "../core.js";
import { find, isMatch, isObject, pick } from "lodash-es";
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.extend({
'allow_message_corrections': 'all',
'allow_message_retraction': 'all',
'allow_message_styling': true,
'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') || this.get('body') || __('Undecryptable OMEMO message');
}
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 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 = 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': _converse.isUniView() && !api.settings.get('singleton'),
'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-${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.fetched = u.getResolveablePromise();
this.messages.fetched.then(() => {
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
* `sessionStorage` but **NOT** from the server.
* @event _converse#afterMessagesFetched
* @type {_converse.ChatBoxView | _converse.ChatRoomView}
* @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
*/
api.trigger('afterMessagesFetched', this);
});
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_flag) {
log.info(`Not re-fetching messages for ${this.get('jid')}`);
return;
}
this.messages.fetched_flag = true;
const resolve = this.messages.fetched.resolve;
this.messages.fetch({
'add': true,
'success': () => { this.afterMessagesFetched(); resolve() },
'error': () => { this.afterMessagesFetched(); resolve() }
});
return this.messages.fetched;
},
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,
'editable': false,
};
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 { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
*/
queueMessage (attrs) {
this.msg_chain = (this.msg_chain || this.messages.fetched)
.then(() => this.onMessage(attrs))
.catch(e => log.error(e));
return this.msg_chain;
},
/**
* @async
* @private
* @method _converse.ChatRoom#onMessage
* @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
*/
async onMessage (attrs) {
attrs = await attrs;
if (u.isErrorObject(attrs)) {
attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message);
}
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.handleUnreadMessage(msg);
}
}
},
async clearMessages () {
try {
await this.messages.clearStore();
} catch (e) {
this.messages.trigger('reset');
log.error(e);
} finally {
delete this.msg_chain;
delete this.messages.fetched_flag;
this.messages.fetched = u.getResolveablePromise();
}
},
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) && !api.settings.get('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_event_id': error.retry_event_id
});
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) {
const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
new_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) {
// 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
if(Object.keys(older_versions).length) {
older_versions[message.get('edited')] = message.get('message');
}else {
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
attrs['time'] = message.get('time');
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) {
const query = {
'from': attrs.from,
'msgid': attrs.msgid
}
if (!attrs.is_encrypted) {
// We can't match the message if it's a reflected
// encrypted message (e.g. via MAM or in a MUC)
query['message'] = attrs.message;
}
return query;
}
},
/**
* 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_archived && !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_valid_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();
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
return {
'from': _converse.bare_jid,
'fullname': _converse.xmppstatus.get('fullname'),
'id': origin_id,
'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
'jid': this.get('jid'),
'message': body,
'msgid': origin_id,
'nickname': this.get('nickname'),
'sender': 'me',
'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
'time': (new Date()).toISOString(),
'type': this.get('message_type'),
body,
is_spoiler,
origin_id
}
},
/**
* 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 || 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);
}
},
/**
* Queue the creation of a message, to make sure that we don't run
* into a race condition whereby we're creating a new message
* before the collection has been fetched.
* @async
* @private
* @method _converse.ChatRoom#queueMessageCreation
* @param { Object } attrs
*/
async createMessage (attrs, options) {
attrs.time = attrs.time || (new Date()).toISOString();
await this.messages.fetched;
const p = this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
return p;
},
/**
* 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 (force) {
if (force) {
if (_converse.isUniView()) {
// We only have one chat visible at any one time.
// So before opening a chat, we make sure all other chats are hidden.
const filter = c => !c.get('hidden') &&
c.get('jid') !== this.get('jid') &&
c.get('id') !== 'controlbox';
_converse.chatboxes.filter(filter).forEach(c => u.safeSave(c, {'hidden': true}));
}
u.safeSave(this, {'hidden': false});
}
if (_converse.isUniView() && this.get('hidden')) {
return;
} else {
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 () {
// Note: This methods gets overridden by converse-minimize
const hidden = _converse.isUniView() && this.get('hidden');
return hidden || this.isScrolledUp() || _converse.windowState === 'hidden';
},
/**
* Given a newly received {@link _converse.Message} instance,
* update the unread counter if necessary.
* @private
* @param {_converse.Message} message
*/
handleUnreadMessage (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);
} 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);
await chatbox?.queueMessage(attrs);
/**
* @typedef { Object } MessageData
* An object containing the original message stanza, as well as the
* parsed attributes.
* @property { XMLElement } stanza
* @property { MessageAttributes } stanza
* @property { ChatBox } chatbox
*/
const data = {stanza, attrs, chatbox};
/**
* Triggered when a message stanza is been received and processed.
* @event _converse#message
* @type { object }
* @property { module:converse-chat~MessageData } data
*/
api.trigger('message', data);
}
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 (typeof jid === 'string') {
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', async () => {
if (_converse.shouldClearCache()) {
await Promise.all(_converse.chatboxes.map(c => c.messages && c.messages.clearStore({'silent': true})));
const filter = (o) => (o.get('type') !== _converse.CONTROLBOX_TYPE);
_converse.chatboxes.clearStore({'silent': true}, filter);
}
});
/************************ 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 (typeof jids === 'string') {
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 (typeof jids === 'string') {
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 (typeof jids === 'string') {
return _get(jids);
}
return Promise.all(jids.map(jid => _get(jid)));
}
}
});
/************************ END API ************************/
}
});
import { _converse, api } from "../../core.js";
import log from "../../log.js";
export default {
/**
* 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 (typeof jids === 'string') {
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 (typeof jids === 'string') {
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 (typeof jids === 'string') {
return _get(jids);
}
return Promise.all(jids.map(jid => _get(jid)));
}
}
}
/**
* @module converse-chat
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import ChatBox from './model.js';
import MessageMixin from './message.js';
import ModelWithContact from './model-with-contact.js';
import chat_api from './api.js';
import log from '../../log.js';
import st from '../../utils/stanza';
import { Collection } from "@converse/skeletor/src/collection";
import { _converse, api, converse } from '../../core.js';
const { Strophe, sizzle, utils } = converse.env;
const u = converse.env.utils;
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);
}
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.
*/
Object.assign(api, chat_api);
// Configuration values for this plugin
// ====================================
// Refer to docs/source/configuration.rst for explanations of these
// configuration settings.
api.settings.extend({
'allow_message_corrections': 'all',
'allow_message_retraction': 'all',
'allow_message_styling': true,
'auto_join_private_chats': [],
'clear_messages_on_reconnection': false,
'filter_by_resource': false,
'send_chat_state_notifications': true
});
_converse.Message = ModelWithContact.extend(MessageMixin);
_converse.Messages = Collection.extend({
model: _converse.Message,
comparator: 'time'
});
_converse.ChatBox = ChatBox;
/**
* 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);
await chatbox?.queueMessage(attrs);
/**
* @typedef { Object } MessageData
* An object containing the original message stanza, as well as the
* parsed attributes.
* @property { XMLElement } stanza
* @property { MessageAttributes } stanza
* @property { ChatBox } chatbox
*/
const data = { stanza, attrs, chatbox };
/**
* Triggered when a message stanza is been received and processed.
* @event _converse#message
* @type { object }
* @property { module:converse-chat~MessageData } data
*/
api.trigger('message', data);
};
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 (typeof jid === 'string') {
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', async () => {
if (_converse.shouldClearCache()) {
await Promise.all(
_converse.chatboxes.map(c => c.messages && c.messages.clearStore({ 'silent': true }))
);
const filter = o => o.get('type') !== _converse.CONTROLBOX_TYPE;
_converse.chatboxes.clearStore({ 'silent': true }, filter);
}
});
/************************ END Event Handlers ************************/
}
});
import ModelWithContact from './model-with-contact.js';
import log from '../../log.js';
import { _converse, api, converse } from '../../core.js';
const u = converse.env.utils;
const { Strophe } = converse.env;
/**
* Mixin which turns a `ModelWithContact` model into a non-MUC message. These can be either `chat` messages or `headline` messages.
* @mixin
* @namespace _converse.Message
* @memberOf _converse
* @example const msg = new _converse.Message({'message': 'hello world!'});
*/
const MessageMixin = {
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 () {
const { __ } = _converse;
if (this.get('is_encrypted')) {
return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
}
return this.get('message');
},
isMeCommand () {
const text = this.getMessageText();
if (!text) {
return false;
}
return text.startsWith('/me ');
},
/**
* Send out an IQ stanza to request a file upload slot.
* https://xmpp.org/extensions/xep-0363.html#request
* @private
* @method _converse.Message#sendSlotRequestStanza
*/
sendSlotRequestStanza () {
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 () {
const { __ } = _converse;
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 = () => {
const { __ } = _converse;
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);
}
};
export default MessageMixin;
import { converse } from "../../core.js";
import { Model } from '@converse/skeletor/src/model.js';
const u = converse.env.utils;
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();
}
}
});
export default ModelWithContact;
import ModelWithContact from './model-with-contact.js';
import filesize from "filesize";
import log from "../../log.js";
import st from "../../utils/stanza";
import { Model } from '@converse/skeletor/src/model.js';
import { _converse, api, converse } from "../../core.js";
import { find, isMatch, isObject, pick } from "lodash-es";
const { Strophe, $msg } = converse.env;
const u = converse.env.utils;
/**
* Represents an open/ongoing chat conversation.
*
* @class
* @namespace _converse.ChatBox
* @memberOf _converse
*/
const ChatBox = ModelWithContact.extend({
defaults () {
return {
'bookmarked': false,
'chat_state': undefined,
'hidden': _converse.isUniView() && !api.settings.get('singleton'),
'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-${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();
},
getMessagesCollection () {
return new _converse.Messages();
},
getMessagesCacheKey () {
return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
},
initMessages () {
this.messages = this.getMessagesCollection();
this.messages.fetched = u.getResolveablePromise();
this.messages.fetched.then(() => {
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
* `sessionStorage` but **NOT** from the server.
* @event _converse#afterMessagesFetched
* @type {_converse.ChatBoxView | _converse.ChatRoomView}
* @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
*/
api.trigger('afterMessagesFetched', this);
});
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_flag) {
log.info(`Not re-fetching messages for ${this.get('jid')}`);
return;
}
this.messages.fetched_flag = true;
const resolve = this.messages.fetched.resolve;
this.messages.fetch({
'add': true,
'success': () => { this.afterMessagesFetched(); resolve() },
'error': () => { this.afterMessagesFetched(); resolve() }
});
return this.messages.fetched;
},
async handleErrorMessageStanza (stanza) {
const { __ } = _converse;
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,
'editable': false,
};
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 { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
*/
queueMessage (attrs) {
this.msg_chain = (this.msg_chain || this.messages.fetched)
.then(() => this.onMessage(attrs))
.catch(e => log.error(e));
return this.msg_chain;
},
/**
* @async
* @private
* @method _converse.ChatRoom#onMessage
* @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
*/
async onMessage (attrs) {
attrs = await attrs;
if (u.isErrorObject(attrs)) {
attrs.stanza && log.error(attrs.stanza);
return log.error(attrs.message);
}
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.handleUnreadMessage(msg);
}
}
},
async clearMessages () {
try {
await this.messages.clearStore();
} catch (e) {
this.messages.trigger('reset');
log.error(e);
} finally {
delete this.msg_chain;
delete this.messages.fetched_flag;
this.messages.fetched = u.getResolveablePromise();
}
},
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) && !api.settings.get('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_event_id': error.retry_event_id
});
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) {
const new_attrs = this.getUpdatedMessageAttributes(message, attrs);
new_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) {
// 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
if(Object.keys(older_versions).length) {
older_versions[message.get('edited')] = message.get('message');
}else {
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
attrs['time'] = message.get('time');
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) {
const query = {
'from': attrs.from,
'msgid': attrs.msgid
}
if (!attrs.is_encrypted) {
// We can't match the message if it's a reflected
// encrypted message (e.g. via MAM or in a MUC)
query['message'] = attrs.message;
}
return query;
}
},
/**
* 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_archived && !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_valid_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();
const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
return {
'from': _converse.bare_jid,
'fullname': _converse.xmppstatus.get('fullname'),
'id': origin_id,
'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
'jid': this.get('jid'),
'message': body,
'msgid': origin_id,
'nickname': this.get('nickname'),
'sender': 'me',
'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
'time': (new Date()).toISOString(),
'type': this.get('message_type'),
body,
is_spoiler,
origin_id
}
},
/**
* 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 || 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);
}
},
/**
* Queue the creation of a message, to make sure that we don't run
* into a race condition whereby we're creating a new message
* before the collection has been fetched.
* @async
* @private
* @method _converse.ChatRoom#queueMessageCreation
* @param { Object } attrs
*/
async createMessage (attrs, options) {
attrs.time = attrs.time || (new Date()).toISOString();
await this.messages.fetched;
const p = this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
return p;
},
/**
* 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 { __ } = _converse;
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 (force) {
if (force) {
if (_converse.isUniView()) {
// We only have one chat visible at any one time.
// So before opening a chat, we make sure all other chats are hidden.
const filter = c => !c.get('hidden') &&
c.get('jid') !== this.get('jid') &&
c.get('id') !== 'controlbox';
_converse.chatboxes.filter(filter).forEach(c => u.safeSave(c, {'hidden': true}));
}
u.safeSave(this, {'hidden': false});
}
if (_converse.isUniView() && this.get('hidden')) {
return;
} else {
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 () {
// Note: This methods gets overridden by converse-minimize
const hidden = _converse.isUniView() && this.get('hidden');
return hidden || this.isScrolledUp() || _converse.windowState === 'hidden';
},
/**
* Given a newly received {@link _converse.Message} instance,
* update the unread counter if necessary.
* @private
* @param {_converse.Message} message
*/
handleUnreadMessage (message) {
if (!message?.get('body')) {
return
}
if (u.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);
} 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);
}
});
export default ChatBox;
......@@ -4,7 +4,7 @@
* @license Mozilla Public License (MPLv2)
* @description Implements the non-view logic for XEP-0045 Multi-User Chat
*/
import "./chat";
import "./chat/index.js";
import "./disco";
import "./emoji/index.js";
import { Collection } from "@converse/skeletor/src/collection";
......@@ -398,7 +398,6 @@ converse.plugins.add('converse-muc', {
* @memberOf _converse
*/
_converse.ChatRoom = _converse.ChatBox.extend({
messagesCollection: _converse.ChatRoomMessages,
defaults () {
return {
......@@ -595,6 +594,10 @@ converse.plugins.add('converse-muc', {
this.announceReconnection();
},
getMessagesCollection () {
return new _converse.ChatRoomMessages();
},
restoreSession () {
const id = `muc.session-${_converse.bare_jid}-${this.get('jid')}`;
this.session = new MUCSession({id});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment