Pārlūkot izejas kodu

Fixes #3340 Save unsent messages when switching chats

- Refactor message correction
- Play well with message quoting
JC Brand 1 mēnesi atpakaļ
vecāks
revīzija
c5e148b933

+ 1 - 0
CHANGES.md

@@ -38,6 +38,7 @@
 - #3305: New config option [muc_search_service](https://conversejs.org/docs/html/configuration.html#muc-search-service)
 - #3307: Fix inconsistency between browsers on textarea outlines
 - #3337: Correctly display multiline nested quotes
+- #3340: Save draft messages when switching channels 
 - #3362: Don't create empty nick element in bookmarks
 - #3386: Registration form is not fetched
 - #3464: Missing localization: the online status is not localized

+ 1 - 0
karma.conf.js

@@ -85,6 +85,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/csn.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/deprecated-retractions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/drafts.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/http-file-upload.js", type: 'module' },

+ 15 - 4
src/headless/shared/model-with-messages.js

@@ -117,6 +117,7 @@ export default function ModelWithMessages(BaseModel) {
 
             this.listenTo(this.messages, "add", (m) => this.onMessageAdded(m));
             this.listenTo(this.messages, "change:upload", (m) => this.onMessageUploadChanged(m));
+            this.listenTo(this.messages, "change:correcting", (m) => this.onMessageCorrecting(m));
         }
 
         fetchMessages() {
@@ -497,6 +498,17 @@ export default function ModelWithMessages(BaseModel) {
             }
         }
 
