Przeglądaj źródła

Fixes #2557

Add the ability to send OMEMO corrections.

Refactor how OMEMO messages are sent to avoid having to override
`sendMessage` and thereby also allowing corrections of OMEMO messages to
be sent out.

Add two new hooks.
- getOutgoingMessageAttributes
- createMessageStanza
JC Brand 3 lat temu
rodzic
commit
d2622f6fed

+ 1 - 0
CHANGES.md

@@ -8,6 +8,7 @@
 - Fix bug where MUC config wasn't persisted across page loads
 - Fix bug where MUC config wasn't persisted across page loads
 - Add support for calling the IndexedDB `getAll` method to speed up fetching models from storage.
 - Add support for calling the IndexedDB `getAll` method to speed up fetching models from storage.
 - #1761: Add a new dark theme based on the [Dracula](https://draculatheme.com/) theme
 - #1761: Add a new dark theme based on the [Dracula](https://draculatheme.com/) theme
+- #2557: Allow OMEMO encrypted messages to be edited
 - #2627: Spoiler toggles only after switching to another tab and back
 - #2627: Spoiler toggles only after switching to another tab and back
 - #2733: Fix OMEMO race condition related to automatic reconnection and SMACKS
 - #2733: Fix OMEMO race condition related to automatic reconnection and SMACKS
 - #2733: Wait for decrypted/parsed message before queuing to UI
 - #2733: Wait for decrypted/parsed message before queuing to UI

+ 2 - 1
karma.conf.js

@@ -95,9 +95,10 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/unfurls.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/xss.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/xss.js", type: 'module' },
       { pattern: "src/plugins/notifications/tests/notification.js", type: 'module' },
       { pattern: "src/plugins/notifications/tests/notification.js", type: 'module' },
+      { pattern: "src/plugins/omemo/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/media-sharing.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/media-sharing.js", type: 'module' },
-      { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/muc.js", type: 'module' },
+      { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' },
       { pattern: "src/plugins/push/tests/push.js", type: 'module' },
       { pattern: "src/plugins/push/tests/push.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },

+ 1 - 6
src/headless/core.js

@@ -210,12 +210,7 @@ export const api = _converse.api = {
             // Create a chain of promises, with each one feeding its output to
             // Create a chain of promises, with each one feeding its output to
             // the next. The first input is a promise with the original data
             // the next. The first input is a promise with the original data
             // sent to this hook.
             // sent to this hook.
-            const o = events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data));
-            o.catch(e => {
-                log.error(e)
-                throw e;
-            });
-            return o;
+            return events.reduce((o, e) => o.then(d => e.callback(context, d)), Promise.resolve(data));
         } else {
         } else {
             return data;
             return data;
         }
         }

+ 5 - 2
src/headless/plugins/chat/message.js

@@ -162,11 +162,14 @@ const MessageMixin = {
     },
     },
 
 
     getMessageText () {
     getMessageText () {
-        const { __ } = _converse;
         if (this.get('is_encrypted')) {
         if (this.get('is_encrypted')) {
+            const { __ } = _converse;
             return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
             return this.get('plaintext') || this.get('body') || __('Undecryptable OMEMO message');
+        } else if (['groupchat', 'chat'].includes(this.get('type'))) {
+            return this.get('body');
+        } else {
+            return this.get('message');
         }
         }
-        return this.get('message');
     },
     },
 
 
     /**
     /**

+ 83 - 34
src/headless/plugins/chat/model.js

@@ -816,59 +816,79 @@ const ChatBox = ModelWithContact.extend({
      * @method _converse.ChatBox#createMessageStanza
      * @method _converse.ChatBox#createMessageStanza
      * @param { _converse.Message } message - The message object
      * @param { _converse.Message } message - The message object
      */
      */
