UNPKG

converse.js

Version:
299 lines (274 loc) 10.1 kB
import dayjs from "dayjs"; import sizzle from "sizzle"; import { Strophe, $iq } from "strophe.js"; import { Model } from "@converse/skeletor"; import log from "@converse/log"; import _converse from "../shared/_converse.js"; import api from "../shared/api/index.js"; import { SUCCESS, FAILURE } from "../shared/constants.js"; import ColorAwareModel from "../shared/color.js"; import ModelWithContact from "../shared/model-with-contact.js"; import ModelWithVCard from "../shared/model-with-vcard"; import { getUniqueId } from "../utils/index.js"; /** * @template {import('./types').ModelExtender} T * @param {T} BaseModel */ class BaseMessage extends ModelWithVCard(ModelWithContact(ColorAwareModel(Model))) { defaults() { return { msgid: getUniqueId(), time: new Date().toISOString(), is_ephemeral: false, }; } /** * @param {Model[]} [models] * @param {object} [options] */ constructor(models, options) { super(models, options); this.file = null; /** @type {import('./types').MessageAttributes} */ this.attributes; } initialize() { this.lazy_load_vcard = true; super.initialize(); this.chatbox = this.collection?.chatbox; if (!this.checkValidity()) return; if (this.get('file')) { this.on('change:put', () => this.uploadFile()); } this.on('change:is_ephemeral', () => this.setTimerForEphemeralMessage()); this.setTimerForEphemeralMessage(); } 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; } safeDestroy() { try { this.destroy(); } catch (e) { log.warn(`safeDestroy: ${e}`); } } /** * Sets an auto-destruct timer for this message, if it's is_ephemeral. */ 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 = setTimeout(() => this.safeDestroy(), timeout); } } /** * 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 {boolean} */ isRetracted() { return this.get("retracted") || this.get("moderated") === "retracted"; } /** * 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; if (!messages) { // Happens during tests return false; } 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.isRetracted() && !prev_model.isRetracted() && !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) ); } /** * Determines whether this messsage may be retracted by the current user. * @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")); } 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 */ sendSlotRequestStanza() { if (!this.file) return Promise.reject(new Error("file is undefined")); const iq = $iq({ "from": _converse.session.get("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); } /** * @param {Element} stanza */ 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({ is_ephemeral: true, message: __("Sorry, could not determine upload URL."), type: "error", }); } 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({ is_ephemeral: true, message: __("Sorry, could not determine file upload URL."), type: "error", }); } } uploadFile() { const xhr = new XMLHttpRequest(); xhr.onreadystatechange = async (event) => { if (xhr.readyState === XMLHttpRequest.DONE) { log.info("Status: " + xhr.status); if (xhr.status === 200 || xhr.status === 201) { let attrs = { body: this.get("get"), message: this.get("get"), oob_url: this.get("get"), upload: SUCCESS, }; /** * *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 { log.error(event); xhr.onerror(new ProgressEvent(`Response status: ${xhr.status}`)); } } }; 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({ is_ephemeral: true, message, type: "error", upload: FAILURE, }); }; 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 BaseMessage;