|
@@ -1,1504 +0,0 @@
|
|
|
-/**
|
|
|
- * @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 ************************/
|
|
|
-
|
|
|
- }
|
|
|
-});
|