+        /**
+         * @param {BaseMessage} message
+         */
+        onMessageCorrecting(message) {
+            if (message.get('correcting')) {
+                this.save({ correcting: message.get('id'), draft: u.prefixMentions(message) });
+            } else {
+                this.save({ correcting: undefined, draft: undefined });
+            }
+        }
+
         onScrolledChanged() {
             if (!this.ui.get("scrolled")) {
                 this.clearUnreadMsgCounter();
@@ -562,12 +574,11 @@ export default function ModelWithMessages(BaseModel) {
             message =
                 message ||
                 this.messages
-                    .filter({ "sender": "me" })
+                    .filter({ sender: "me" })
                     .reverse()
                     .find((m) => m.get("editable"));
-            if (message) {
-                message.save("correcting", true);
-            }
+
+            message?.save("correcting", true);
         }
 
         editLaterMessage() {

+ 1 - 0
src/headless/types/plugins/chat/model.d.ts

@@ -101,6 +101,7 @@ declare const ChatBox_base: {
         chat_state_timeout: NodeJS.Timeout;
         onMessageAdded(message: import("../../index.js").BaseMessage<any>): void;
         onMessageUploadChanged(message: import("../../index.js").BaseMessage<any>): Promise<void>;
+        onMessageCorrecting(message: import("../../index.js").BaseMessage<any>): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;

+ 1 - 0
src/headless/types/plugins/muc/muc.d.ts

@@ -101,6 +101,7 @@ declare const MUC_base: {
         chat_state_timeout: NodeJS.Timeout;
         onMessageAdded(message: import("../../shared/message.js").default<any>): void;
         onMessageUploadChanged(message: import("../../shared/message.js").default<any>): Promise<void>;
+        onMessageCorrecting(message: import("../../shared/message.js").default<any>): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;

+ 1 - 0
src/headless/types/plugins/muc/occupant.d.ts

@@ -101,6 +101,7 @@ declare const MUCOccupant_base: {
         chat_state_timeout: NodeJS.Timeout;
         onMessageAdded(message: import("../../index.js").BaseMessage<any>): void;
         onMessageUploadChanged(message: import("../../index.js").BaseMessage<any>): Promise<void>;
+        onMessageCorrecting(message: import("../../index.js").BaseMessage<any>): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("../../shared/types").MessageAttributes): Promise<boolean>;

+ 1 - 0
src/headless/types/shared/chatbox.d.ts

@@ -31,6 +31,7 @@ declare const ChatBoxBase_base: {
         chat_state_timeout: NodeJS.Timeout;
         onMessageAdded(message: import("./message.js").default<any>): void;
         onMessageUploadChanged(message: import("./message.js").default<any>): Promise<void>;
+        onMessageCorrecting(message: import("./message.js").default<any>): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         shouldShowErrorMessage(attrs: import("./types.js").MessageAttributes): Promise<boolean>;

+ 4 - 0
src/headless/types/shared/model-with-messages.d.ts

@@ -119,6 +119,10 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
          * @param {BaseMessage} message
          */
         onMessageUploadChanged(message: import("./message").default<any>): Promise<void>;
+        /**
+         * @param {BaseMessage} message
+         */
+        onMessageCorrecting(message: import("./message").default<any>): void;
         onScrolledChanged(): void;
         pruneHistoryWhenScrolledDown(): void;
         /**

+ 2 - 1
src/headless/types/utils/index.d.ts

@@ -6,8 +6,9 @@ export function isEmptyMessage(attrs: any): boolean;
 /**
  * Given a message object, return its text with @ chars
  * inserted before the mentioned nicknames.
+ * @param {import('../shared/message').default} message
  */
-export function prefixMentions(message: any): any;
+export function prefixMentions(message: import("../shared/message").default<any>): any;
 export function getRandomInt(max: any): number;
 /**
  * @param {string} [suffix]

+ 1 - 0
src/headless/utils/index.js

@@ -63,6 +63,7 @@ export function isEmptyMessage (attrs) {
 /**
  * Given a message object, return its text with @ chars
  * inserted before the mentioned nicknames.
+ * @param {import('../shared/message').default} message
  */
 export function prefixMentions (message) {
     let text = message.getMessageText();

+ 15 - 30
src/plugins/chatview/message-form.js

@@ -23,12 +23,19 @@ export default class MessageForm extends CustomElement {
 
     async initialize() {
         await this.model.initialized;
-        this.listenTo(this.model.messages, "change:correcting", this.onMessageCorrecting);
         this.listenTo(this.model, "change:composing_spoiler", () => this.requestUpdate());
+        this.listenTo(this.model, "change:draft", () => this.requestUpdate());
+        this.listenTo(this.model, "change:hidden", () => {
+            if (this.model.get("hidden")) {
+                const draft_hint = /** @type {HTMLInputElement} */ (this.querySelector(".spoiler-hint"))?.value;
+                const draft_message = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"))?.value;
+                u.safeSave(this.model, { draft: draft_message, draft_hint });
+            }
+        });
 
         this.handleEmojiSelection = (/** @type { CustomEvent } */ { detail }) => {
             if (this.model.get("jid") === detail.jid) {
-                this.insertIntoTextArea(detail.value, detail.autocompleting, false, detail.ac_position);
+                this.insertIntoTextArea(detail.value, detail.autocompleting, detail.ac_position);
             }
         };
         document.addEventListener("emojiSelected", this.handleEmojiSelection);
@@ -54,13 +61,8 @@ export default class MessageForm extends CustomElement {
      * @param {number} [position] - The end index of the string to be
      *  replaced with the new value.
      */
-    insertIntoTextArea(value, replace = false, correcting = false, position, separator = " ") {
+    insertIntoTextArea(value, replace = false, position, separator = " ") {
         const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"));
-        if (correcting) {
-            u.addClass("correcting", textarea);
-        } else {
-            u.removeClass("correcting", textarea);
-        }
         if (replace) {
             if (position && typeof replace == "string") {
                 textarea.value = textarea.value.replace(new RegExp(replace, "g"), (match, offset) =>
@@ -81,22 +83,6 @@ export default class MessageForm extends CustomElement {
         u.placeCaretAtEnd(textarea);
     }
 
-    /**
-     * @param {import('@converse/headless').BaseMessage} message
-     */
-    onMessageCorrecting(message) {
-        if (message.get("correcting")) {
-            this.insertIntoTextArea(u.prefixMentions(message), true, true);
-        } else {
-            const currently_correcting = this.model.messages.findWhere("correcting");
-            if (currently_correcting && currently_correcting !== message) {
-                this.insertIntoTextArea(u.prefixMentions(message), true, true);
-            } else {
-                this.insertIntoTextArea("", true, false);
-            }
-        }
-    }
-
     /**
      * Handles the escape key press event to stop correcting a message.
      * @param {KeyboardEvent} ev
@@ -107,7 +93,6 @@ export default class MessageForm extends CustomElement {
         if (message) {
             ev.preventDefault();
             message.save("correcting", false);
-            this.insertIntoTextArea("", true, false);
         }
     }
 
@@ -122,7 +107,7 @@ export default class MessageForm extends CustomElement {
             this.model.sendFiles(Array.from(ev.clipboardData.files));
             return;
         }
-        this.model.set({ draft: ev.clipboardData.getData("text/plain") });
+        this.model.save({ draft: ev.clipboardData.getData("text/plain") });
     }
 
     /**
@@ -141,7 +126,8 @@ export default class MessageForm extends CustomElement {
      * @param {KeyboardEvent} ev
      */
     onKeyUp(ev) {
-        this.model.set({ draft: /** @type {HTMLTextAreaElement} */ (ev.target).value });
+        // Trigger an event, for `<converse-message-limit-indicator/>`
+        this.model.trigger('event:keyup', { ev });
     }
 
     /**
@@ -171,13 +157,13 @@ export default class MessageForm extends CustomElement {
                 return this.onFormSubmitted(ev);
             } else if (ev.key === converse.keycodes.UP_ARROW && !target.selectionEnd) {
                 const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector(".chat-textarea"));
-                if (!textarea.value || u.hasClass("correcting", textarea)) {
+                if (!textarea.value || this.model.get('correcting')) {
                     return this.model.editEarlierMessage();
                 }
             } else if (
                 ev.key === converse.keycodes.DOWN_ARROW &&
                 target.selectionEnd === target.value.length &&
-                u.hasClass("correcting", this.querySelector(".chat-textarea"))
+                this.model.get('correcting')
             ) {
                 return this.model.editLaterMessage();
             }
@@ -231,7 +217,6 @@ export default class MessageForm extends CustomElement {
         if (is_command || message) {
             hint_el.value = "";
             textarea.value = "";
-            u.removeClass("correcting", textarea);
             textarea.style.height = "auto";
             this.model.set({ "draft": "" });
         }

+ 13 - 7
src/plugins/chatview/templates/message-form.js

@@ -1,5 +1,5 @@
 import { __ } from "i18n";
-import { api } from "@converse/headless";
+import { api, u } from "@converse/headless";
 import { html } from "lit";
 import { resetElementHeight } from "../utils.js";
 
@@ -16,10 +16,11 @@ export default (el) => {
     const show_send_button = api.settings.get("show_send_button");
     const show_spoiler_button = api.settings.get("visible_toolbar_buttons").spoiler;
     const show_toolbar = api.settings.get("show_toolbar");
-    const hint_value = /** @type {HTMLInputElement} */ (el.querySelector(".spoiler-hint"))?.value;
-    const message_value = /** @type {HTMLTextAreaElement} */ (el.querySelector(".chat-textarea"))?.value;
 
-    return html` <form class="chat-message-form" @submit="${/** @param {SubmitEvent} ev */ (ev) => el.onFormSubmitted(ev)}">
+    return html` <form
+        class="chat-message-form"
+        @submit="${/** @param {SubmitEvent} ev */ (ev) => el.onFormSubmitted(ev)}"
+    >
         ${show_toolbar
             ? html` <converse-chat-toolbar
                   class="btn-toolbar chat-toolbar no-text-select"
@@ -38,14 +39,18 @@ export default (el) => {
             type="text"
             enterkeyhint="send"
             placeholder="${label_spoiler_hint || ""}"
-            value="${hint_value || ""}"
+            .value="${el.model.get("draft_hint") ?? ""}"
+            @change="${
+                /** @param {Event} ev */ (ev) =>
+                    u.safeSave(el.model, { draft_hint: /** @type {HTMLInputElement} */ (ev.target).value })
+            }"
             class="${composing_spoiler ? "" : "hidden"} spoiler-hint"
         />
         <textarea
             autofocus
             type="text"
             enterkeyhint="send"
-            .value="${message_value || ""}"
+            .value="${el.model.get("draft") ?? ""}"
             @drop="${/** @param {DragEvent} ev */ (ev) => el.onDrop(ev)}"
             @input="${resetElementHeight}"
             @keydown="${/** @param {KeyboardEvent} ev */ (ev) => el.onKeyDown(ev)}"
@@ -53,9 +58,10 @@ export default (el) => {
             @paste="${/** @param {ClipboardEvent} ev */ (ev) => el.onPaste(ev)}"
             @change="${
                 /** @param {Event} ev */ (ev) =>
-                    el.model.set({ draft: /** @type {HTMLTextAreaElement} */ (ev.target).value })
+                    u.safeSave(el.model, { draft: /** @type {HTMLTextAreaElement} */ (ev.target).value })
             }"
             class="chat-textarea
+                        ${el.model.get("correcting") ? "correcting" : ""}
                         ${show_send_button ? "chat-textarea-send-button" : ""}
                         ${composing_spoiler ? "spoiler" : ""}"
             placeholder="${label_message}"

+ 10 - 3
src/plugins/chatview/tests/actions.js

@@ -79,14 +79,20 @@ describe("A Chat Message", function () {
         let firstAction = view.querySelector('.chat-msg__action-quote');
         expect(firstAction).not.toBeNull();
         firstAction.click();
-        expect(textarea.value).toBe('> ' + firstMessageText + '\n');
+        await u.waitUntil(() => textarea.value === `> ${firstMessageText}`);
+
+
         // Quote with already-present text
         textarea.value = 'Hi!';
+        textarea.dispatchEvent(new Event('change'));
+
         firstAction.click();
-        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+        await u.waitUntil(() => textarea.value === `Hi!\n> ${firstMessageText}\n`);
 
         // Test messages from other users
         textarea.value = '';
+        textarea.dispatchEvent(new Event('change'));
+
         const secondMessageText = 'Hello';
         _converse.handleMessageStanza(
             $msg({
@@ -98,12 +104,13 @@ describe("A Chat Message", function () {
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
         );
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
         const quoteActions = view.querySelectorAll('.chat-msg__action-quote');
         expect(quoteActions.length).toBe(2);
         let secondAction = quoteActions[quoteActions.length - 1];
         expect(secondAction).not.toBeNull();
         secondAction.click();
-        expect(textarea.value).toBe('> ' + secondMessageText + '\n');
+        await u.waitUntil(() => textarea.value === `> ${secondMessageText}`);
     }));
 
 });

+ 2 - 2
src/plugins/chatview/tests/chatbox.js

@@ -255,8 +255,8 @@ describe("Chatboxes", function () {
                 const toolbar = view.querySelector('.chat-toolbar');
                 const counter = toolbar.querySelector('.message-limit');
                 expect(counter.textContent).toBe('200');
-                view.getMessageForm().insertIntoTextArea('hello world');
-                await u.waitUntil(() => counter.textContent === '188');
+                view.model.set({ draft: 'hello world' });
+                await u.waitUntil(() => counter.textContent === '189');
 
                 toolbar.querySelector('.toggle-emojis').click();
                 const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));

+ 54 - 48
src/plugins/chatview/tests/corrections.js

@@ -1,6 +1,5 @@
 /*global mock, converse */
-
-const { Promise, $msg, Strophe, sizzle, u } = converse.env;
+const { Promise, Strophe, sizzle, u } = converse.env;
 
 describe("A Chat Message", function () {
 
@@ -41,7 +40,7 @@ describe("A Chat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
@@ -87,7 +86,7 @@ describe("A Chat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder window breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
@@ -96,7 +95,7 @@ describe("A Chat Message", function () {
             target: textarea,
             key: "ArrowDown",
         });
-        expect(textarea.value).toBe('');
+        await u.waitUntil(() => textarea.value === '');
         expect(view.model.messages.at(0).get('correcting')).toBe(false);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
@@ -124,7 +123,7 @@ describe("A Chat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+        await u.waitUntil(() => textarea.value === 'Arise, fair sun, and kill the envious moon');
         await u.waitUntil(() => view.model.messages.at(2).get('correcting') === true);
         expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
         expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
@@ -136,7 +135,7 @@ describe("A Chat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('It is the east, and Juliet is the one.');
+        await u.waitUntil(() => textarea.value === 'It is the east, and Juliet is the one.');
         expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
         expect(view.model.messages.at(1).get('correcting')).toBe(true);
         expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
@@ -200,7 +199,7 @@ describe("A Chat Message", function () {
         action.style.opacity = 1;
         action.click();
 
-        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
@@ -217,16 +216,16 @@ describe("A Chat Message", function () {
         expect(api.connection.get().send).toHaveBeenCalled();
 
         const msg = api.connection.get().send.calls.all()[0].args[0];
-        expect(Strophe.serialize(msg))
-        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
-                `to="mercutio@montague.lit" type="chat" `+
-                `xmlns="jabber:client">`+
-                    `<body>But soft, what light through yonder window breaks?</body>`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<request xmlns="urn:xmpp:receipts"/>`+
-                    `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                    `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-            `</message>`);
+        expect(msg).toEqualStanza(stx`
+              <message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}"
+                to="mercutio@montague.lit" type="chat"
+                xmlns="jabber:client">
+                    <body>But soft, what light through yonder window breaks?</body>
+                    <active xmlns="http://jabber.org/protocol/chatstates"/>
+                    <request xmlns="urn:xmpp:receipts"/>
+                    <replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>
+                    <origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>
+            </message>`);
         expect(view.model.messages.models.length).toBe(1);
         const corrected_message = view.model.messages.at(0);
         expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
@@ -245,7 +244,7 @@ describe("A Chat Message", function () {
         action.style.opacity = 1;
         action.click();
 
-        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder window breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === true);
@@ -255,18 +254,19 @@ describe("A Chat Message", function () {
         action.click();
         expect(view.model.messages.at(0).get('correcting')).toBe(false);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(textarea.value).toBe('');
+        await u.waitUntil(() => textarea.value === '');
         await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
 
         // Test that messages from other users don't have the pencil icon
         _converse.handleMessageStanza(
-            $msg({
-                'from': contact_jid,
-                'to': api.connection.get().jid,
-                'type': 'chat',
-                'id': u.getUniqueId()
-            }).c('body').t('Hello').up()
-            .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+            stx`<message from="${contact_jid}"
+                    to="${api.connection.get().jid}"
+                    type="chat"
+                    id="${u.getUniqueId()}"
+                    xmlns="jabber:client">
+                <body>Hello</body>
+                <active xmlns="http://jabber.org/protocol/chatstates"/>
+            </message>`
         );
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.querySelector('.chat-msg .chat-msg__action .chat-msg__action-edit')).toBeNull()
@@ -282,7 +282,7 @@ describe("A Chat Message", function () {
         expect(_converse.api.confirm).toHaveBeenCalledWith(
             'You have an unsent message which will be lost if you continue. Are you sure?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
-        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder window breaks?');
 
         textarea.value = 'But soft, what light through yonder airlock breaks?'
         action.click();
@@ -307,24 +307,28 @@ describe("A Chat Message", function () {
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msg_id = u.getUniqueId();
             const view = await mock.openChatBoxFor(_converse, sender_jid);
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': api.connection.get().jid,
-                    'type': 'chat',
-                    'id': msg_id,
-                }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+            _converse.handleMessageStanza(stx`
+                <message from="${sender_jid}"
+                        xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="chat"
+                        id="${msg_id}">
+                    <body>But soft, what light through yonder airlock breaks?</body>
+                </message>`);
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             expect(view.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.querySelector('.chat-msg__text').textContent)
                 .toBe('But soft, what light through yonder airlock breaks?');
 
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': api.connection.get().jid,
-                    'type': 'chat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+            _converse.handleMessageStanza(stx`
+                <message from="${sender_jid}"
+                        xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="chat"
+                        id="${u.getUniqueId()}">
+                    <body>But soft, what light through yonder chimney breaks?</body>
+                    <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+                </message>`);
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             expect(view.querySelector('.chat-msg__text').textContent)
@@ -333,13 +337,15 @@ describe("A Chat Message", function () {
             expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
             expect(view.model.messages.models.length).toBe(1);
 
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': api.connection.get().jid,
-                    'type': 'chat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder window breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+            _converse.handleMessageStanza(stx`
+                <message from="${sender_jid}"
+                        xmlns="jabber:client"
+                        to="${api.connection.get().jid}"
+                        type="chat"
+                        id="${u.getUniqueId()}">
+                    <body>But soft, what light through yonder window breaks?</body>
+                    <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+                </message>`);
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             expect(view.querySelector('.chat-msg__text').textContent)

+ 1 - 1
src/plugins/chatview/tests/emojis.js

@@ -78,7 +78,7 @@ describe("Emojis", function () {
                 target: textarea,
                 key: "ArrowUp",
             });
-            expect(textarea.value).toBe('💩 😇');
+            await u.waitUntil(() => textarea.value === '💩 😇');
             expect(view.model.messages.at(2).get('correcting')).toBe(true);
             sel = 'converse-chat-message:last-child .chat-msg'
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);

+ 41 - 0
src/plugins/chatview/tests/spoilers.js

@@ -201,4 +201,45 @@ describe("A spoiler message", function () {
         spoiler_toggle.click();
         await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden'));
     }));
+
+    it("can be saved as an unsent draft",
+            mock.initConverse(['chatBoxesFetched'], { ...settings, view_mode: 'fullscreen' }, async (_converse) => {
+
+        const { api } = _converse;
+        await mock.waitForRoster(_converse, 'current', 2);
+        mock.openControlBox(_converse);
+        const contact1_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        const contact2_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+        // XXX: We need to send a presence from the contact, so that we
+        // have a resource, that resource is then queried to see
+        // whether Strophe.NS.SPOILER is supported, in which case
+        // the spoiler button will appear.
+        const presence = stx`<presence xmlns="jabber:client" from="${contact1_jid}/phone" to="romeo@montague.lit"/>`;
+        api.connection.get()._dataRecv(mock.createRequest(presence));
+
+        await mock.openChatBoxFor(_converse, contact1_jid);
+        await mock.waitUntilDiscoConfirmed(_converse, contact1_jid+'/phone', [], [Strophe.NS.SPOILER]);
+        const view = _converse.chatboxviews.get(contact1_jid);
+        spyOn(api.connection.get(), 'send');
+
+        await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler'));
+        let spoiler_toggle = view.querySelector('.toggle-compose-spoiler');
+        spoiler_toggle.click();
+
+        let hint_input = view.querySelector('.spoiler-hint');
+        hint_input.value = 'This is the hint';
+
+        let textarea = view.querySelector('.chat-textarea');
+        textarea.value = 'This is the spoiler';
+
+        await mock.openChatBoxFor(_converse, contact2_jid);
+        await mock.openChatBoxFor(_converse, contact1_jid);
+
+        hint_input = view.querySelector('.spoiler-hint');
+        expect(hint_input.value).toBe('This is the hint');
+
+        textarea = view.querySelector('.chat-textarea');
+        expect(textarea.value).toBe('This is the spoiler');
+    }));
 });

+ 3 - 3
src/plugins/muc-views/message-form.js

@@ -1,9 +1,9 @@
-import AutoComplete from "shared/autocomplete/autocomplete.js";
-import MessageForm from "plugins/chatview/message-form.js";
-import tplMUCMessageForm from "./templates/message-form.js";
 import { FILTER_CONTAINS, FILTER_STARTSWITH } from "shared/autocomplete/utils.js";
 import { MUCOccupant, api, converse, log } from "@converse/headless";
+import AutoComplete from "shared/autocomplete/autocomplete.js";
+import MessageForm from "plugins/chatview/message-form.js";
 import { getAutoCompleteListItem } from "./utils.js";
+import tplMUCMessageForm from "./templates/message-form.js";
 
 export default class MUCMessageForm extends MessageForm {
     async initialize() {

+ 1 - 0
src/plugins/muc-views/styles/muc.scss

@@ -95,6 +95,7 @@
                 .chat-info {
                     color: var(--info-color);
                     line-height: normal;
+                    margin-bottom: 0.3em;
                     &.badge {
                         color: var(--muc-color);
                     }

+ 15 - 10
src/plugins/muc-views/templates/message-form.js

@@ -1,5 +1,5 @@
 import { __ } from "i18n";
-import { api } from "@converse/headless";
+import { api, u } from "@converse/headless";
 import { html } from "lit";
 import { resetElementHeight } from "plugins/chatview/utils.js";
 
@@ -16,12 +16,10 @@ export default (el) => {
     const show_send_button = api.settings.get("show_send_button");
     const show_spoiler_button = api.settings.get("visible_toolbar_buttons").spoiler;
     const show_toolbar = api.settings.get("show_toolbar");
-    const hint_value = /** @type {HTMLInputElement} */ (el.querySelector(".spoiler-hint"))?.value;
-    const message_value = /** @type {HTMLInputElement} */ (el.querySelector(".chat-textarea"))?.value;
     return html` <form class="setNicknameButtonForm hidden">
             <input type="submit" class="btn btn-primary" name="join" value="Join" />
         </form>
-        <form class="chat-message-form" @submit=${(ev) => el.onFormSubmitted(ev)}>
+        <form class="chat-message-form" @submit="${/** @param {SubmitEvent} ev */ (ev) => el.onFormSubmitted(ev)}">
             ${show_toolbar
                 ? html` <converse-chat-toolbar
                       class="btn-toolbar chat-toolbar no-text-select"
@@ -40,7 +38,11 @@ export default (el) => {
             <input
                 type="text"
                 placeholder="${label_spoiler_hint || ""}"
-                value="${hint_value || ""}"
+                .value="${el.model.get("draft_hint") ?? ""}"
+                @change="${
+                    /** @param {Event} ev */ (ev) =>
+                        u.safeSave(el.model, { draft_hint: /** @type {HTMLInputElement} */ (ev.target).value })
+                }"
                 class="${composing_spoiler ? "" : "hidden"} spoiler-hint"
             />
             <div class="suggestion-box">
@@ -48,19 +50,22 @@ export default (el) => {
                 <textarea
                     autofocus
                     type="text"
-                    @drop=${(ev) => el.onDrop(ev)}
+                    .value="${el.model.get("draft") ?? ""}"
+                    @drop="${/** @param {DragEvent} ev */ (ev) => el.onDrop(ev)}"
                     @input=${resetElementHeight}
                     @keydown="${/** @param {KeyboardEvent} ev */ (ev) => el.onKeyDown(ev)}"
                     @keyup="${/** @param {KeyboardEvent} ev */ (ev) => el.onKeyUp(ev)}"
                     @paste="${/** @param {ClipboardEvent} ev */ (ev) => el.onPaste(ev)}"
-                    @change=${(ev) => el.model.set({ "draft": ev.target.value })}
+                    @change="${
+                        /** @param {Event} ev */ (ev) =>
+                            u.safeSave(el.model, { draft: /** @type {HTMLTextAreaElement} */ (ev.target).value })
+                    }"
                     class="chat-textarea suggestion-box__input
+                        ${el.model.get("correcting") ? "correcting" : ""}
                         ${show_send_button ? "chat-textarea-send-button" : ""}
                         ${composing_spoiler ? "spoiler" : ""}"
                     placeholder="${label_message}"
-                >
-${message_value || ""}</textarea
-                >
+                ></textarea>
                 <span
                     class="suggestion-box__additions visually-hidden"
                     role="status"

+ 4 - 7
src/plugins/muc-views/tests/actions.js

@@ -94,17 +94,14 @@ describe("A Groupchat Message", function () {
         let firstAction = await u.waitUntil(() => view.querySelector('.chat-msg__action-quote'));
         expect(firstAction).not.toBeNull();
         firstAction.click();
-        expect(textarea.value).toBe('> ' + firstMessageText + '\n');
-        // Quote with already-present text
-        textarea.value = 'Hi!';
-        firstAction.click();
-        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+        await u.waitUntil(() => textarea.value === '> ' + firstMessageText);
 
         // Quote with already-present text
         textarea.value = 'Hi!';
-        firstAction.click();
-        expect(textarea.value).toBe('Hi!\n> ' + firstMessageText + '\n');
+        textarea.dispatchEvent(new Event('change'));
 
+        firstAction.click();
+        await u.waitUntil(() => textarea.value === `Hi!\n> ${firstMessageText}\n`);
     }));
 
     it("Cannot be quoted without permission to speak",

+ 3 - 3
src/plugins/muc-views/tests/corrections.js

@@ -206,7 +206,7 @@ describe("A Groupchat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
@@ -265,7 +265,7 @@ describe("A Groupchat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder window breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
@@ -274,7 +274,7 @@ describe("A Groupchat Message", function () {
             target: textarea,
             key: "ArrowDown",
         });
-        expect(textarea.value).toBe('');
+        await u.waitUntil(() => textarea.value === '');
         expect(view.model.messages.at(0).get('correcting')).toBe(false);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         await u.waitUntil(() => !u.hasClass('correcting', view.querySelector('.chat-msg')), 500);

+ 24 - 0
src/plugins/muc-views/tests/drafts.js

@@ -0,0 +1,24 @@
+/*global mock, converse */
+const { u } = converse.env;
+
+describe("An unsent groupchat message", function () {
+    it(
+        "will be saved as a draft when switching chats",
+        mock.initConverse([], { view_mode: "fullscreen" }, async function (_converse) {
+            const muc1_jid = "lounge@montague.lit";
+            const muc2_jid = "garden@montague.lit";
+
+            await mock.openAndEnterMUC(_converse, muc1_jid, "romeo");
+            const view = _converse.chatboxviews.get(muc1_jid);
+
+            const textarea = await u.waitUntil(() => view.querySelector(".chat-textarea"));
+            textarea.value = "This is an unsaved message";
+
+            await mock.openAndEnterMUC(_converse, muc2_jid, "romeo");
+
+            // Switch back to the room with the draft
+            document.querySelector(`converse-rooms-list li[data-room-jid="${muc1_jid}"] a`).click();
+            expect(view.querySelector(".chat-textarea").value).toBe("This is an unsaved message");
+        })
+    );
+});

+ 3 - 1
src/plugins/muc-views/tests/mentions.js

@@ -453,7 +453,9 @@ describe("A sent groupchat message", function () {
             action.style.opacity = 1;
             action.click();
 
-            expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
+                    debugger;
+            await u.waitUntil(() => textarea.value === 'hello @z3r0 @gibson @mr.robot, how are you?');
+                    return
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.querySelectorAll('.chat-msg').length).toBe(1);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);

+ 1 - 1
src/plugins/muc-views/tests/unfurls.js

@@ -474,7 +474,7 @@ describe("A Groupchat Message", function () {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe(unfurl_url);
+        await u.waitUntil(() => textarea.value === unfurl_url);
         textarea.value = "never mind";
         message_form.onKeyDown(enter_event);
 

+ 3 - 3
src/plugins/omemo/tests/corrections.js

@@ -61,7 +61,7 @@ describe("An OMEMO encrypted message", function() {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
@@ -113,7 +113,7 @@ describe("An OMEMO encrypted message", function() {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe('But soft, what light through yonder door breaks?');
+        await u.waitUntil(() => textarea.value === 'But soft, what light through yonder door breaks?');
 
         const newest_text = 'But soft, what light through yonder window breaks?';
         textarea.value = newest_text;
@@ -329,7 +329,7 @@ describe("An OMEMO encrypted MUC message", function() {
             target: textarea,
             key: "ArrowUp",
         });
-        expect(textarea.value).toBe(original_text);
+        await u.waitUntil(() => textarea.value === original_text);
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
 
         const new_text = 'This is an edit of the encrypted message';

+ 14 - 10
src/shared/chat/message-actions.js

@@ -298,12 +298,16 @@ class MessageActions extends CustomElement {
     /** @param {MouseEvent} [ev] */
     onMessageQuoteButtonClicked (ev) {
         ev?.preventDefault?.();
-        const { chatboxviews } = _converse.state;
-        const view = chatboxviews.get(this.model.collection.chatbox.get('jid'));
-        view?.getMessageForm().insertIntoTextArea(
-            this.model.getMessageText().replaceAll(/^/gm, '> '),
-            false, false, null, '\n'
-        );
+        const chatbox = this.model.collection.chatbox;
+        const idx = u.ancestor(this, '.chatbox')?.querySelector('.chat-textarea')?.selectionEnd;
+        const new_text = this.model.getMessageText().replaceAll(/^/gm, '> ');
+        let draft = chatbox.get('draft') ?? '';
+        if (idx) {
+            draft = `${draft.slice(0, idx)}\n${new_text}\n${draft.slice(idx)}`;
+        } else {
+            draft += new_text;
+        }
+        chatbox.save({ draft });
     }
 
     async getActionButtons () {
@@ -311,7 +315,7 @@ class MessageActions extends CustomElement {
         if (this.model.get('editable')) {
             buttons.push(/** @type {MessageActionAttributes} */({
                 'i18n_text': this.model.get('correcting') ? __('Cancel Editing') : __('Edit'),
-                'handler': ev => this.onMessageEditButtonClicked(ev),
+                'handler': (ev) => this.onMessageEditButtonClicked(ev),
                 'button_class': 'chat-msg__action-edit',
                 'icon_class': 'fa fa-pencil-alt',
                 'name': 'edit',
@@ -324,7 +328,7 @@ class MessageActions extends CustomElement {
         if (retractable) {
             buttons.push({
                 'i18n_text': __('Retract'),
-                'handler': ev => this.onMessageRetractButtonClicked(ev),
+                'handler': (ev) => this.onMessageRetractButtonClicked(ev),
                 'button_class': 'chat-msg__action-retract',
                 'icon_class': 'fas fa-trash-alt',
                 'name': 'retract',
@@ -341,7 +345,7 @@ class MessageActions extends CustomElement {
 
         buttons.push({
             'i18n_text': __('Copy'),
-            'handler': ev => this.onMessageCopyButtonClicked(ev),
+            'handler': (ev) => this.onMessageCopyButtonClicked(ev),
             'button_class': 'chat-msg__action-copy',
             'icon_class': 'fas fa-copy',
             'name': 'copy',
@@ -350,7 +354,7 @@ class MessageActions extends CustomElement {
         if (this.model.collection.chatbox.canPostMessages()) {
             buttons.push({
                 'i18n_text': __('Quote'),
-                'handler': ev => this.onMessageQuoteButtonClicked(ev),
+                'handler': (ev) => this.onMessageQuoteButtonClicked(ev),
                 'button_class': 'chat-msg__action-quote',
                 'icon_class': 'fas fa-quote-right',
                 'name': 'quote',

+ 18 - 9
src/shared/chat/message-limit.js

@@ -4,27 +4,36 @@ import { api } from '@converse/headless';
 
 export default class MessageLimitIndicator extends CustomElement {
 
-    constructor () {
-        super();
-        this.model = null;
-    }
-
     static get properties () {
         return {
-            model: { type: Object }
+            model: { type: Object },
+            _draft_length: { state: true }
         }
     }
 
+    constructor () {
+        super();
+        this.model = null;
+        this._draft_length = 0;
+    }
+
     connectedCallback () {
         super.connectedCallback();
-        this.listenTo(this.model, 'change:draft', () => this.requestUpdate());
+        this._draft_length = this.model.get('draft')?.length ?? 0;
+
+        this.listenTo(this.model, 'change:draft', () => {
+            this._draft_length = this.model.get('draft')?.length ?? 0;
+        });
+        this.listenTo(this.model, 'event:keyup', ({ ev }) => {
+            const textarea = /** @type {HTMLTextAreaElement} */ (ev.target);
+            this._draft_length = textarea.value.length;
+        });
     }
 
     render () {
         const limit = api.settings.get('message_limit');
         if (!limit) return '';
-        const chars = this.model.get('draft') || '';
-        return tplMessageLimit(limit - chars.length);
+        return tplMessageLimit(limit - this._draft_length);
     }
 }
 

+ 1 - 5
src/types/plugins/chatview/message-form.d.ts

@@ -18,11 +18,7 @@ export default class MessageForm extends CustomElement {
      * @param {number} [position] - The end index of the string to be
      *  replaced with the new value.
      */
-    insertIntoTextArea(value: string, replace?: (boolean | string), correcting?: boolean, position?: number, separator?: string): void;
-    /**
-     * @param {import('@converse/headless').BaseMessage} message
-     */
-    onMessageCorrecting(message: import("@converse/headless").BaseMessage<any>): void;
+    insertIntoTextArea(value: string, replace?: (boolean | string), position?: number, separator?: string): void;
     /**
      * Handles the escape key press event to stop correcting a message.
      * @param {KeyboardEvent} ev

+ 4 - 0
src/types/shared/chat/message-limit.d.ts

@@ -3,8 +3,12 @@ export default class MessageLimitIndicator extends CustomElement {
         model: {
             type: ObjectConstructor;
         };
+        _draft_length: {
+            state: boolean;
+        };
     };
     model: any;
+    _draft_length: number;
     connectedCallback(): void;
     render(): "" | import("lit").TemplateResult<1>;
 }