2
0
Эх сурвалжийг харах

Add support for correcting the last message sent

fixes #421
JC Brand 7 жил өмнө
parent
commit
2929647e16

+ 1 - 1
CHANGES.md

@@ -7,6 +7,7 @@
 - #161 XEP-0363: HTTP File Upload
 - #161 XEP-0363: HTTP File Upload
 - #194 Include entity capabilities in outgoing presence stanzas
 - #194 Include entity capabilities in outgoing presence stanzas
 - #337 API call to update a VCard
 - #337 API call to update a VCard
+- #421 XEP-0308: Last Message Correction
 - #968 Use nickname from VCard when joining a room
 - #968 Use nickname from VCard when joining a room
 - #1091 There's now only one CSS file for all view modes.
 - #1091 There's now only one CSS file for all view modes.
 - #1094 Show room members who aren't currently online
 - #1094 Show room members who aren't currently online
@@ -22,7 +23,6 @@
   If the device is trusted, localStorage is used and user data is cached indefinitely.
   If the device is trusted, localStorage is used and user data is cached indefinitely.
 - Initial support for XEP-0357 Push Notifications, specifically registering an "App Server".
 - Initial support for XEP-0357 Push Notifications, specifically registering an "App Server".
 - Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configurations.html#oauth-providers) setting)
 - Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configurations.html#oauth-providers) setting)
-- XEP-0308: Render message corrections 
 
 
 ### Bugfixes
 ### Bugfixes
 
 

+ 1 - 0
README.md

@@ -60,6 +60,7 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
 -   Server-side archiving of messages [XEP 313](http://xmpp.org/extensions/xep-0313.html)
 -   Server-side archiving of messages [XEP 313](http://xmpp.org/extensions/xep-0313.html)
 -   Hidden Messages (aka Spoilers) [XEP 382](http://xmpp.org/extensions/xep-0382.html)
 -   Hidden Messages (aka Spoilers) [XEP 382](http://xmpp.org/extensions/xep-0382.html)
 -   Client state indication [XEP 352](http://xmpp.org/extensions/xep-0352.html)
 -   Client state indication [XEP 352](http://xmpp.org/extensions/xep-0352.html)
+-   Last Message Correction [XEP 308](http://xmpp.org/extensions/xep-0308.html)
 -   Off-the-record encryption
 -   Off-the-record encryption
 -   Translated into 16 languages
 -   Translated into 16 languages
 
 

+ 33 - 30
css/converse.css

@@ -8051,36 +8051,6 @@ body.reset {
   #conversejs .toggle-controlbox span {
   #conversejs .toggle-controlbox span {
     color: white; }
     color: white; }
 
 
-@media (max-width: 767.98px) {
-  #conversejs:not(.converse-embedded) {
-    left: 0;
-    right: 0;
-    padding-left: env(safe-area-inset-left);
-    padding-right: env(safe-area-inset-right); }
-    #conversejs:not(.converse-embedded) .converse-chatboxes {
-      margin: 0 !important;
-      flex-direction: row !important;
-      justify-content: space-between; }
-      #conversejs:not(.converse-embedded) .converse-chatboxes .converse-chatroom {
-        font-size: 14px; }
-      #conversejs:not(.converse-embedded) .converse-chatboxes .chatbox .box-flyout {
-        margin-left: 15px;
-        left: 0;
-        bottom: 0;
-        border-radius: 0;
-        width: 100vw !important;
-        height: 100vh !important; }
-      #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox {
-        width: 100vw !important; }
-        #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox .box-flyout {
-          width: 100vw !important;
-          height: 100vh !important; }
-        #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox .sidebar {
-          display: block; }
-      #conversejs:not(.converse-embedded) .converse-chatboxes.sidebar-open .chatbox:not(#controlbox) {
-        display: none; }
-      #conversejs:not(.converse-embedded) .converse-chatboxes.sidebar-open #controlbox .controlbox-pane {
-        display: block; } }
 #conversejs.converse-overlayed #controlbox {
 #conversejs.converse-overlayed #controlbox {
   order: -1;
   order: -1;
   min-width: 250px !important;
   min-width: 250px !important;
@@ -8257,6 +8227,39 @@ body.reset {
     #conversejs.converse-mobile #controlbox #converse-login input[type=button] {
     #conversejs.converse-mobile #controlbox #converse-login input[type=button] {
       width: auto; }
       width: auto; }
 
 