-    createMessageStanza (message) {
+    async createMessageStanza (message) {
         const stanza = $msg({
         const stanza = $msg({
                 '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('edited') && u.getUniqueId() || message.get('msgid'),
                 'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
-            }).c('body').t(message.get('message')).up()
+            }).c('body').t(message.get('body')).up()
               .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
               .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
 
 
         if (message.get('type') === 'chat') {
         if (message.get('type') === 'chat') {
             stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
             stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
         }
         }
-        if (message.get('is_spoiler')) {
-            if (message.get('spoiler_hint')) {
-                stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
-            } else {
-                stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
-            }
-        }
-        (message.get('references') || []).forEach(reference => {
-            const attrs = {
-                'xmlns': Strophe.NS.REFERENCE,
-                'begin': reference.begin,
-                'end': reference.end,
-                'type': reference.type,
-            }
-            if (reference.uri) {
-                attrs.uri = reference.uri;
+
+        if (!message.get('is_encrypted')) {
+            if (message.get('is_spoiler')) {
+                if (message.get('spoiler_hint')) {
+                    stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
+                } else {
+                    stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
+                }
             }
             }
-            stanza.c('reference', attrs).root();
-        });
+            (message.get('references') || []).forEach(reference => {
+                const attrs = {
+                    'xmlns': Strophe.NS.REFERENCE,
+                    'begin': reference.begin,
+                    'end': reference.end,
+                    'type': reference.type,
+                }
+                if (reference.uri) {
+                    attrs.uri = reference.uri;
+                }
+                stanza.c('reference', attrs).root();
+            });
 
 
-        if (message.get('oob_url')) {
-            stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
+            if (message.get('oob_url')) {
+                stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
+            }
         }
         }
+
         if (message.get('edited')) {
         if (message.get('edited')) {
             stanza.c('replace', {
             stanza.c('replace', {
                 'xmlns': Strophe.NS.MESSAGE_CORRECT,
                 'xmlns': Strophe.NS.MESSAGE_CORRECT,
                 'id': message.get('msgid')
                 'id': message.get('msgid')
             }).root();
             }).root();
         }
         }
+
         if (message.get('origin_id')) {
         if (message.get('origin_id')) {
             stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
             stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
         }
         }
-        return stanza;
+        stanza.root();
+        /**
+         * *Hook* which allows plugins to update an outgoing message stanza
+         * @event _converse#createMessageStanza
+         * @param { _converse.ChatBox | _converse.ChatRoom } - The chat from
+         *      which this message stanza is being sent.
+         * @param { Object } data - Message data
+         * @param { _converse.Message | _converse.ChatRoomMessage } data.message
+         *      The message object from which the stanza is created and which gets persisted to storage.
+         * @param { Strophe.Builder } data.stanza
+         *      The stanza that will be sent out, as a Strophe.Builder object.
+         *      You can use the Strophe.Builder functions to extend the stanza.
+         *      See http://strophe.im/strophejs/doc/1.4.3/files/strophe-umd-js.html#Strophe.Builder.Functions
+         */
+        const data = await api.hook('createMessageStanza', this, { message, stanza });
+        return data.stanza;
     },
     },
 
 
