123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155 |
- import isMatch from "lodash-es/isMatch";
- import pick from "lodash-es/pick";
- import { getOpenPromise } from '@converse/openpromise';
- import { Model } from '@converse/skeletor';
- import { ACTIVE, PRIVATE_CHAT_TYPE, COMPOSING, INACTIVE, PAUSED, SUCCESS, GONE } from '../../shared/constants.js';
- import ModelWithContact from './model-with-contact.js';
- import _converse from '../../shared/_converse.js';
- import api from '../../shared/api/index.js';
- import converse from '../../shared/api/public.js';
- import log from '../../log.js';
- import { TimeoutError } from '../../shared/errors.js';
- import { debouncedPruneHistory, handleCorrection } from '../../shared/chat/utils.js';
- import { filesize } from "filesize";
- import { initStorage } from '../../utils/storage.js';
- import { isEmptyMessage } from '../../utils/index.js';
- import { isNewMessage } from './utils.js';
- import { isUniView } from '../../utils/session.js';
- import { parseMessage } from './parsers.js';
- import { sendMarker } from '../../shared/actions.js';
- const { Strophe, $msg, u } = converse.env;
- /**
- * Represents an open/ongoing chat conversation.
- */
- class ChatBox extends ModelWithContact {
- /**
- * @typedef {import('./message.js').default} Message
- * @typedef {import('../muc/muc.js').default} MUC
- * @typedef {import('../muc/message.js').default} MUCMessage
- * @typedef {module:plugin-chat-parsers.MessageAttributes} MessageAttributes
- * @typedef {import('strophe.js').Builder} Builder
- */
- defaults () {
- return {
- 'bookmarked': false,
- 'hidden': isUniView() && !api.settings.get('singleton'),
- 'message_type': 'chat',
- 'num_unread': 0,
- 'time_opened': this.get('time_opened') || (new Date()).getTime(),
- 'time_sent': (new Date(0)).toISOString(),
- 'type': PRIVATE_CHAT_TYPE,
- }
- }
- constructor (attrs, options) {
- super(attrs, options);
- this.disable_mam = false;
- }
- async initialize () {
- super.initialize();
- this.initialized = getOpenPromise();
- 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.initUI();
- this.initMessages();
- if (this.get('type') === PRIVATE_CHAT_TYPE) {
- const { presences } = _converse.state;
- this.presence = presences.get(jid) || presences.create({ jid });
- await this.setModelContact(jid);
- this.presence.on('change:show', (item) => this.onPresenceChanged(item));
- }
- this.on('change:chat_state', this.sendChatState, this);
- this.ui.on('change:scrolled', this.onScrolledChanged, this);
- await this.fetchMessages();
- /**
- * Triggered once a {@link ChatBox} has been created and initialized.
- * @event _converse#chatBoxInitialized
- * @type { ChatBox}
- * @example _converse.api.listen.on('chatBoxInitialized', model => { ... });
- */
- await api.trigger('chatBoxInitialized', this, {'Synchronous': true});
- this.initialized.resolve();
- }
- getMessagesCollection () {
- return new _converse.exports.Messages();
- }
- getMessagesCacheKey () {
- return `converse.messages-${this.get('jid')}-${_converse.session.get('bare_jid')}`;
- }
- initMessages () {
- this.messages = this.getMessagesCollection();
- this.messages.fetched = getOpenPromise();
- this.messages.chatbox = this;
- initStorage(this.messages, this.getMessagesCacheKey());
- this.listenTo(this.messages, 'change:upload', m => this.onMessageUploadChanged(m));
- this.listenTo(this.messages, 'add', m => this.onMessageAdded(m));
- }
- initUI () {
- this.ui = new Model();
- }
- initNotifications () {
- this.notifications = new Model();
- }
- getNotificationsText () {
- const { __ } = _converse;
- if (this.notifications?.get('chat_state') === COMPOSING) {
- return __('%1$s is typing', this.getDisplayName());
- } else if (this.notifications?.get('chat_state') === PAUSED) {
- return __('%1$s has stopped typing', this.getDisplayName());
- } else if (this.notifications?.get('chat_state') === GONE) {
- return __('%1$s has gone away', this.getDisplayName());
- } else {
- return '';
- }
- }
- afterMessagesFetched () {
- this.pruneHistoryWhenScrolledDown();
- /**
- * Triggered whenever a { @link ChatBox } or ${ @link MUC }
- * has fetched its messages from the local cache.
- * @event _converse#afterMessagesFetched
- * @type { ChatBox| MUC }
- * @example _converse.api.listen.on('afterMessagesFetched', (chat) => { ... });
- */
- 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;
- }
- /**
- * @param {Element} stanza
- */
- async handleErrorMessageStanza (stanza) {
- const { __ } = _converse;
- const attrs = await parseMessage(stanza);
- 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
- * @method ChatBox#queueMessage
- * @param {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
- * @method ChatBox#onMessage
- * @param {Promise<MessageAttributes>} attrs_promise - A promise which resolves to the message attributes.
- */
- async onMessage (attrs_promise) {
- const attrs = await attrs_promise;
- 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 = await handleCorrection(this, attrs) || await this.createMessage(attrs);
- this.notifications.set({'chat_state': null});
- this.handleUnreadMessage(msg);
- }
- }
- }
- async onMessageUploadChanged (message) {
- if (message.get('upload') === SUCCESS) {
- const attrs = {
- 'body': message.get('body'),
- 'spoiler_hint': message.get('spoiler_hint'),
- 'oob_url': message.get('oob_url')
- }
- await this.sendMessage(attrs);
- message.destroy();
- }
- }
- onMessageAdded (message) {
- if (api.settings.get('prune_messages_above') &&
- (api.settings.get('pruning_behavior') === 'scrolled' || !this.ui.get('scrolled')) &&
- !isEmptyMessage(message)
- ) {
- debouncedPruneHistory(this);
- }
- }
- async clearMessages () {
- try {
- await this.messages.clearStore();
- } catch (e) {
- this.messages.trigger('reset');
- log.error(e);
- } finally {
- // No point in fetching messages from the cache if it's been cleared.
- // Make sure to resolve the fetched promise to avoid freezes.
- this.messages.fetched.resolve();
- }
- }
- /**
- * @param {Object} [_ev]
- */
- async close (_ev) {
- if (api.connection.connected()) {
- // Immediately sending the chat state, because the
- // model is going to be destroyed afterwards.
- this.setChatState(INACTIVE);
- this.sendChatState();
- }
- 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();
- }
- }
- /**
- * Triggered once a chatbox has been closed.
- * @event _converse#chatBoxClosed
- * @type {ChatBox | MUC}
- * @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
- */
- api.trigger('chatBoxClosed', this);
- }
- announceReconnection () {
- /**
- * Triggered whenever a `ChatBox` instance has reconnected after an outage
- * @event _converse#onChatReconnected
- * @type {ChatBox | MUC}
- * @example _converse.api.listen.on('onChatReconnected', chat => { ... });
- */
- api.trigger('chatReconnected', this);
- }
- async onReconnection () {
- if (api.settings.get('clear_messages_on_reconnection')) {
- await this.clearMessages();
- }
- this.announceReconnection();
- }
- onPresenceChanged (item) {
- const { __ } = _converse;
- const show = item.get('show');
- const fullname = this.getDisplayName();
- let text;
- if (show === 'offline') {
- text = __('%1$s has gone offline', fullname);
- } else if (show === 'away') {
- text = __('%1$s has gone away', fullname);
- } else if (show === 'dnd') {
- text = __('%1$s is busy', fullname);
- } else if (show === 'online') {
- text = __('%1$s is online', fullname);
- }
- text && this.createMessage({ 'message': text, 'type': 'info' });
- }
- onScrolledChanged () {
- if (!this.ui.get('scrolled')) {
- this.clearUnreadMsgCounter();
- this.pruneHistoryWhenScrolledDown();
- }
- }
- pruneHistoryWhenScrolledDown () {
- if (
- api.settings.get('prune_messages_above') &&
- api.settings.get('pruning_behavior') === 'unscrolled' &&
- !this.ui.get('scrolled')
- ) {
- debouncedPruneHistory(this);
- }
- }
- validate (attrs) {
- if (!attrs.jid) {
- return 'Ignored ChatBox without JID';
- }
- const room_jids = api.settings.get('auto_join_rooms').map(s => (s instanceof Object) ? 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 TimeoutError) {
- const msg = await this.createMessage({
- 'type': 'error',
- 'message': error.message,
- 'retry_event_id': error.retry_event_id,
- 'is_ephemeral': 30000,
- });
- msg.error = error;
- }
- }
- editEarlierMessage () {
- let message;
- let idx = this.messages.findLastIndex('correcting');
- if (idx >= 0) {
- this.messages.at(idx).save('correcting', false);
- while (idx > 0) {
- idx -= 1;
- const candidate = this.messages.at(idx);
- if (candidate.get('editable')) {
- message = candidate;
- break;
- }
- }
- }
- message =
- message ||
- this.messages.filter({ 'sender': 'me' })
- .reverse()
- .find(m => m.get('editable'));
- if (message) {
- message.save('correcting', true);
- }
- }
- editLaterMessage () {
- let message;
- let idx = this.messages.findLastIndex('correcting');
- if (idx >= 0) {
- this.messages.at(idx).save('correcting', false);
- while (idx < this.messages.length - 1) {
- idx += 1;
- const candidate = this.messages.at(idx);
- if (candidate.get('editable')) {
- message = candidate;
- message.save('correcting', true);
- break;
- }
- }
- }
- return message;
- }
- 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) {
- if (!attrs.error_type && message.get('error_type') === 'Decryption') {
- // Looks like we have a failed decrypted message stored, and now
- // we have a properly decrypted version of the same message.
- // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594
- return Object.assign({}, attrs, {
- error_condition: undefined,
- error_message: undefined,
- error_text: undefined,
- error_type: undefined,
- is_archived: attrs.is_archived,
- is_ephemeral: false,
- is_error: false,
- });
- } else {
- return { is_archived: attrs.is_archived };
- }
- }
- 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.
- * @method ChatBox#setChatState
- * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
- */
- setChatState (state, options) {
- if (this.chat_state_timeout !== undefined) {
- clearTimeout(this.chat_state_timeout);
- delete this.chat_state_timeout;
- }
- if (state === COMPOSING) {
- this.chat_state_timeout = setTimeout(
- this.setChatState.bind(this),
- _converse.TIMEOUTS.PAUSED,
- PAUSED
- );
- } else if (state === PAUSED) {
- this.chat_state_timeout = setTimeout(
- this.setChatState.bind(this),
- _converse.TIMEOUTS.INACTIVE,
- 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));
- }
- /**
- * @method ChatBox#shouldShowErrorMessage
- * @param {object} attrs
- * @returns {Promise<boolean>}
- */
- shouldShowErrorMessage (attrs) {
- const msg = this.getMessageReferencedByError(attrs);
- if (!msg && attrs.chat_state) {
- // If the error refers to a message not included in our store,
- // and it has a chat state 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 MUC
- // Return promise because subclasses need to return promises
- return Promise.resolve(true);
- }
- /**
- * @param {string} jid1
- * @param {string} jid2
- */
- 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 ChatBox#findDanglingRetraction
- * @param { object } attrs - Attributes representing a received
- * message, as returned by {@link parseMessage}
- * @returns { 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.
- * @method ChatBox#handleRetraction
- * @param {object} attrs - Attributes representing a received
- * message, as returned by {@link parseMessage}
- * @returns {Promise<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;
- }
- /**
- * Returns an already cached message (if it exists) based on the
- * passed in attributes map.
- * @method ChatBox#getDuplicateMessage
- * @param {object} attrs - Attributes representing a received
- * message, as returned by {@link parseMessage}
- * @returns {Message}
- */
- getDuplicateMessage (attrs) {
- const queries = [
- ...this.getStanzaIdQueryAttrs(attrs),
- this.getOriginIdQueryAttrs(attrs),
- this.getMessageBodyQueryAttrs(attrs)
- ].filter(s => s);
- const msgs = this.messages.models;
- return msgs.find(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.msgid) {
- const query = {
- 'from': attrs.from,
- 'msgid': attrs.msgid
- }
- // XXX: Need to take XEP-428 <fallback> into consideration
- if (!attrs.is_encrypted && attrs.body) {
- // We can't match the message if it's a reflected
- // encrypted message (e.g. via MAM or in a MUC)
- query['body'] = attrs.body;
- }
- return query;
- }
- }
- /**
- * Retract one of your messages in this chat
- * @method ChatBoxView#retractOwnMessage
- * @param { 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 ChatBox#sendRetractionMessage
- * @param { 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 api.connection.get().send(msg);
- }
- /**
- * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
- * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
- * @param { Boolean } force - Whether a marker should be sent for the
- * message, even if it didn't include a `markable` element.
- */
- sendMarkerForLastMessage (type='displayed', force=false) {
- const msgs = Array.from(this.messages.models);
- msgs.reverse();
- const msg = msgs.find(m => m.get('sender') === 'them' && (force || m.get('is_markable')));
- msg && this.sendMarkerForMessage(msg, type, force);
- }
- /**
- * Given the passed in message object, send a XEP-0333 chat marker.
- * @param { Message } msg
- * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
- * @param { Boolean } force - Whether a marker should be sent for the
- * message, even if it didn't include a `markable` element.
- */
- sendMarkerForMessage (msg, type='displayed', force=false) {
- if (!msg || !api.settings.get('send_chat_markers').includes(type)) {
- return;
- }
- if (msg?.get('is_markable') || force) {
- const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
- sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
- }
- }
- handleChatMarker (attrs) {
- const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
- if (to_bare_jid !== _converse.session.get('bare_jid')) {
- return false;
- }
- if (attrs.is_markable) {
- if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
- 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': api.connection.get().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 Message} return the XML stanza that represents it.
- * @private
- * @method ChatBox#createMessageStanza
- * @param { Message } message - The message object
- */
- async createMessageStanza (message) {
- const stanza = $msg({
- 'from': api.connection.get().jid,
- 'to': this.get('jid'),
- 'type': this.get('message_type'),
- 'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
- }).c('body').t(message.get('body')).up()
- .c(ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
- if (message.get('type') === 'chat') {
- stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
- }
- if (!message.get('is_encrypted')) {
- 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();
- }
- stanza.root();
- /**
- * *Hook* which allows plugins to update an outgoing message stanza
- * @event _converse#createMessageStanza
- * @param {ChatBox|MUC} chat - The chat from
- * which this message stanza is being sent.
- * @param {Object} data - Message data
- * @param {Message|MUCMessage} data.message
- * The message object from which the stanza is created and which gets persisted to storage.
- * @param {Builder} data.stanza
- * The stanza that will be sent out, as a Strophe.Builder object.
- * You can use the Strophe.Builder functions to extend the stanza.
- * See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
- */
- const data = await api.hook('createMessageStanza', this, { message, stanza });
- return data.stanza;
- }
- async getOutgoingMessageAttributes (attrs) {
- const is_spoiler = !!this.get('composing_spoiler');
- const origin_id = u.getUniqueId();
- const text = attrs?.body;
- const body = text ? u.shortnamesToUnicode(text) : undefined;
- attrs = Object.assign({}, attrs, {
- 'from': _converse.session.get('bare_jid'),
- 'fullname': _converse.state.xmppstatus.get('fullname'),
- 'id': origin_id,
- 'jid': this.get('jid'),
- 'message': body,
- 'msgid': origin_id,
- 'nickname': this.get('nickname'),
- 'sender': 'me',
- 'time': (new Date()).toISOString(),
- 'type': this.get('message_type'),
- body,
- is_spoiler,
- origin_id
- }, u.getMediaURLsMetadata(text));
- /**
- * *Hook* which allows plugins to update the attributes of an outgoing message.
- * These attributes get set on the {@link Message} or
- * {@link MUCMessage} and persisted to storage.
- * @event _converse#getOutgoingMessageAttributes
- * @param {ChatBox|MUC} chat
- * The chat from which this message will be sent.
- * @param {MessageAttributes} attrs
- * The message attributes, from which the stanza will be created.
- */
- attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
- return attrs;
- }
- /**
- * 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 ChatBox#setEditable
- * @memberOf 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 || 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});
- this.messages.findWhere({'editable': true})?.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.
- * @method ChatBox#createMessage
- * @param {Object} attrs
- */
- async createMessage (attrs, options) {
- attrs.time = attrs.time || (new Date()).toISOString();
- await this.messages.fetched;
- return this.messages.create(attrs, options);
- }
- /**
- * Responsible for sending off a text message inside an ongoing chat conversation.
- * @method ChatBox#sendMessage
- * @memberOf ChatBox
- * @param {Object} [attrs] - A map of attributes to be saved on the message
- * @returns {Promise<Message>}
- * @example
- * const chat = api.chats.get('buddy1@example.org');
- * chat.sendMessage({'body': 'hello world'});
- */
- async sendMessage (attrs) {
- attrs = await this.getOutgoingMessageAttributes(attrs);
- let message = this.messages.findWhere('correcting')
- if (message) {
- const older_versions = message.get('older_versions') || {};
- const edited_time = message.get('edited') || message.get('time');
- older_versions[edited_time] = message.getMessageText();
- message.save({
- ...pick(attrs, ['body', 'is_only_emojis', 'media_urls', 'references', 'is_encrypted']),
- ...{
- 'correcting': false,
- 'edited': (new Date()).toISOString(),
- 'message': attrs.body,
- 'ogp_metadata': [],
- 'origin_id': u.getUniqueId(),
- 'received': undefined,
- older_versions,
- plaintext: attrs.is_encrypted ? attrs.message : undefined,
- }
- });
- } else {
- this.setEditable(attrs, (new Date()).toISOString());
- message = await this.createMessage(attrs);
- }
- try {
- const stanza = await this.createMessageStanza(message);
- api.send(stanza);
- } catch (e) {
- message.destroy();
- log.error(e);
- return;
- }
- /**
- * Triggered when a message is being sent out
- * @event _converse#sendMessage
- * @type { Object }
- * @param { Object } data
- * @property { (ChatBox | MUC) } data.chatbox
- * @property { (Message | MUCMessage) } 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 ChatBox}.
- * @method 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})
- );
- }
- }
- /**
- * @param {File[]} files
- */
- async sendFiles (files) {
- const { __, session } = _converse;
- const result = await api.disco.features.get(Strophe.NS.HTTPUPLOAD, session.get('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 = parseInt((data?.attributes || {})['max-file-size']?.value, 10);
- 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 => {
- /**
- * *Hook* which allows plugins to transform files before they'll be
- * uploaded. The main use-case is to encrypt the files.
- * @event _converse#beforeFileUpload
- * @param {ChatBox|MUC} chat - The chat from which this file will be uploaded.
- * @param {File} file - The file that will be uploaded
- */
- file = await api.hook('beforeFileUpload', this, file);
- if (!isNaN(max_file_size) && file.size > max_file_size) {
- const size = filesize(max_file_size);
- const message = Array.isArray(size)
- ? __('The size of your file, %1$s, exceeds the maximum allowed by your server.', file.name)
- : __(
- 'The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
- file.name, size
- );
- return this.createMessage({
- message,
- type: 'error',
- is_ephemeral: true
- });
- } else {
- const initial_attrs = await this.getOutgoingMessageAttributes();
- const attrs = Object.assign(initial_attrs, {
- '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();
- }
- });
- }
- /**
- * @param {boolean} force
- */
- maybeShow (force) {
- if (isUniView()) {
- const filter = (c) => !c.get('hidden') &&
- c.get('jid') !== this.get('jid') &&
- c.get('id') !== 'controlbox';
- const other_chats = _converse.state.chatboxes.filter(filter);
- if (force || other_chats.length === 0) {
- // We only have one chat visible at any one time.
- // So before opening a chat, we make sure all other chats are hidden.
- other_chats.forEach(c => u.safeSave(c, {'hidden': true}));
- u.safeSave(this, {'hidden': false});
- }
- return;
- }
- u.safeSave(this, {'hidden': false});
- this.trigger('show');
- return this;
- }
- /**
- * Indicates whether the chat is hidden and therefore
- * whether a newly received message will be visible
- * to the user or not.
- * @returns {boolean}
- */
- isHidden () {
- return this.get('hidden') || this.isScrolledUp() || document.hidden;
- }
- /**
- * Given a newly received {@link Message} instance,
- * update the unread counter if necessary.
- * @method ChatBox#handleUnreadMessage
- * @param {Message} message
- */
- handleUnreadMessage (message) {
- if (!message?.get('body')) {
- return
- }
- if (isNewMessage(message)) {
- if (message.get('sender') === 'me') {
- // We remove the "scrolled" flag so that the chat area
- // gets scrolled down. We always want to scroll down
- // when the user writes a message as opposed to when a
- // message is received.
- this.ui.set('scrolled', false);
- } else if (this.isHidden()) {
- this.incrementUnreadMsgsCounter(message);
- } else {
- this.sendMarkerForMessage(message);
- }
- }
- }
- /**
- * @param {Message} message
- */
- incrementUnreadMsgsCounter (message) {
- 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);
- }
- clearUnreadMsgCounter () {
- if (this.get('num_unread') > 0) {
- this.sendMarkerForMessage(this.messages.last());
- }
- u.safeSave(this, {'num_unread': 0});
- }
- isScrolledUp () {
- return this.ui.get('scrolled');
- }
- canPostMessages () {
- return true;
- }
- }
- export default ChatBox;
|