+@media (max-width: 767.98px) {
+  #conversejs:not(.converse-embedded) {
+    left: 0;
+    right: 0;
+    padding-left: env(safe-area-inset-left);
+    padding-right: env(safe-area-inset-right); }
+    #conversejs:not(.converse-embedded) .converse-chatboxes {
+      margin: 0 !important;
+      flex-direction: row !important;
+      justify-content: space-between; }
+      #conversejs:not(.converse-embedded) .converse-chatboxes .converse-chatroom {
+        font-size: 14px; }
+      #conversejs:not(.converse-embedded) .converse-chatboxes .chatbox .box-flyout {
+        margin-left: 15px;
+        left: 0;
+        bottom: 0;
+        border-radius: 0;
+        width: 100vw !important;
+        height: 100vh !important; }
+      #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox {
+        width: 100vw !important; }
+        #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox .box-flyout {
+          width: 100vw !important;
+          height: 100vh !important; }
+        #conversejs:not(.converse-embedded) .converse-chatboxes #controlbox .sidebar {
+          display: block; }
+      #conversejs:not(.converse-embedded) .converse-chatboxes.sidebar-open .chatbox:not(#controlbox) {
+        display: none; }
+      #conversejs:not(.converse-embedded) .converse-chatboxes.sidebar-open #controlbox .controlbox-pane {
+        display: block; }
+
+  #conversejs.converse-overlayed .converse-chatboxes .chatbox .box-flyout {
+    margin-left: 30px; } }
 #conversejs #converse-roster {
 #conversejs #converse-roster {
   text-align: left;
   text-align: left;
   width: 100%;
   width: 100%;

+ 56 - 12
dist/converse.js

@@ -68305,6 +68305,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
         utils = _converse$env.utils,
         utils = _converse$env.utils,
         _ = _converse$env._;
         _ = _converse$env._;
   const u = converse.env.utils;
   const u = converse.env.utils;
+  Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
   converse.plugins.add('converse-chatboxes', {
   converse.plugins.add('converse-chatboxes', {
     dependencies: ["converse-roster", "converse-vcard"],
     dependencies: ["converse-roster", "converse-vcard"],
     overrides: {
     overrides: {
@@ -68615,7 +68616,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
             'from': _converse.connection.jid,
             'from': _converse.connection.jid,
             'to': this.get('jid'),
             'to': this.get('jid'),
             'type': this.get('message_type'),
             'type': this.get('message_type'),
-            'id': message.get('msgid')
+            'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid')
           }).c('body').t(message.get('message')).up().c(_converse.ACTIVE, {
           }).c('body').t(message.get('message')).up().c(_converse.ACTIVE, {
             'xmlns': Strophe.NS.CHATSTATES
             'xmlns': Strophe.NS.CHATSTATES
           }).up();
           }).up();
@@ -68638,6 +68639,13 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
             }).c('url').t(message.get('message')).up();
             }).c('url').t(message.get('message')).up();
           }
           }
 
 
+          if (message.get('edited')) {
+            stanza.c('replace', {
+              'xmlns': Strophe.NS.MESSAGE_CORRECT,
+              'id': message.get('msgid')
+            }).up();
+          }
+
           return stanza;
           return stanza;
         },
         },
 
 
