Converse converse.js

Source: headless/plugins/chat/message.js

import ModelWithContact from './model-with-contact.js';
import dayjs from 'dayjs';
import log from '../../log.js';
import { _converse, api, converse } from '../../core.js';
import { getOpenPromise } from '@converse/openpromise';

const { Strophe, sizzle, u } = converse.env;

/**
 * Mixin which turns a `ModelWithContact` model into a non-MUC message.
 * These can be either `chat`, `normal` or `headline` messages.
 * @mixin
 * @namespace _converse.Message
 * @memberOf _converse
 * @example const msg = new _converse.Message({'message': 'hello world!'});
 */
const MessageMixin = {

    defaults () {
        return {
            'msgid': u.getUniqueId(),
            'time': new Date().toISOString(),
            'is_ephemeral': false
        };
    },

    async initialize () {
        if (!this.checkValidity()) {
            return;
        }
        this.initialized = getOpenPromise();
        if (this.get('file')) {
            this.on('change:put', () => this.uploadFile());
        }
        // If `type` changes from `error` to `chat`, we want to set the contact. See #2733
        this.on('change:type', () => this.setContact());
        this.on('change:is_ephemeral', () => this.setTimerForEphemeralMessage());

        await this.setContact();
        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();
    },

    setContact () {
        if (['chat', 'normal'].includes(this.get('type'))) {
            ModelWithContact.prototype.initialize.apply(this, arguments);
            this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
        }
    },

    /**
     * Sets an auto-destruct timer for this message, if it's is_ephemeral.
     * @private
     * @method _converse.Message#setTimerForEphemeralMessage
     */
    setTimerForEphemeralMessage () {
        if (this.ephemeral_timer) {
            clearTimeout(this.ephemeral_timer);
        }
        const is_ephemeral = this.isEphemeral();
        if (is_ephemeral) {
            const timeout = typeof is_ephemeral === "number" ? is_ephemeral : 10000;
            this.ephemeral_timer = window.setTimeout(() => this.safeDestroy(), timeout);
        }
    },

    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';
        const not_canceled = this.get('error_type') !== 'cancel';
        return is_own_message && not_canceled && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
    },

    safeDestroy () {
        try {
            this.destroy();
        } catch (e) {
            log.warn(`safeDestroy: ${e}`);
        }
    },

    /**
     * Returns a boolean indicating whether this message is ephemeral,
     * meaning it will get automatically removed after ten seconds.
     * @returns { boolean }
     */
    isEphemeral () {
        return this.get('is_ephemeral');
    },

    /**
     * Returns a boolean indicating whether this message is a XEP-0245 /me command.
     * @returns { boolean }
     */
    isMeCommand () {
        const text = this.getMessageText();
        if (!text) {
            return false;
        }
        return text.startsWith('/me ');
    },

    /**
     * Returns a boolean indicating whether this message is considered a followup
     * message from the previous one. Followup messages are shown grouped together
     * under one author heading.
     * A message is considered a followup of it's predecessor when it's a chat
     * message from the same author, within 10 minutes.
     * @returns { boolean }
     */
    isFollowup () {
        const messages = this.collection.models;
        const idx = messages.indexOf(this);
        const prev_model = idx ? messages[idx-1] : null;
        if (prev_model === null) {
            return false;
        }
        const date = dayjs(this.get('time'));
        return this.get('from') === prev_model.get('from') &&
            !this.isMeCommand() && !prev_model.isMeCommand() &&
            !!this.get('is_encrypted') === !!prev_model.get('is_encrypted') &&
            this.get('type') === prev_model.get('type') && this.get('type') !== 'info' &&
            date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
            (this.get('type') === 'groupchat' ? this.get('occupant_id') === prev_model.get('occupant_id') : true);
    },

    getDisplayName () {
        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')) {
            const { __ } = _converse;
            return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
        } else if (['groupchat', 'chat', 'normal'].includes(this.get('type'))) {
            return this.get('body');
        } else {
            return this.get('message');
        }
    },

    /**
     * Send out an IQ stanza to request a file upload slot.
     * https://xmpp.org/extensions/xep-0363.html#request
     * @private
     * @method _converse.Message#sendSlotRequestStanza
     */
    sendSlotRequestStanza () {
        if (!this.file) {
            return Promise.reject(new Error('file is undefined'));
        }
        const iq = converse.env
            .$iq({
                'from': _converse.jid,
                'to': this.get('slot_request_url'),
                'type': 'get'
            })
            .c('request', {
                'xmlns': Strophe.NS.HTTPUPLOAD,
                'filename': this.file.name,
                'size': this.file.size,
                'content-type': this.file.type
            });
        return api.sendIQ(iq);
    },

    getUploadRequestMetadata (stanza) {
        const headers = sizzle(`slot[xmlns="${Strophe.NS.HTTPUPLOAD}"] put header`, stanza);
        // https://xmpp.org/extensions/xep-0363.html#request
        // TODO: Can't set the Cookie header in JavaScipt, instead cookies need
        // to be manually set via document.cookie, so we're leaving it out here.
        return {
            'headers': headers
                .map(h => ({ 'name': h.getAttribute('name'), 'value': h.textContent }))
                .filter(h => ['Authorization', 'Expires'].includes(h.name))
        }
    },

    async getRequestSlotURL () {
        const { __ } = _converse;
        let stanza;
        try {
            stanza = await this.sendSlotRequestStanza();
        } catch (e) {
            log.error(e);
            return this.save({
                'type': 'error',
                'message': __('Sorry, could not determine upload URL.'),
                'is_ephemeral': true
            });
        }
        const slot = sizzle(`slot[xmlns="${Strophe.NS.HTTPUPLOAD}"]`, stanza).pop();
        if (slot) {
            this.upload_metadata = this.getUploadRequestMetadata(stanza);
            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 = async () => {
            if (xhr.readyState === XMLHttpRequest.DONE) {
                log.info('Status: ' + xhr.status);
                if (xhr.status === 200 || xhr.status === 201) {
                    let attrs = {
                        'upload': _converse.SUCCESS,
                        'oob_url': this.get('get'),
                        'message': this.get('get'),
                        'body': this.get('get'),
                    };
                    /**
                     * *Hook* which allows plugins to change the attributes
                     * saved on the message once a file has been uploaded.
                     * @event _converse#afterFileUploaded
                     */
                    attrs = await api.hook('afterFileUploaded', this, attrs);
                    this.save(attrs);
                } else {
                    xhr.onerror();
                }
            }
        };

        xhr.upload.addEventListener(
            'progress',
            evt => {
                if (evt.lengthComputable) {
                    this.set('progress', evt.loaded / evt.total);
                }
            },
            false
        );

        xhr.onerror = () => {
            const { __ } = _converse;
            let message;
            if (xhr.responseText) {
                message = __(
                    'Sorry, could not succesfully upload your file. Your server’s response: "%1$s"',
                    xhr.responseText
                );
            } else {
                message = __('Sorry, could not succesfully upload your file.');
            }
            this.save({
                'type': 'error',
                'upload': _converse.FAILURE,
                'message': message,
                'is_ephemeral': true
            });
        };
        xhr.open('PUT', this.get('put'), true);
        xhr.setRequestHeader('Content-type', this.file.type);
        this.upload_metadata.headers?.forEach(h => xhr.setRequestHeader(h.name, h.value));
        xhr.send(this.file);
    }
};

export default MessageMixin;