-    getOutgoingMessageAttributes (attrs) {
+    async getOutgoingMessageAttributes (attrs) {
         const is_spoiler = !!this.get('composing_spoiler');
         const is_spoiler = !!this.get('composing_spoiler');
         const origin_id = u.getUniqueId();
         const origin_id = u.getUniqueId();
         const text = attrs?.body;
         const text = attrs?.body;
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-        return Object.assign({}, attrs, {
+        attrs = Object.assign({}, attrs, {
             'from': _converse.bare_jid,
             'from': _converse.bare_jid,
             'fullname': _converse.xmppstatus.get('fullname'),
             'fullname': _converse.xmppstatus.get('fullname'),
             'id': origin_id,
             'id': origin_id,
@@ -884,6 +904,19 @@ const ChatBox = ModelWithContact.extend({
             is_spoiler,
             is_spoiler,
             origin_id
             origin_id
         }, getMediaURLsMetadata(text));
         }, getMediaURLsMetadata(text));
+
+        /**
+         * *Hook* which allows plugins to update the attributes of an outgoing message.
+         * These attributes get set on the { @link _converse.Message } or
+         * { @link _converse.ChatRoomMessage } and persisted to storage.
+         * @event _converse#getOutgoingMessageAttributes
+         * @param { _converse.ChatBox | _converse.ChatRoom } chat
+         *      The chat from which this message will be sent.
+         * @param { MessageAttributes } attrs
+         *      The message attributes, from which the stanza will be created.
+         */
+        attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
+        return attrs;
     },
     },
 
 
     /**
     /**
@@ -939,26 +972,38 @@ const ChatBox = ModelWithContact.extend({
      * chat.sendMessage({'body': 'hello world'});
      * chat.sendMessage({'body': 'hello world'});
      */
      */
     async sendMessage (attrs) {
     async sendMessage (attrs) {
-        attrs = this.getOutgoingMessageAttributes(attrs);
+        attrs = await this.getOutgoingMessageAttributes(attrs);
         let message = this.messages.findWhere('correcting')
         let message = this.messages.findWhere('correcting')
         if (message) {
         if (message) {
             const older_versions = message.get('older_versions') || {};
             const older_versions = message.get('older_versions') || {};
-            older_versions[message.get('time')] = message.get('message');
+            older_versions[message.get('time')] = message.getMessageText();
+            const plaintext = attrs.is_encrypted ? attrs.message : undefined;
+
             message.save({
             message.save({
+                'body': attrs.body,
+                'message': attrs.body,
                 'correcting': false,
                 'correcting': false,
                 'edited': (new Date()).toISOString(),
                 'edited': (new Date()).toISOString(),
-                'message': attrs.message,
-                'older_versions': older_versions,
-                'references': attrs.references,
                 'is_only_emojis':  attrs.is_only_emojis,
                 'is_only_emojis':  attrs.is_only_emojis,
                 'origin_id': u.getUniqueId(),
                 'origin_id': u.getUniqueId(),
-                'received': undefined
+                'received': undefined,
+                'references': attrs.references,
+                older_versions,
+                plaintext,
             });
             });
         } else {
         } else {
             this.setEditable(attrs, (new Date()).toISOString());
             this.setEditable(attrs, (new Date()).toISOString());
             message = await this.createMessage(attrs);
             message = await this.createMessage(attrs);
         }
         }
-        api.send(this.createMessageStanza(message));
+
+        try {
+            const stanza = await this.createMessageStanza(message);
+            api.send(stanza);
+        } catch (e) {
+            message.destroy();
+            log.error(e);
+            return;
+        }
 
 
        /**
        /**
         * Triggered when a message is being sent out
         * Triggered when a message is being sent out
@@ -1026,6 +1071,10 @@ const ChatBox = ModelWithContact.extend({
              * *Hook* which allows plugins to transform files before they'll be
              * *Hook* which allows plugins to transform files before they'll be
              * uploaded. The main use-case is to encrypt the files.
              * uploaded. The main use-case is to encrypt the files.
              * @event _converse#beforeFileUpload
              * @event _converse#beforeFileUpload
+             * @param { _converse.ChatBox | _converse.ChatRoom } chat
+             *      The chat from which this file will be uploaded.
+             * @param { File } file
+             *      The file that will be uploaded
              */
              */
             file = await api.hook('beforeFileUpload', this, file);
             file = await api.hook('beforeFileUpload', this, file);
 
 
@@ -1037,8 +1086,8 @@ const ChatBox = ModelWithContact.extend({
                     'is_ephemeral': true
                     'is_ephemeral': true
                 });
                 });
             } else {
             } else {
-                const attrs = Object.assign(
-                    this.getOutgoingMessageAttributes(), {
+                const initial_attrs = await this.getOutgoingMessageAttributes();
+                const attrs = Object.assign(initial_attrs, {
                     'file': true,
                     'file': true,
                     'progress': 0,
                     'progress': 0,
                     'slot_request_url': slot_request_url
                     'slot_request_url': slot_request_url

+ 10 - 2
src/headless/plugins/muc/muc.js

@@ -989,7 +989,7 @@ const ChatRoomMixin = {
         return [updated_message, updated_references];
         return [updated_message, updated_references];
     },
     },
 
 
-    getOutgoingMessageAttributes (attrs) {
+    async getOutgoingMessageAttributes (attrs) {
         const is_spoiler = this.get('composing_spoiler');
         const is_spoiler = this.get('composing_spoiler');
         let text = '', references;
         let text = '', references;
         if (attrs?.body) {
         if (attrs?.body) {
@@ -997,7 +997,7 @@ const ChatRoomMixin = {
         }
         }
         const origin_id = getUniqueId();
         const origin_id = getUniqueId();
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
         const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-        return Object.assign({}, attrs, {
+        attrs = Object.assign({}, attrs, {
             body,
             body,
             is_spoiler,
             is_spoiler,
             origin_id,
             origin_id,
@@ -1012,6 +1012,14 @@ const ChatRoomMixin = {
             'sender': 'me',
             'sender': 'me',
             'type': 'groupchat'
             'type': 'groupchat'
         }, getMediaURLsMetadata(text));
         }, getMediaURLsMetadata(text));
+
+        /**
+         * *Hook* which allows plugins to update the attributes of an outgoing
+         * message.
+         * @event _converse#getOutgoingMessageAttributes
+         */
+        attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
+        return attrs;
     },
     },
 
 
     /**
     /**

+ 8 - 6
src/headless/utils/core.js

@@ -103,18 +103,19 @@ u.getLongestSubstring = function (string, candidates) {
     return candidates.reduce(reducer, '');
     return candidates.reduce(reducer, '');
 }
 }
 
 
-u.prefixMentions = function (message) {
-    /* Given a message object, return its text with @ chars
-     * inserted before the mentioned nicknames.
-     */
-    let text = message.get('message');
+/**
+ * Given a message object, return its text with @ chars
+ * inserted before the mentioned nicknames.
+ */
+export function prefixMentions (message) {
+    let text = message.getMessageText();
     (message.get('references') || [])
     (message.get('references') || [])
         .sort((a, b) => b.begin - a.begin)
         .sort((a, b) => b.begin - a.begin)
         .forEach(ref => {
         .forEach(ref => {
             text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
             text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
         });
         });
     return text;
     return text;
-};
+}
 
 
 u.isValidJID = function (jid) {
 u.isValidJID = function (jid) {
     if (typeof jid === 'string') {
     if (typeof jid === 'string') {
@@ -587,6 +588,7 @@ export function decodeHTMLEntities (str) {
 }
 }
 
 
 export default Object.assign({
 export default Object.assign({
+    prefixMentions,
     isEmptyMessage,
     isEmptyMessage,
     getUniqueId
     getUniqueId
 }, u);
 }, u);

+ 4 - 3
src/plugins/chatview/message-form.js

@@ -1,8 +1,9 @@
 import tpl_message_form from './templates/message-form.js';
 import tpl_message_form from './templates/message-form.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
+import { _converse, api, converse } from "@converse/headless/core.js";
 import { parseMessageForCommands } from './utils.js';
 import { parseMessageForCommands } from './utils.js';
+import { prefixMentions } from '@converse/headless/utils/core.js';
 
 
 const { u } = converse.env;
 const { u } = converse.env;
 
 
@@ -86,11 +87,11 @@ export default class MessageForm extends ElementView {
 
 
     onMessageCorrecting (message) {
     onMessageCorrecting (message) {
         if (message.get('correcting')) {
         if (message.get('correcting')) {
-            this.insertIntoTextArea(u.prefixMentions(message), true, true);
+            this.insertIntoTextArea(prefixMentions(message), true, true);
         } else {
         } else {
             const currently_correcting = this.model.messages.findWhere('correcting');
             const currently_correcting = this.model.messages.findWhere('correcting');
             if (currently_correcting && currently_correcting !== message) {
             if (currently_correcting && currently_correcting !== message) {
-                this.insertIntoTextArea(u.prefixMentions(message), true, true);
+                this.insertIntoTextArea(prefixMentions(message), true, true);
             } else {
             } else {
                 this.insertIntoTextArea('', true, false);
                 this.insertIntoTextArea('', true, false);
             }
             }

+ 3 - 4
src/plugins/omemo/device.js

@@ -3,7 +3,7 @@ import { IQError } from './errors.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { UNDECIDED } from './consts.js';
 import { UNDECIDED } from './consts.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
-import { parseBundle } from './utils.js';
+import { parseBundle, handleMessageSendError } from './utils.js';
 
 
 const { Strophe, sizzle, u, $iq } = converse.env;
 const { Strophe, sizzle, u, $iq } = converse.env;
 
 
@@ -30,9 +30,8 @@ const Device = Model.extend({
             'type': 'get',
             'type': 'get',
             'from': _converse.bare_jid,
             'from': _converse.bare_jid,
             'to': this.get('jid')
             'to': this.get('jid')
-        })
-            .c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
-            .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
+        }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB })
+          .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` });
 
 
         let iq;
         let iq;
         try {
         try {

+ 15 - 6
src/plugins/omemo/index.js

@@ -6,7 +6,6 @@ import './fingerprints.js';
 import './profile.js';
 import './profile.js';
 import 'modals/user-details.js';
 import 'modals/user-details.js';
 import 'plugins/profile/index.js';
 import 'plugins/profile/index.js';
-import ChatBox from './overrides/chatbox.js';
 import ConverseMixins from './mixins/converse.js';
 import ConverseMixins from './mixins/converse.js';
 import Device from './device.js';
 import Device from './device.js';
 import DeviceList from './devicelist.js';
 import DeviceList from './devicelist.js';
@@ -15,19 +14,21 @@ import Devices from './devices.js';
 import OMEMOStore from './store.js';
 import OMEMOStore from './store.js';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
 import omemo_api from './api.js';
 import omemo_api from './api.js';
-import { OMEMOEnabledChatBox } from './mixins/chatbox.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
 import {
 import {
+    createOMEMOMessageStanza,
     encryptFile,
     encryptFile,
     getOMEMOToolbarButton,
     getOMEMOToolbarButton,
+    getOutgoingMessageAttributes,
     handleEncryptedFiles,
     handleEncryptedFiles,
+    handleMessageSendError,
     initOMEMO,
     initOMEMO,
     omemo,
     omemo,
     onChatBoxesInitialized,
     onChatBoxesInitialized,
     onChatInitialized,
     onChatInitialized,
     parseEncryptedMessage,
     parseEncryptedMessage,
-    setEncryptedFileURL,
     registerPEPPushHandler,
     registerPEPPushHandler,
+    setEncryptedFileURL,
 } from './utils.js';
 } from './utils.js';
 
 
 const { Strophe } = converse.env;
 const { Strophe } = converse.env;
@@ -52,15 +53,12 @@ converse.plugins.add('converse-omemo', {
 
 
     dependencies: ['converse-chatview', 'converse-pubsub'],
     dependencies: ['converse-chatview', 'converse-pubsub'],
 
 
-    overrides: { ChatBox },
-
     initialize () {
     initialize () {
         api.settings.extend({ 'omemo_default': false });
         api.settings.extend({ 'omemo_default': false });
         api.promises.add(['OMEMOInitialized']);
         api.promises.add(['OMEMOInitialized']);
 
 
         _converse.NUM_PREKEYS = 100; // Set here so that tests can override
         _converse.NUM_PREKEYS = 100; // Set here so that tests can override
 
 
-        Object.assign(_converse.ChatBox.prototype, OMEMOEnabledChatBox);
         Object.assign(_converse, ConverseMixins);
         Object.assign(_converse, ConverseMixins);
         Object.assign(_converse.api, omemo_api);
         Object.assign(_converse.api, omemo_api);
 
 
@@ -73,6 +71,17 @@ converse.plugins.add('converse-omemo', {
         /******************** Event Handlers ********************/
         /******************** Event Handlers ********************/
         api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
         api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized);
 
 
+        api.listen.on('getOutgoingMessageAttributes', getOutgoingMessageAttributes);
+
+        api.listen.on('createMessageStanza', async (chat, data) => {
+            try {
+                data = await createOMEMOMessageStanza(chat, data);
+            } catch (e) {
+                handleMessageSendError(e, chat);
+            }
+            return data;
+        });
+
         api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs);
         api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs);
         api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file);
         api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file);
 
 

+ 0 - 55
src/plugins/omemo/mixins/chatbox.js

@@ -1,55 +0,0 @@
-import log from '@converse/headless/log';
-import { __ } from 'i18n';
-import { api, converse } from '@converse/headless/core';
-import { getSessionCipher } from '../utils.js';
-
-const { Strophe, sizzle } = converse.env;
-
-/**
- * Mixin object that contains OMEMO-related methods for
- * {@link _converse.ChatBox} or {@link _converse.ChatRoom} objects.
- *
- * @typedef {Object} OMEMOEnabledChatBox
- */
-export const OMEMOEnabledChatBox = {
-    encryptKey (plaintext, device) {
-        return getSessionCipher(device.get('jid'), device.get('id'))
-            .encrypt(plaintext)
-            .then(payload => ({ 'payload': payload, 'device': device }));
-    },
-
-    handleMessageSendError (e) {
-        if (e.name === 'IQError') {
-            this.save('omemo_supported', false);
-
-            const err_msgs = [];
-            if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
-                err_msgs.push(
-                    __(
-                        "Sorry, we're unable to send an encrypted message because %1$s " +
-                            'requires you to be subscribed to their presence in order to see their OMEMO information',
-                        e.iq.getAttribute('from')
-                    )
-                );
-            } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
-                err_msgs.push(
-                    __(
-                        "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
-                        e.iq.getAttribute('from')
-                    )
-                );
-            } else {
-                err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
-                err_msgs.push(e.iq.outerHTML);
-            }
-            api.alert('error', __('Error'), err_msgs);
-            log.error(e);
-        } else if (e.user_facing) {
-            api.alert('error', __('Error'), [e.message]);
-            log.error(e);
-        } else {
-            throw e;
-        }
-    }
-};
-

+ 0 - 28
src/plugins/omemo/overrides/chatbox.js

@@ -1,28 +0,0 @@
-import { _converse } from '@converse/headless/core';
-import { createOMEMOMessageStanza, getBundlesAndBuildSessions } from '../utils.js';
-
-const ChatBox = {
-    async sendMessage (attrs) {
-        if (this.get('omemo_active') && attrs?.body) {
-            const plaintext = attrs?.body;
-            attrs = this.getOutgoingMessageAttributes(attrs);
-            attrs['is_encrypted'] = true;
-            attrs['plaintext'] = plaintext;
-            let message, stanza;
-            try {
-                const devices = await getBundlesAndBuildSessions(this);
-                message = await this.createMessage(attrs);
-                stanza = await createOMEMOMessageStanza(this, message, devices);
-            } catch (e) {
-                this.handleMessageSendError(e);
-                return null;
-            }
-            _converse.api.send(stanza);
-            return message;
-        } else {
-            return this.__super__.sendMessage.apply(this, arguments);
-        }
-    }
-}
-
-export default ChatBox;

+ 344 - 0
src/plugins/omemo/tests/corrections.js

@@ -0,0 +1,344 @@
+/*global mock, converse */
+
+const { Strophe, $iq, $pres, u } = converse.env;
+
+describe("An OMEMO encrypted message", function() {
+
+    it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.initializedOMEMO(_converse);
+        await mock.openChatBoxFor(_converse, contact_jid);
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        let stanza = $iq({
+                'from': contact_jid,
+                'id': iq_stanza.getAttribute('id'),
+                'to': _converse.connection.jid,
+                'type': 'result',
+            }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+                .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+                    .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+                        .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+                            .c('device', {'id': '555'});
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.omemo_store);
+        const devicelist = _converse.devicelists.get({'jid': contact_jid});
+        await u.waitUntil(() => devicelist.devices.length === 1);
+
+        const view = _converse.chatboxviews.get(contact_jid);
+        view.model.set('omemo_active', true);
+
+        const textarea = view.querySelector('.chat-textarea');
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
+        stanza = $iq({
+            'from': contact_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+                        .c('signedPreKeySignature').t(btoa('2222')).up()
+                        .c('identityKey').t(btoa('3333')).up()
+                        .c('prekeys')
+                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'));
+        stanza = $iq({
+            'from': _converse.bare_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+                        .c('signedPreKeySignature').t(btoa('200000')).up()
+                        .c('identityKey').t(btoa('300000')).up()
+                        .c('prekeys')
+                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+        expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.querySelector('.chat-msg__text').textContent)
+            .toBe('But soft, what light through yonder airlock breaks?');
+
+        await u.waitUntil(() => textarea.value === '');
+
+        message_form.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('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?'});
+
+        const new_text = 'But soft, what light through yonder window breaks?';
+        textarea.value = new_text;
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text);
+
+        await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 3);
+        const msg = _converse.connection.sent_stanzas.pop();
+        const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+
+        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>${fallback_text}</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"/>`+
+                    `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+                        `<header sid="123456789">`+
+                            `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+                            `<key rid="555">YzFwaDNSNzNYNw==</key>`+
+                            `<iv>${msg.querySelector('header iv').textContent}</iv>`+
+                        `</header>`+
+                        `<payload>${msg.querySelector('payload').textContent}</payload>`+
+                    `</encrypted>`+
+                    `<store xmlns="urn:xmpp:hints"/>`+
+                    `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+            `</message>`);
+
+        const older_versions = first_msg.get('older_versions');
+        const keys = Object.keys(older_versions);
+        expect(keys.length).toBe(1);
+        expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+        expect(first_msg.get('plaintext')).toBe(new_text);
+        expect(first_msg.get('is_encrypted')).toBe(true);
+        expect(first_msg.get('body')).toBe(fallback_text);
+        expect(first_msg.get('message')).toBe(fallback_text);
+
+        message_form.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+    }));
+});
+
+describe("An OMEMO encrypted MUC message", function() {
+
+    it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+        // MEMO encryption works only in members only conferences
+        // that are non-anonymous.
+        const features = [
+            'http://jabber.org/protocol/muc',
+            'jabber:iq:register',
+            'muc_passwordprotected',
+            'muc_hidden',
+            'muc_temporary',
+            'muc_membersonly',
+            'muc_unmoderated',
+            'muc_nonanonymous'
+        ];
+        const nick = 'romeo';
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
+        await u.waitUntil(() => mock.initializedOMEMO(_converse));
+
+        const view = _converse.chatboxviews.get(muc_jid);
+        const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+        const omemo_toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo'));
+        omemo_toggle.click();
+        expect(view.model.get('omemo_active')).toBe(true);
+
+        // newguy enters the room
+        const contact_jid = 'newguy@montague.lit';
+        let stanza = $pres({
+                'to': 'romeo@montague.lit/orchard',
+                'from': 'lounge@montague.lit/newguy'
+            })
+            .c('x', {xmlns: Strophe.NS.MUC_USER})
+            .c('item', {
+                'affiliation': 'none',
+                'jid': 'newguy@montague.lit/_converse.js-290929789',
+                'role': 'participant'
+            }).tree();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        // Wait for Converse to fetch newguy's device list
+        let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        expect(Strophe.serialize(iq_stanza)).toBe(
+            `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+
+                `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+
+                    `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+
+                `</pubsub>`+
+            `</iq>`);
+
+        // The server returns his device list
+        stanza = $iq({
+            'from': contact_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"})
+            .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"})
+                .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute
+                    .c('list', {'xmlns': "eu.siacs.conversations.axolotl"})
+                        .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up()
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.omemo_store);
+        expect(_converse.devicelists.length).toBe(2);
+
+        await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
+        const devicelist = _converse.devicelists.get(contact_jid);
+        expect(devicelist.devices.length).toBe(1);
+        expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228');
+        expect(view.model.get('omemo_active')).toBe(true);
+
+        const original_text = 'This message will be encrypted';
+        const textarea = view.querySelector('.chat-textarea');
+        textarea.value = original_text;
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000);
+        stanza = $iq({
+            'from': contact_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up()
+                        .c('signedPreKeySignature').t(btoa('2222')).up()
+                        .c('identityKey').t(btoa('3333')).up()
+                        .c('prekeys')
+                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up()
+                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up()
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000);
+        stanza = $iq({
+            'from': _converse.bare_jid,
+            'id': iq_stanza.getAttribute('id'),
+            'to': _converse.bare_jid,
+            'type': 'result',
+        }).c('pubsub', {
+            'xmlns': 'http://jabber.org/protocol/pubsub'
+            }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"})
+                .c('item')
+                    .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'})
+                        .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up()
+                        .c('signedPreKeySignature').t(btoa('200000')).up()
+                        .c('identityKey').t(btoa('300000')).up()
+                        .c('prekeys')
+                            .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up()
+                            .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up()
+                            .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993'));
+
+        spyOn(_converse.connection, 'send').and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.connection.send.calls.count(), 1000);
+        const sent_stanza = _converse.connection.send.calls.all()[0].args[0];
+
+        expect(Strophe.serialize(sent_stanza)).toBe(
+            `<message from="romeo@montague.lit/orchard" `+
+                     `id="${sent_stanza.getAttribute("id")}" `+
+                     `to="lounge@montague.lit" `+
+                     `type="groupchat" `+
+                     `xmlns="jabber:client">`+
+                `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+                `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+                    `<header sid="123456789">`+
+                        `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+                        `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
+                        `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+
+                    `</header>`+
+                    `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+
+                `</encrypted>`+
+                `<store xmlns="urn:xmpp:hints"/>`+
+                `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+            `</message>`);
+
+        await u.waitUntil(() => textarea.value === '');
+
+        const first_msg = view.model.messages.findWhere({'message': original_text});
+
+        message_form.onKeyDown({
+            target: textarea,
+            keyCode: 38 // Up arrow
+        });
+        expect(textarea.value).toBe(original_text);
+        expect(view.model.messages.at(0).get('correcting')).toBe(true);
+
+        const new_text = 'This is an edit of the encrypted message';
+        textarea.value = new_text;
+        message_form.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text);
+
+        const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo';
+        const older_versions = first_msg.get('older_versions');
+        const keys = Object.keys(older_versions);
+        expect(keys.length).toBe(1);
+        expect(older_versions[keys[0]]).toBe(original_text);
+        expect(first_msg.get('plaintext')).toBe(new_text);
+        expect(first_msg.get('is_encrypted')).toBe(true);
+        expect(first_msg.get('body')).toBe(fallback_text);
+        expect(first_msg.get('message')).toBe(fallback_text);
+
+        await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+        const msg = _converse.connection.sent_stanzas.pop();
+
+        expect(Strophe.serialize(msg))
+            .toBe(`<message from="${_converse.jid}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+                    `<body>${fallback_text}</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<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"/>`+
+                    `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
+                        `<header sid="123456789">`+
+                            `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
+                            `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+
+                            `<iv>${msg.querySelector("iv").textContent}</iv>`+
+                        `</header>`+
+                        `<payload>${msg.querySelector("payload").textContent}</payload>`+
+                    `</encrypted>`+
+                    `<store xmlns="urn:xmpp:hints"/>`+
+                    `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+
+                `</message>`);
+    }));
+
+});

+ 2 - 0
src/plugins/omemo/tests/media-sharing.js

@@ -129,7 +129,9 @@ describe("The OMEMO module", function() {
                 `type="chat" `+
                 `type="chat" `+
                 `xmlns="jabber:client">`+
                 `xmlns="jabber:client">`+
                     `<body>${fallback}</body>`+
                     `<body>${fallback}</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                     `<request xmlns="urn:xmpp:receipts"/>`+
                     `<request xmlns="urn:xmpp:receipts"/>`+
+                    `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
                     `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                     `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                     `<header sid="123456789">`+
                     `<header sid="123456789">`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+

+ 2 - 0
src/plugins/omemo/tests/muc.js

@@ -139,6 +139,8 @@ describe("The OMEMO module", function() {
                      `type="groupchat" `+
                      `type="groupchat" `+
                      `xmlns="jabber:client">`+
                      `xmlns="jabber:client">`+
                 `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
                 `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
                 `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                 `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                     `<header sid="123456789">`+
                     `<header sid="123456789">`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+

+ 2 - 0
src/plugins/omemo/tests/omemo.js

@@ -96,7 +96,9 @@ describe("The OMEMO module", function() {
                         `to="mercutio@montague.lit" `+
                         `to="mercutio@montague.lit" `+
                         `type="chat" xmlns="jabber:client">`+
                         `type="chat" xmlns="jabber:client">`+
                 `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
                 `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+
+                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                 `<request xmlns="urn:xmpp:receipts"/>`+
                 `<request xmlns="urn:xmpp:receipts"/>`+
+                `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
                 `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                 `<encrypted xmlns="eu.siacs.conversations.axolotl">`+
                     `<header sid="123456789">`+
                     `<header sid="123456789">`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+
                         `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+

+ 76 - 40
src/plugins/omemo/utils.js

@@ -24,7 +24,7 @@ import {
     stringToArrayBuffer
     stringToArrayBuffer
 } from '@converse/headless/utils/arraybuffer.js';
 } from '@converse/headless/utils/arraybuffer.js';
 
 
-const { $msg, Strophe, URI, sizzle, u } = converse.env;
+const { Strophe, URI, sizzle, u } = converse.env;
 
 
 export function formatFingerprint (fp) {
 export function formatFingerprint (fp) {
     fp = fp.replace(/^05/, '');
     fp = fp.replace(/^05/, '');
@@ -35,6 +35,49 @@ export function formatFingerprint (fp) {
     return fp;
     return fp;
 }
 }
 
 
+export function handleMessageSendError (e, chat) {
+    if (e.name === 'IQError') {
+        chat.save('omemo_supported', false);
+
+        const err_msgs = [];
+        if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) {
+            err_msgs.push(
+                __(
+                    "Sorry, we're unable to send an encrypted message because %1$s " +
+                        'requires you to be subscribed to their presence in order to see their OMEMO information',
+                    e.iq.getAttribute('from')
+                )
+            );
+        } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) {
+            err_msgs.push(
+                __(
+                    "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found",
+                    e.iq.getAttribute('from')
+                )
+            );
+        } else {
+            err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.'));
+            err_msgs.push(e.iq.outerHTML);
+        }
+        api.alert('error', __('Error'), err_msgs);
+    } else if (e.user_facing) {
+        api.alert('error', __('Error'), [e.message]);
+    }
+    throw e;
+}
+
+export function getOutgoingMessageAttributes (chat, attrs) {
+    if (chat.get('omemo_active') && attrs.body) {
+        attrs['is_encrypted'] = true;
+        attrs['plaintext'] = attrs.body;
+        attrs['body'] = __(
+            'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
+            'Find more information on https://conversations.im/omemo'
+        );
+    }
+    return attrs;
+}
+
 async function encryptMessage (plaintext) {
 async function encryptMessage (plaintext) {
     // The client MUST use fresh, randomly generated key/IV pairs
     // The client MUST use fresh, randomly generated key/IV pairs
     // with AES-128 in Galois/Counter Mode (GCM).
     // with AES-128 in Galois/Counter Mode (GCM).
@@ -730,7 +773,7 @@ export function getOMEMOToolbarButton (toolbar_el, buttons) {
 }
 }
 
 
 
 
-export async function getBundlesAndBuildSessions (chatbox) {
+async function getBundlesAndBuildSessions (chatbox) {
     const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
     const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.');
     let devices;
     let devices;
     if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
     if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
@@ -767,56 +810,49 @@ export async function getBundlesAndBuildSessions (chatbox) {
     return devices;
     return devices;
 }
 }
 
 
+function encryptKey (key_and_tag, device) {
+    return getSessionCipher(device.get('jid'), device.get('id'))
+        .encrypt(key_and_tag)
+        .then(payload => ({ 'payload': payload, 'device': device }));
+}
 
 
-export function createOMEMOMessageStanza (chatbox, message, devices) {
-    const body = __(
-        'This is an OMEMO encrypted message which your client doesn’t seem to support. ' +
-            'Find more information on https://conversations.im/omemo'
-    );
-
-    if (!message.get('message')) {
+export async function createOMEMOMessageStanza (chat, data) {
+    let { stanza } = data;
+    const { message } = data;
+    if (!message.get('is_encrypted')) {
+        return data;
+    }
+    if (!message.get('body')) {
         throw new Error('No message body to encrypt!');
         throw new Error('No message body to encrypt!');
     }
     }
-    const stanza = $msg({
-        'from': _converse.connection.jid,
-        'to': chatbox.get('jid'),
-        'type': chatbox.get('message_type'),
-        'id': message.get('msgid')
-    }).c('body').t(body).up();
+    const devices = await getBundlesAndBuildSessions(chat);
 
 
-    if (message.get('type') === 'chat') {
-        stanza.c('request', { 'xmlns': Strophe.NS.RECEIPTS }).up();
-    }
     // An encrypted header is added to the message for
     // An encrypted header is added to the message for
     // each device that is supposed to receive it.
     // each device that is supposed to receive it.
     // These headers simply contain the key that the
     // These headers simply contain the key that the
     // payload message is encrypted with,
     // payload message is encrypted with,
     // and they are separately encrypted using the
     // and they are separately encrypted using the
     // session corresponding to the counterpart device.
     // session corresponding to the counterpart device.
-    stanza
-        .c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
+    stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO })
         .c('header', { 'sid': _converse.omemo_store.get('device_id') });
         .c('header', { 'sid': _converse.omemo_store.get('device_id') });
 
 
-    return omemo.encryptMessage(message.get('message')).then(obj => {
-        // The 16 bytes key and the GCM authentication tag (The tag
-        // SHOULD have at least 128 bit) are concatenated and for each
-        // intended recipient device, i.e. both own devices as well as
-        // devices associated with the contact, the result of this
-        // concatenation is encrypted using the corresponding
-        // long-standing SignalProtocol session.
-        const promises = devices
-            .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
-            .map(device => chatbox.encryptKey(obj.key_and_tag, device));
-
-        return Promise.all(promises)
-            .then(dicts => addKeysToMessageStanza(stanza, dicts, obj.iv))
-            .then(stanza => {
-                stanza.c('payload').t(obj.payload).up().up();
-                stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up();
-                stanza.c('encryption', { 'xmlns': Strophe.NS.EME,  namespace: Strophe.NS.OMEMO });
-                return stanza;
-            });
-    });
+    const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext'));
+
+    // The 16 bytes key and the GCM authentication tag (The tag
+    // SHOULD have at least 128 bit) are concatenated and for each
+    // intended recipient device, i.e. both own devices as well as
+    // devices associated with the contact, the result of this
+    // concatenation is encrypted using the corresponding
+    // long-standing SignalProtocol session.
+    const dicts = await Promise.all(devices
+        .filter(device => device.get('trusted') != UNTRUSTED && device.get('active'))
+        .map(device => encryptKey(key_and_tag, device)));
+
+    stanza = await addKeysToMessageStanza(stanza, dicts, iv);
+    stanza.c('payload').t(payload).up().up();
+    stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up();
+    stanza.c('encryption', { 'xmlns': Strophe.NS.EME,  namespace: Strophe.NS.OMEMO });
+    return { message, stanza };
 }
 }
 
 
 export const omemo = {
 export const omemo = {

+ 1 - 0
src/shared/chat/templates/message-text.js

@@ -22,6 +22,7 @@ export default (el) => {
             </a>
             </a>
         </div>
         </div>
     `;
     `;
+
     const spoiler_classes = el.model.get('is_spoiler') ? `spoiler ${el.model.get('is_spoiler_visible') ? '' : 'hidden'}` : '';
     const spoiler_classes = el.model.get('is_spoiler') ? `spoiler ${el.model.get('is_spoiler_visible') ? '' : 'hidden'}` : '';
     const text = el.model.getMessageText();
     const text = el.model.getMessageText();
     const show_oob = el.model.get('oob_url') && text !== el.model.get('oob_url');
     const show_oob = el.model.get('oob_url') && text !== el.model.get('oob_url');

+ 3 - 3
src/shared/components/message-versions.js

@@ -15,13 +15,13 @@ export class MessageVersions extends CustomElement {
 
 
     render () {
     render () {
         const older_versions = this.model.get('older_versions');
         const older_versions = this.model.get('older_versions');
-        const message = this.model.get('message');
         return html`
         return html`
             <h4>Older versions</h4>
             <h4>Older versions</h4>
-            ${Object.keys(older_versions).map(k => html`<p class="older-msg"><time>${dayjs(k).format('MMM D, YYYY, HH:mm:ss')}</time>: ${older_versions[k]}</p>`) }
+            ${ Object.keys(older_versions).map(
+                k => html`<p class="older-msg"><time>${dayjs(k).format('MMM D, YYYY, HH:mm:ss')}</time>: ${older_versions[k]}</p>`) }
             <hr/>
             <hr/>
             <h4>Current version</h4>
             <h4>Current version</h4>
-            <p>${message}</p>`;
+            <p>${this.model.getMessageText()}</p>`;
     }
     }
 }
 }