@@ -68667,6 +68675,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
 
 
           return {
           return {
             'fullname': fullname,
             'fullname': fullname,
+            'replace': this.correction,
             'from': _converse.bare_jid,
             'from': _converse.bare_jid,
             'sender': 'me',
             'sender': 'me',
             'time': moment().format(),
             'time': moment().format(),
@@ -68682,7 +68691,24 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
            *  Parameters:
            *  Parameters:
            *    (Message) message - The chat message
            *    (Message) message - The chat message
            */
            */
-          this.sendMessageStanza(this.messages.create(attrs));
+          if (attrs.replace) {
+            const message = this.messages.findWhere({
+              'id': attrs.replace
+            });
+
+            if (message) {
+              const older_versions = message.get('older_versions') || [];
+              older_versions.push(message.get('message'));
+              message.save({
+                'message': attrs.message,
+                'older_versions': older_versions,
+                'edited': true
+              });
+              return this.sendMessageStanza(message);
+            }
+          }
+
+          return this.sendMessageStanza(this.messages.create(attrs));
         },
         },
 
 
         sendChatState() {
         sendChatState() {
@@ -69177,6 +69203,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
       });
       });
 
 
       _converse.on('addClientFeatures', () => {
       _converse.on('addClientFeatures', () => {
+        _converse.api.disco.own.features.add(Strophe.NS.MESSAGE_CORRECT);
+
         _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
         _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
 
 
         _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
         _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
@@ -69315,6 +69343,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
   const u = converse.env.utils;
   const u = converse.env.utils;
   const KEY = {
   const KEY = {
     ENTER: 13,
     ENTER: 13,
+    UP_ARROW: 38,
     FORWARD_SLASH: 47
     FORWARD_SLASH: 47
   };
   };
   converse.plugins.add('converse-chatview', {
   converse.plugins.add('converse-chatview', {
@@ -69592,7 +69621,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
           'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
           'click .toggle-smiley': 'toggleEmojiMenu',
           'click .toggle-smiley': 'toggleEmojiMenu',
           'click .upload-file': 'toggleFileUpload',
           'click .upload-file': 'toggleFileUpload',
-          'keypress .chat-textarea': 'keyPressed',
+          'keyup .chat-textarea': 'keyPressed',
           'input .chat-textarea': 'inputChanged'
           'input .chat-textarea': 'inputChanged'
         },
         },
 
 
@@ -70113,6 +70142,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           }
           }
 
 
           const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
           const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
+          delete this.model.correction;
           this.model.sendMessage(attrs);
           this.model.sendMessage(attrs);
         },
         },
 
 
@@ -70175,6 +70205,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
            */
            */
           if (ev.keyCode === KEY.ENTER && !ev.shiftKey) {
           if (ev.keyCode === KEY.ENTER && !ev.shiftKey) {
             this.onFormSubmitted(ev);
             this.onFormSubmitted(ev);
+          } else if (ev.keyCode === KEY.UP_ARROW && !ev.shiftKey) {
+            this.editPreviousMessage();
           } else if (ev.keyCode !== KEY.FORWARD_SLASH && this.model.get('chat_state') !== _converse.COMPOSING) {
           } else if (ev.keyCode !== KEY.FORWARD_SLASH && this.model.get('chat_state') !== _converse.COMPOSING) {
             // Set chat state to composing if keyCode is not a forward-slash
             // Set chat state to composing if keyCode is not a forward-slash
             // (which would imply an internal command and not a message).
             // (which would imply an internal command and not a message).
@@ -70182,6 +70214,19 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           }
           }
         },
         },
 
 
+        editPreviousMessage() {
+          const msg = _.findLast(this.model.messages.models, msg => msg.get('message'));
+
+          if (msg) {
+            const textbox_el = this.el.querySelector('.chat-textarea');
+            textbox_el.value = msg.get('message');
+            textbox_el.focus(); // We don't set "correcting" the Backbone-way, because
+            // we don't want it to persist to storage.
+
+            this.model.correction = msg.get('id');
+          }
+        },
+
         inputChanged(ev) {
         inputChanged(ev) {
           ev.target.style.height = 'auto'; // Fixes weirdness
           ev.target.style.height = 'auto'; // Fixes weirdness
 
 
@@ -71193,7 +71238,6 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
   Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
   Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
   Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
   Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
   Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
   Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
-  Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
   Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
   Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
   Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
   Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
   Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
   Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
@@ -87771,6 +87815,14 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
     return result;
     return result;
   };
   };
 
 
+  u.getUniqueId = function () {
+    return 'xxxxxxxx-xxxx'.replace(/[x]/g, function (c) {
+      var r = Math.random() * 16 | 0,
+          v = c === 'x' ? r : r & 0x3 | 0x8;
+      return v.toString(16);
+    });
+  };
+
   return u;
   return u;
 });
 });
 
 
@@ -87839,14 +87891,6 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
     }));
     }));
   };
   };
 
 
-  u.getUniqueId = function () {
-    return 'xxxxxxxx-xxxx'.replace(/[x]/g, function (c) {
-      var r = Math.random() * 16 | 0,
-          v = c === 'x' ? r : r & 0x3 | 0x8;
-      return v.toString(16);
-    });
-  };
-
   u.xForm2webForm = function (field, stanza, domain) {
   u.xForm2webForm = function (field, stanza, domain) {
     /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
     /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
      * and turns it into an HTML field.
      * and turns it into an HTML field.

+ 1 - 0
index.html

@@ -174,6 +174,7 @@
                             <li>Server-side archiving of messages (<a href="http://xmpp.org/extensions/xep-0313.html" target="_blank" rel="noopener">XEP 313</a>)</li>
                             <li>Server-side archiving of messages (<a href="http://xmpp.org/extensions/xep-0313.html" target="_blank" rel="noopener">XEP 313</a>)</li>
                             <li>Hidden messages (aka Spoilers) (<a href="http://xmpp.org/extensions/xep-0382.html" target="_blank" rel="noopener">XEP 382</a>)</li>
                             <li>Hidden messages (aka Spoilers) (<a href="http://xmpp.org/extensions/xep-0382.html" target="_blank" rel="noopener">XEP 382</a>)</li>
                             <li>Client state indication (<a href="http://xmpp.org/extensions/xep-0352.html" target="_blank" rel="noopener">XEP 352</a>)</li>
                             <li>Client state indication (<a href="http://xmpp.org/extensions/xep-0352.html" target="_blank" rel="noopener">XEP 352</a>)</li>
+                            <li>Last Message Correction (<a href="http://xmpp.org/extensions/xep-0308.html" target="_blank" rel="noopener">XEP 308</a>)</li>
                             <li>Off-the-record encryption</li>
                             <li>Off-the-record encryption</li>
                             <li>Supports anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a>.</li>
                             <li>Supports anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a>.</li>
                             <li>Translated into 17 languages</li>
                             <li>Translated into 17 languages</li>

+ 64 - 53
sass/_controlbox.scss

@@ -350,59 +350,6 @@
     }
     }
 }
 }
 
 
-@include media-breakpoint-down(sm) {
-
-    #conversejs:not(.converse-embedded)  {
-        left: 0;
-        right: 0;
-        padding-left: env(safe-area-inset-left);
-        padding-right: env(safe-area-inset-right);
-
-        .converse-chatboxes {
-            margin: 0 !important;
-            flex-direction: row !important;
-            justify-content: space-between;
-
-            .converse-chatroom {
-                font-size: 14px;
-            }
-
-            .chatbox { 
-                .box-flyout {
-                    margin-left: 15px; // Counteracts Bootstrap margins, but
-                                       // not clear why needed...
-                    left: 0;
-                    bottom: 0;
-                    border-radius: 0;
-                    width: 100vw !important;
-                    height: 100vh !important;
-                }
-            }
-
-            #controlbox {
-                width: 100vw !important;
-                .box-flyout {
-                    width: 100vw !important;
-                    height: 100vh !important;
-                }
-                .sidebar {
-                    display: block;
-                }
-            }
-
-            &.sidebar-open {
-                .chatbox:not(#controlbox) {
-                    display: none;
-                }
-                #controlbox {
-                    .controlbox-pane {
-                        display: block;
-                    }
-                }
-            }
-        }
-    }
-}
 
 
 #conversejs.converse-overlayed {
 #conversejs.converse-overlayed {
     #controlbox {
     #controlbox {
@@ -563,3 +510,67 @@
         }
         }
     }
     }
 }
 }
+
+@include media-breakpoint-down(sm) {
+
+    #conversejs:not(.converse-embedded)  {
+        left: 0;
+        right: 0;
+        padding-left: env(safe-area-inset-left);
+        padding-right: env(safe-area-inset-right);
+
+        .converse-chatboxes {
+            margin: 0 !important;
+            flex-direction: row !important;
+            justify-content: space-between;
+
+            .converse-chatroom {
+                font-size: 14px;
+            }
+
+            .chatbox { 
+                .box-flyout {
+                    margin-left: 15px; // Counteracts Bootstrap margins, but
+                                       // not clear why needed...
+                    left: 0;
+                    bottom: 0;
+                    border-radius: 0;
+                    width: 100vw !important;
+                    height: 100vh !important;
+                }
+            }
+
+            #controlbox {
+                width: 100vw !important;
+                .box-flyout {
+                    width: 100vw !important;
+                    height: 100vh !important;
+                }
+                .sidebar {
+                    display: block;
+                }
+            }
+
+            &.sidebar-open {
+                .chatbox:not(#controlbox) {
+                    display: none;
+                }
+                #controlbox {
+                    .controlbox-pane {
+                        display: block;
+                    }
+                }
+            }
+        }
+    }
+    #conversejs.converse-overlayed {
+        .converse-chatboxes {
+            .chatbox { 
+                .box-flyout {
+                    margin-left: 30px; // Counteracts Bootstrap margins, but
+                                       // not clear why needed...
+                }
+            }
+        }
+    }
+}

+ 10 - 18
spec/chatbox.js

@@ -635,7 +635,7 @@
                             spyOn(_converse.connection, 'send');
                             spyOn(_converse.connection, 'send');
                             spyOn(_converse, 'emit');
                             spyOn(_converse, 'emit');
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
@@ -648,7 +648,7 @@
 
 
                             // The notification is not sent again
                             // The notification is not sent again
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
@@ -776,7 +776,7 @@
                             spyOn(view, 'setChatState').and.callThrough();
                             spyOn(view, 'setChatState').and.callThrough();
                             expect(view.model.get('chat_state')).toBe('active');
                             expect(view.model.get('chat_state')).toBe('active');
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
@@ -803,14 +803,14 @@
                             // out if the user simply types longer than the
                             // out if the user simply types longer than the
                             // timeout.
                             // timeout.
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
                             expect(view.setChatState).toHaveBeenCalled();
                             expect(view.setChatState).toHaveBeenCalled();
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
 
 
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
@@ -921,33 +921,25 @@
                             contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
                             contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
                             test_utils.openChatBoxFor(_converse, contact_jid);
                             test_utils.openChatBoxFor(_converse, contact_jid);
                             view = _converse.chatboxviews.get(contact_jid);
                             view = _converse.chatboxviews.get(contact_jid);
-                            return test_utils.waitUntil(function () {
-                                return view.model.get('chat_state') === 'active';
-                            }, 500);
+                            return test_utils.waitUntil(() => view.model.get('chat_state') === 'active', 500);
                         }).then(function () {
                         }).then(function () {
                             console.log('chat_state set to active');
                             console.log('chat_state set to active');
                             view = _converse.chatboxviews.get(contact_jid);
                             view = _converse.chatboxviews.get(contact_jid);
                             expect(view.model.get('chat_state')).toBe('active');
                             expect(view.model.get('chat_state')).toBe('active');
                             view.keyPressed({
                             view.keyPressed({
-                                target: $(view.el).find('textarea.chat-textarea'),
+                                target: view.el.querySelector('textarea.chat-textarea'),
                                 keyCode: 1
                                 keyCode: 1
                             });
                             });
-                            return test_utils.waitUntil(function () {
-                                return view.model.get('chat_state') === 'composing';
-                            }, 500);
+                            return test_utils.waitUntil(() => view.model.get('chat_state') === 'composing', 500);
                         }).then(function () {
                         }).then(function () {
                             console.log('chat_state set to composing');
                             console.log('chat_state set to composing');
                             view = _converse.chatboxviews.get(contact_jid);
                             view = _converse.chatboxviews.get(contact_jid);
                             expect(view.model.get('chat_state')).toBe('composing');
                             expect(view.model.get('chat_state')).toBe('composing');
                             spyOn(_converse.connection, 'send');
                             spyOn(_converse.connection, 'send');
-                            return test_utils.waitUntil(function () {
-                                return view.model.get('chat_state') === 'paused';
-                            }, 500);
+                            return test_utils.waitUntil(() => view.model.get('chat_state') === 'paused', 500);
                         }).then(function () {
                         }).then(function () {
                             console.log('chat_state set to paused');
                             console.log('chat_state set to paused');
-                            return test_utils.waitUntil(function () {
-                                return view.model.get('chat_state') === 'inactive';
-                            }, 500);
+                            return test_utils.waitUntil(() => view.model.get('chat_state') === 'inactive', 500);
                         }).then(function () {
                         }).then(function () {
                             console.log('chat_state set to inactive');
                             console.log('chat_state set to inactive');
                             expect(_converse.connection.send).toHaveBeenCalled();
                             expect(_converse.connection.send).toHaveBeenCalled();

+ 64 - 0
spec/messages.js

@@ -136,6 +136,70 @@
                 });
                 });
             }));
             }));
 
 
+            it("can be sent as a correction",
+                mock.initConverseWithPromises(
+                    null, ['rosterGroupsFetched'], {},
+                    function (done, _converse) {
+
+                test_utils.createContacts(_converse, 'current', 1);
+                test_utils.openControlBox();
+                const message = 'This is a received message';
+                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                test_utils.openChatBoxFor(_converse, contact_jid);
+
+                const view = _converse.chatboxviews.get(contact_jid);
+                const textarea = view.el.querySelector('textarea.chat-textarea');
+                expect(textarea.value).toBe('');
+                view.keyPressed({
+                    target: textarea,
+                    keyCode: 38
+                });
+                expect(textarea.value).toBe('');
+
+                textarea.value = 'But soft, what light through yonder airlock breaks?';
+                view.keyPressed({
+                    target: textarea,
+                    preventDefault: _.noop,
+                    keyCode: 13
+                });
+                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+                expect(view.el.querySelector('.chat-msg-text').textContent)
+                    .toBe('But soft, what light through yonder airlock breaks?');
+
+                const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+                expect(textarea.value).toBe('');
+                view.keyPressed({
+                    target: textarea,
+                    keyCode: 38
+                });
+                expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+
+                spyOn(_converse.connection, 'send');
+                textarea.value = 'But soft, what light through yonder window breaks?';
+                view.keyPressed({
+                    target: textarea,
+                    preventDefault: _.noop,
+                    keyCode: 13
+                });
+                expect(_converse.connection.send).toHaveBeenCalled();
+
+                const msg = _converse.connection.send.calls.all()[0].args[0];
+                expect(msg.toLocaleString())
+                .toBe(`<message from='dummy@localhost/resource' `+
+                        `to='max.frankfurter@localhost' type='chat' id='${msg.nodeTree.getAttribute('id')}' `+
+                        `xmlns='jabber:client'>`+
+                            `<body>But soft, what light through yonder window breaks?</body>`+
+                            `<active xmlns='http://jabber.org/protocol/chatstates'/>`+
+                            `<replace xmlns='urn:xmpp:message-correct:0' id='${first_msg.get('msgid')}'/>`+
+                    `</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'));
+                expect(corrected_message.get('older_versions').length).toBe(1);
+                expect(corrected_message.get('older_versions')[0]).toBe('But soft, what light through yonder airlock breaks?');
+                done();
+            }));
+
             describe("when a chatbox is opened for someone who is not in the roster", function () {
             describe("when a chatbox is opened for someone who is not in the roster", function () {
 
 
                 it("the VCard for that user is fetched and the chatbox updated with the results",
                 it("the VCard for that user is fetched and the chatbox updated with the results",

+ 5 - 5
spec/presence.js

@@ -47,7 +47,7 @@
                 "<presence xmlns='jabber:client'>"+
                 "<presence xmlns='jabber:client'>"+
                     "<status>Hello world</status>"+
                     "<status>Hello world</status>"+
                     "<priority>0</priority>"+
                     "<priority>0</priority>"+
-                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='1J7kq1MEvnB6ea6vKcgCsSE37gw='/>"+
+                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='wmJWAEmiBuDhg0VUoDmqHp3qXJ0='/>"+
                 "</presence>"
                 "</presence>"
             );
             );
             _converse.priority = 2;
             _converse.priority = 2;
@@ -57,7 +57,7 @@
                     "<show>away</show>"+
                     "<show>away</show>"+
                     "<status>Going jogging</status>"+
                     "<status>Going jogging</status>"+
                     "<priority>2</priority>"+
                     "<priority>2</priority>"+
-                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='1J7kq1MEvnB6ea6vKcgCsSE37gw='/>"+
+                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='wmJWAEmiBuDhg0VUoDmqHp3qXJ0='/>"+
                 "</presence>"
                 "</presence>"
             );
             );
 
 
@@ -68,7 +68,7 @@
                     "<show>dnd</show>"+
                     "<show>dnd</show>"+
                     "<status>Doing taxes</status>"+
                     "<status>Doing taxes</status>"+
                     "<priority>0</priority>"+
                     "<priority>0</priority>"+
-                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='1J7kq1MEvnB6ea6vKcgCsSE37gw='/>"+
+                    "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='wmJWAEmiBuDhg0VUoDmqHp3qXJ0='/>"+
                 "</presence>"
                 "</presence>"
             );
             );
         }));
         }));
@@ -97,7 +97,7 @@
                     .toBe("<presence xmlns='jabber:client'>"+
                     .toBe("<presence xmlns='jabber:client'>"+
                           "<status>My custom status</status>"+
                           "<status>My custom status</status>"+
                           "<priority>0</priority>"+
                           "<priority>0</priority>"+
-                          "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='1J7kq1MEvnB6ea6vKcgCsSE37gw='/>"+
+                          "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='wmJWAEmiBuDhg0VUoDmqHp3qXJ0='/>"+
                           "</presence>")
                           "</presence>")
 
 
                 return test_utils.waitUntil(function () {
                 return test_utils.waitUntil(function () {
@@ -113,7 +113,7 @@
                 modal.el.querySelector('[type="submit"]').click();
                 modal.el.querySelector('[type="submit"]').click();
                 expect(_converse.connection.send.calls.mostRecent().args[0].toLocaleString())
                 expect(_converse.connection.send.calls.mostRecent().args[0].toLocaleString())
                     .toBe("<presence xmlns='jabber:client'><show>dnd</show><status>My custom status</status><priority>0</priority>"+
                     .toBe("<presence xmlns='jabber:client'><show>dnd</show><status>My custom status</status><priority>0</priority>"+
-                          "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='1J7kq1MEvnB6ea6vKcgCsSE37gw='/>"+
+                          "<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' node='https://conversejs.org' ver='wmJWAEmiBuDhg0VUoDmqHp3qXJ0='/>"+
                           "</presence>")
                           "</presence>")
                 done();
                 done();
             });
             });

+ 26 - 3
src/converse-chatboxes.js

@@ -19,6 +19,8 @@
     const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
     const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
     const u = converse.env.utils;
     const u = converse.env.utils;
 
 
+    Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
+
 
 
     converse.plugins.add('converse-chatboxes', {
     converse.plugins.add('converse-chatboxes', {
 
 
@@ -43,7 +45,7 @@
              * loaded by converse.js's plugin machinery.
              * loaded by converse.js's plugin machinery.
              */
              */
             const { _converse } = this,
             const { _converse } = this,
-                { __ } = _converse;
+                  { __ } = _converse;
 
 
             // Configuration values for this plugin
             // Configuration values for this plugin
             // ====================================
             // ====================================
@@ -314,7 +316,7 @@
                             'from': _converse.connection.jid,
                             'from': _converse.connection.jid,
                             'to': this.get('jid'),
                             'to': this.get('jid'),
                             'type': this.get('message_type'),
                             'type': this.get('message_type'),
-                            'id': message.get('msgid')
+                            'id': message.get('edited') && _converse.connection.getUniqueId() || message.get('msgid'),
                         }).c('body').t(message.get('message')).up()
                         }).c('body').t(message.get('message')).up()
                           .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
                           .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
 
 
@@ -328,6 +330,12 @@
                     if (message.get('file')) {
                     if (message.get('file')) {
                         stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
                         stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
                     }
                     }
+                    if (message.get('edited')) {
+                        stanza.c('replace', {
+                            'xmlns': Strophe.NS.MESSAGE_CORRECT,
+                            'id': message.get('msgid')
+                        }).up();
+                    }
                     return stanza;
                     return stanza;
                 },
                 },
 
 
@@ -357,6 +365,7 @@
 
 
                     return {
                     return {
                         'fullname': fullname,
                         'fullname': fullname,
+                        'replace': this.correction,
                         'from': _converse.bare_jid,
                         'from': _converse.bare_jid,
                         'sender': 'me',
                         'sender': 'me',
                         'time': moment().format(),
                         'time': moment().format(),
@@ -372,7 +381,20 @@
                      *  Parameters:
                      *  Parameters:
                      *    (Message) message - The chat message
                      *    (Message) message - The chat message
                      */
                      */
-                    this.sendMessageStanza(this.messages.create(attrs));
+                    if (attrs.replace) {
+                        const message = this.messages.findWhere({'id': attrs.replace})
+                        if (message) {
+                            const older_versions = message.get('older_versions') || [];
+                            older_versions.push(message.get('message'));
+                            message.save({
+                                'message': attrs.message,
+                                'older_versions': older_versions,
+                                'edited': true
+                            });
+                            return this.sendMessageStanza(message);
+                        }
+                    }
+                    return this.sendMessageStanza(this.messages.create(attrs));
                 },
                 },
 
 
                 sendChatState () {
                 sendChatState () {
@@ -826,6 +848,7 @@
 
 
 
 
             _converse.on('addClientFeatures', () => {
             _converse.on('addClientFeatures', () => {
+                _converse.api.disco.own.features.add(Strophe.NS.MESSAGE_CORRECT);
                 _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
                 _converse.api.disco.own.features.add(Strophe.NS.HTTPUPLOAD);
                 _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
                 _converse.api.disco.own.features.add(Strophe.NS.OUTOFBAND);
             });
             });

+ 17 - 1
src/converse-chatview.js

@@ -54,6 +54,7 @@
     const u = converse.env.utils;
     const u = converse.env.utils;
     const KEY = {
     const KEY = {
         ENTER: 13,
         ENTER: 13,
+        UP_ARROW: 38,
         FORWARD_SLASH: 47
         FORWARD_SLASH: 47
     };
     };
 
 
@@ -333,7 +334,7 @@
                     'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
                     'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
                     'click .toggle-smiley': 'toggleEmojiMenu',
                     'click .toggle-smiley': 'toggleEmojiMenu',
                     'click .upload-file': 'toggleFileUpload',
                     'click .upload-file': 'toggleFileUpload',
-                    'keypress .chat-textarea': 'keyPressed',
+                    'keyup .chat-textarea': 'keyPressed',
                     'input .chat-textarea': 'inputChanged'
                     'input .chat-textarea': 'inputChanged'
                 },
                 },
 
 
@@ -847,6 +848,7 @@
                         return;
                         return;
                     }
                     }
                     const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
                     const attrs = this.model.getOutgoingMessageAttributes(text, spoiler_hint);
+                    delete this.model.correction;
                     this.model.sendMessage(attrs);
                     this.model.sendMessage(attrs);
                 },
                 },
 
 
@@ -912,6 +914,8 @@
                      */
                      */
                     if (ev.keyCode === KEY.ENTER && !ev.shiftKey) {
                     if (ev.keyCode === KEY.ENTER && !ev.shiftKey) {
                         this.onFormSubmitted(ev);
                         this.onFormSubmitted(ev);
+                    } else if (ev.keyCode === KEY.UP_ARROW && !ev.shiftKey) {
+                        this.editPreviousMessage();
                     } else if (ev.keyCode !== KEY.FORWARD_SLASH && this.model.get('chat_state') !== _converse.COMPOSING) {
                     } else if (ev.keyCode !== KEY.FORWARD_SLASH && this.model.get('chat_state') !== _converse.COMPOSING) {
                         // Set chat state to composing if keyCode is not a forward-slash
                         // Set chat state to composing if keyCode is not a forward-slash
                         // (which would imply an internal command and not a message).
                         // (which would imply an internal command and not a message).
@@ -919,6 +923,18 @@
                     }
                     }
                 },
                 },
 
 
+                editPreviousMessage () {
+                    const msg = _.findLast(this.model.messages.models, (msg) => msg.get('message'));
+                    if (msg) {
+                        const textbox_el = this.el.querySelector('.chat-textarea');
+                        textbox_el.value = msg.get('message');
+                        textbox_el.focus()
+                        // We don't set "correcting" the Backbone-way, because
+                        // we don't want it to persist to storage.
+                        this.model.correction = msg.get('id');
+                    }
+                },
+
                 inputChanged (ev) {
                 inputChanged (ev) {
                     ev.target.style.height = 'auto'; // Fixes weirdness
                     ev.target.style.height = 'auto'; // Fixes weirdness
                     ev.target.style.height = (ev.target.scrollHeight) + 'px';
                     ev.target.style.height = (ev.target.scrollHeight) + 'px';

+ 0 - 1
src/converse-core.js

@@ -36,7 +36,6 @@
     Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
     Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
     Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
     Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
     Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
     Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
-    Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
     Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
     Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
     Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
     Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
     Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
     Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');

+ 7 - 0
src/utils/core.js

@@ -844,5 +844,12 @@
         return result;
         return result;
     };
     };
 
 
+    u.getUniqueId = function () {
+        return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
+            var r = Math.random() * 16 | 0,
+                v = c === 'x' ? r : r & 0x3 | 0x8;
+            return v.toString(16);
+        });
+    };
     return u;
     return u;
 }));
 }));

+ 0 - 8
src/utils/form.js

@@ -73,14 +73,6 @@
         );
         );
     };
     };
 
 
-    u.getUniqueId = function () {
-        return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
-            var r = Math.random() * 16 | 0,
-                v = c === 'x' ? r : r & 0x3 | 0x8;
-            return v.toString(16);
-        });
-    };
-
     u.xForm2webForm = function (field, stanza, domain) {
     u.xForm2webForm = function (field, stanza, domain) {
         /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
         /* Takes a field in XMPP XForm (XEP-004: Data Forms) format
          * and turns it into an HTML field.
          * and turns it into an HTML field.