diff --git a/CHANGES.md b/CHANGES.md
index 9ea7adeafa..f7fe6d1517 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,6 +2,7 @@
## 6.0.0 (Unreleased)
+- add message-forward (XEP-0297) capabilities
- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage
- #1089: When filtering the roster for `online` users, show all non-offline users.
diff --git a/locale/converse.pot b/locale/converse.pot
index c631ca8f27..0b5ef912d6 100644
--- a/locale/converse.pot
+++ b/locale/converse.pot
@@ -1919,3 +1919,33 @@ msgstr ""
#: dist/converse-no-dependencies.js:55061
msgid "Re-sync your contacts"
msgstr ""
+
+msgid "You can only send a message to an existing contact or an opened room."
+msgstr ""
+
+msgid "forward this message"
+msgstr ""
+
+msgid "Destination:"
+msgstr ""
+
+msgid "Additional Message:"
+msgstr ""
+
+msgid "Original-Text"
+msgstr ""
+
+msgid "Optional: Add additional message here..."
+msgstr ""
+
+msgid "forward"
+msgstr ""
+
+msgid "Forwarded Message:"
+msgstr ""
+
+msgid "original author:"
+msgstr ""
+
+msgid "time:"
+msgstr ""
diff --git a/locale/de/LC_MESSAGES/converse.po b/locale/de/LC_MESSAGES/converse.po
index f74ec5c75b..1bded13b18 100644
--- a/locale/de/LC_MESSAGES/converse.po
+++ b/locale/de/LC_MESSAGES/converse.po
@@ -2493,3 +2493,33 @@ msgstr "Resynchronisieren Sie Ihre Kontakte"
#~ msgid "Contact username"
#~ msgstr "Benutzername"
+
+msgid "You can only send a message to an existing contact or an opened room."
+msgstr "Sie können eine Nachricht nur an einen existieren Kontakt oder offenen Chatraum senden."
+
+msgid "forward this message"
+msgstr "Nachricht weiterleiten"
+
+msgid "Destination:"
+msgstr "Empfänger:"
+
+msgid "Additional Message:"
+msgstr "Zusätzliche Nachricht"
+
+msgid "Original-Text"
+msgstr "Ursprüngliche Nachricht"
+
+msgid "Optional: Add additional message here..."
+msgstr "Optional: Geben Sie hier eine zusätzliche Nachricht ein..."
+
+msgid "forward"
+msgstr "weiterleiten"
+
+msgid "Forwarded Message:"
+msgstr "Weitergeleitete Nachricht:"
+
+msgid "original author:"
+msgstr "Ursprünglicher Autor:"
+
+msgid "time:"
+msgstr "Zeit:"
\ No newline at end of file
diff --git a/sass/_autocomplete.scss b/sass/_autocomplete.scss
index 4e90e5ab17..6da6216825 100644
--- a/sass/_autocomplete.scss
+++ b/sass/_autocomplete.scss
@@ -10,6 +10,14 @@
.suggestion-box {
width: 100%;
}
+ .forward-message {
+ overflow: visible;
+ border: black;
+ height: 100px;
+ border-radius: 5px;
+ background-color: lightgrey;
+ padding: 5px;
+ }
}
.suggestion-box {
diff --git a/sass/_messages.scss b/sass/_messages.scss
index 1038f66bdc..da8bae9424 100644
--- a/sass/_messages.scss
+++ b/sass/_messages.scss
@@ -217,8 +217,9 @@
width: 100%;
}
}
-
+
.chat-msg__actions {
+ width: 50px;
.chat-msg__action {
height: var(--message-font-size);
font-size: var(--message-font-size);
@@ -227,10 +228,11 @@
border: none;
opacity: 0;
background: transparent;
+ width: 10px;
cursor: pointer;
- &:focus {
- display: block;
- }
+ display: block;
+ margin: 0px 0px 0px 10px;
+ float: right;
}
}
@@ -310,6 +312,24 @@
margin-right: 0.5em;
color: var(--message-receipt-color);
}
+
+ .forwarded-message {
+ white-space: normal;
+ background-color: lightblue;
+ border-radius: 5px;
+ padding: 5px;
+ margin-left: 5px;
+ }
+
+ .forwarded-message__content {
+ background-color: white;
+ border-radius: 5px;
+ padding-left: 5px;
+ }
+
+ .forwarded-message__header {
+ font-size: 11px;
+ }
}
}
diff --git a/spec/controlbox.js b/spec/controlbox.js
index 5bd3b1b676..fff92bc15e 100644
--- a/spec/controlbox.js
+++ b/spec/controlbox.js
@@ -145,7 +145,7 @@
``+
`dnd`+
`0`+
- ``+
+ ``+
``);
const first_child = view.el.querySelector('.xmpp-status span:first-child');
expect(u.hasClass('online', first_child)).toBe(false);
@@ -174,7 +174,7 @@
``+
`I am happy`+
`0`+
- ``+
+ ``+
``);
const first_child = view.el.querySelector('.xmpp-status span:first-child');
diff --git a/spec/messages.js b/spec/messages.js
index e2c9f79611..d208183444 100644
--- a/spec/messages.js
+++ b/spec/messages.js
@@ -17,6 +17,7 @@
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
+ _converse.whitelisted_plugins = _converse.whitelisted_plugins.filter(e => e !== 'converse-forward-message');
await test_utils.waitForRoster(_converse, 'current', 2);
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
@@ -83,7 +84,7 @@
expect(textarea.value).toBe('');
const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
- expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
+ expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
let action = view.el.querySelector('.chat-msg .chat-msg__action');
expect(action.getAttribute('title')).toBe('Edit this message');
@@ -160,7 +161,7 @@
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
);
await new Promise(resolve => view.once('messageInserted', resolve));
- expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
+ expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(3);
// Test confirmation dialog
spyOn(window, 'confirm').and.returnValue(true);
diff --git a/spec/presence.js b/spec/presence.js
index a8c455d402..6d6f872969 100644
--- a/spec/presence.js
+++ b/spec/presence.js
@@ -42,7 +42,7 @@
``+
`Hello world`+
`0`+
- ``+
+ ``+
``
);
_converse.priority = 2;
@@ -52,7 +52,7 @@
`away`+
`Going jogging`+
`2`+
- ``+
+ ``+
``
);
@@ -63,7 +63,7 @@
`dnd`+
`Doing taxes`+
`0`+
- ``+
+ ``+
``
);
done();
@@ -91,7 +91,7 @@
.toBe(``+
`My custom status`+
`0`+
- ``+
+ ``+
``)
await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
@@ -101,7 +101,7 @@
modal.el.querySelector('[type="submit"]').click();
expect(_converse.connection.send.calls.mostRecent().args[0].toLocaleString())
.toBe(`dndMy custom status0`+
- ``+
+ ``+
``)
done();
}));
diff --git a/src/converse-forward-message.js b/src/converse-forward-message.js
new file mode 100644
index 0000000000..40d94d7f83
--- /dev/null
+++ b/src/converse-forward-message.js
@@ -0,0 +1,503 @@
+// Converse.js
+// https://conversejs.org
+//
+// Copyright (c) 2013-2019, the Converse.js developers
+// Licensed under the Mozilla Public License (MPLv2)
+
+import "backbone.nativeview";
+import "converse-chatboxviews";
+import "converse-message-view";
+import "converse-modal";
+import converse from "@converse/headless/converse-core";
+import tpl_forward_message_modal from "templates/forward_message_modal.html";
+import tpl_forwarded_message_view from "templates/forwarded_message_view.html";
+
+const { $msg, dayjs, _, Strophe, utils, sizzle } = converse.env;
+const u = converse.env.utils;
+const URL_REGEX = /\b(https?:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
+
+converse.plugins.add('converse-forward-message', {
+ /* Plugin dependencies are other plugins which might be
+ * overridden or relied upon, and therefore need to be loaded before
+ * this plugin.
+ *
+ * If the setting "strict_plugin_dependencies" is set to true,
+ * an error will be raised if the plugin is not found. By default it's
+ * false, which means these plugins are only loaded opportunistically.
+ *
+ * NB: These plugins need to have already been loaded via require.js.
+ */
+ dependencies: ["converse-chatview", "converse-message-view", "converse-muc"],
+
+ overrides: {
+ MessageView: {
+ initialize () {
+ this.__super__.initialize.apply(this, arguments);
+ this.model.collection.on('rendered', this.renderForwardedMessage, this);
+ },
+ },
+
+ ChatBoxView: {
+ events: {
+ 'click .chat-msg__action-forward': 'onMessageForwardClicked',
+ },
+ },
+
+ ChatRoomView: {
+ events: {
+ 'click .chat-msg__action-forward': 'onMessageForwardClicked',
+ },
+ }
+ },
+
+ initialize () {
+ /* The initialize function gets called as soon as the plugin is
+ * loaded by converse.js's plugin machinery.
+ */
+ const { _converse } = this,
+ { __ } = _converse;
+
+ const clickForwardMessage = {
+ onMessageForwardClicked (ev) {
+ const { _converse } = this.__super__;
+ this.add_forward_modal = new _converse.AddForwardMessageModal(this.model, ev);
+ this.add_forward_modal.show(ev);
+ },
+ };
+ Object.assign(_converse.ChatBoxView.prototype, clickForwardMessage);
+
+ const renderForwardedMessages = {
+ renderForwardedMessage (message) {
+ this.renderForwardButton(message);
+
+ if (!message.model.get('is_forwarded_message') || message.el.querySelector('.forwarded-message__content')) {
+ return;
+ }
+
+ const forwarded_message_element = this.createForwardedMessageHtmlElement(message.model);
+ // add msg content as innerText to preserve line endings
+ const text_content = forwarded_message_element.querySelector('.forwarded-message__content');
+ text_content.innerText = message.model.get('original_message');
+
+ // do not use await for this function call because then the forwarded message will be displayed
+ // many times in the chat-history
+ this.renderImageIfPresent(forwarded_message_element);
+ const msg_content = message.el.querySelector('.chat-msg__text');
+ msg_content.insertAdjacentElement('beforeend', forwarded_message_element);
+ },
+
+ renderForwardButton (message) {
+ const element = message.el.querySelector('.chat-msg__actions');
+ if (!_.isNil(element) && !message.el.querySelector('.chat-msg__action-forward')) {
+ element.insertAdjacentHTML('beforeend', '');
+ }
+ },
+
+ createForwardedMessageHtmlElement (model) {
+ const time = dayjs(model.get('original_time'));
+ return utils.stringToElement(tpl_forwarded_message_view(
+ Object.assign(
+ model.toJSON(), {
+ '__': __,
+ 'original_form': model.get('original_from'),
+ 'original_to': model.get('orginal_to'),
+ 'original_time': time.format('DD.MM.YYYY hh:mm'),
+ 'original_type': model.get('original_type')
+ }
+ )
+ ));
+ },
+
+ async renderImageIfPresent (forwarded_message_element) {
+ const forwarded_message = forwarded_message_element.querySelector('.forwarded-message__content');
+ if (forwarded_message.textContent.match(URL_REGEX)) {
+ // order of the calls below is important
+ forwarded_message.innerHTML = await this.transformBodyText(forwarded_message.textContent);
+ forwarded_message.innerHTML = await this.transformOOBURL(forwarded_message.textContent);
+ await utils.renderImageURLs(_converse, forwarded_message_element);
+ }
+ }
+ };
+ Object.assign(_converse.MessageView.prototype, renderForwardedMessages);
+
+ _converse.AddForwardMessageModal = _converse.BootstrapModal.extend({
+ events: {
+ 'submit form.forward-message-form': 'forwardMessage'
+ },
+
+ initialize (chat_model, target_element) {
+ this.message = this.getMessageTextFromTargetElement(target_element, chat_model);
+ this.model = chat_model;
+ _converse.BootstrapModal.prototype.initialize.apply(this, chat_model);
+ },
+
+ getMessageTextFromTargetElement (target_element, chat_model) {
+ const message_action_menu_element = target_element.target.parentElement;
+ const message_body_element = message_action_menu_element.parentElement;
+ const message_element = message_body_element.parentElement.parentElement;
+ return chat_model.messages.findWhere({'msgid': message_element.getAttribute('data-msgid')});
+ },
+
+ toHTML () {
+ return tpl_forward_message_modal(Object.assign(this.model.toJSON(), {
+ '__': __
+ }));
+ },
+
+ afterRender () {
+ const text_element = this.el.querySelector('.forward-message');
+ text_element.innerText = this.message.get('message');
+
+ this.el.addEventListener('shown.bs.modal', () => {
+ this.el.querySelector('input[name="receiver"]').focus();
+ }, false);
+
+ this.addAutocomplete();
+ },
+
+ addAutocomplete () {
+ const contacts = _converse.roster.map(i => ({'label': i.getDisplayName(), 'value': i.get('jid')}));
+ let open_rooms;
+ if (_converse.rooms_list_view.model) {
+ open_rooms = _converse.rooms_list_view.model.map(i => ({'label': i.get('name'), 'value': i.get('jid')}));
+ }
+ const suggestion_list = contacts.concat(open_rooms);
+
+ if (this.invite_auto_complete) {
+ this.invite_auto_complete.destroy();
+ }
+ this.invite_auto_complete = new _converse.AutoComplete(this.el, {
+ 'min_chars': 1,
+ 'list': suggestion_list
+ });
+
+ // prevents suggestion-element to be displayed on load
+ const suggestion_element = this.el.querySelector('.suggestion-box__results');
+ suggestion_element.hidden = true;
+ },
+
+ forwardMessage (ev) {
+ ev.preventDefault();
+ const form_data = this.getJidFromModalForm(ev.target);
+
+ if (!this.isJidOpenMuc(form_data.receiver) && !this.isJidExistingContact(form_data.receiver)) {
+ alert(__("You can only send a message to an existing contact or an opened room."));
+ return;
+ }
+
+ this.modal.hide();
+ ev.target.reset();
+ this.send(form_data);
+ },
+
+ getJidFromModalForm (form) {
+ const data = new FormData(form);
+ const receiver = data.get('receiver');
+ const additional_message = data.get('additional_message');
+ const original_message = data.get('original_message');
+ return {
+ 'receiver': receiver,
+ 'additional_message': additional_message,
+ 'original_message': original_message
+ };
+ },
+
+ async isJidOpenMuc (jid) {
+ const rooms = await _converse.api.rooms.get();
+ const temp = rooms.find(room => {
+ return room.id === jid;
+ });
+ return temp !== undefined;
+ },
+
+ isJidExistingContact (jid) {
+ const contact = _converse.roster.models.find(function (model) {
+ return model.get('jid') === jid;
+ });
+ return contact !== undefined;
+ },
+
+ async send (form_data) {
+ const msg_id = _converse.connection.getUniqueId();
+ const chat_type = await this.getChatType(form_data);
+ const message = $msg({
+ 'from': _converse.connection.jid,
+ 'to': form_data.receiver,
+ 'type': chat_type,
+ 'id': msg_id
+ }).c('body').t(form_data.additional_message).up();
+
+ message.c('forwarded', {'xmlns': Strophe.NS.FORWARD})
+ .c('delay', {'xmlns': Strophe.NS.DELAY, 'stamp': this.message.get('time')}).up();
+
+ message.c('message', {
+ 'from': this.message.get('from'),
+ 'to': this.model.get('jid'),
+ 'id': this.message.get('id'),
+ 'type': this.message.get('type'),
+ 'xmlns': 'jabber:client'
+ }).c('body').t(this.message.get('message')).up().root();
+
+ message.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
+
+ _converse.api.send(message);
+ this.addForwardedMessageToChatHistory(form_data, msg_id);
+ },
+
+ async getChatType (form_data) {
+ const chat = await _converse.api.chats.get(form_data.receiver);
+ if (chat) {
+ return chat.get('message_type');
+ } else {
+ return "groupchat";
+ }
+ },
+
+ async addForwardedMessageToChatHistory (form_data, msg_id) {
+ if (!this.isJidExistingContact(form_data.receiver)) {
+ return;
+ }
+
+ const chat = await _converse.api.chats.get(form_data.receiver);
+ if (_.isNil(chat)) {
+ await _converse.api.chats.create(form_data.receiver, {'minimized': true});
+ chat.save({'num_unread': chat.get('num_unread') + 1});
+ _converse.incrementMsgCounter();
+ } else {
+ const chat_type = await this.getChatType(form_data);
+ const attrs = Object.assign({
+ 'is_archived': false,
+ 'is_delayed': false,
+ 'is_spoiler': false,
+ 'is_single_emoji': false,
+ 'message': form_data.additional_message,
+ 'msgid': msg_id,
+ 'type': chat_type,
+ 'is_forwarded_message': true,
+ 'original_time': this.message.get('time'),
+ 'original_id': this.message.get('id'),
+ 'original_message': this.message.get('message'),
+ 'original_from': this.message.get('from'),
+ 'original_to': this.message.get('to'),
+ 'original_type': this.message.get('type'),
+ });
+
+ attrs.from = _converse.bare_jid;
+ if (attrs.type === 'groupchat') {
+ attrs.nick = Strophe.unescapeNode(Strophe.getResourceFromJid(attrs.from));
+ attrs.sender = attrs.nick === this.model.get('nickname') ? 'me': 'them';
+ attrs.received = (new Date()).toISOString();
+ } else {
+ if (attrs.from === _converse.bare_jid) {
+ attrs.sender = 'me';
+ attrs.fullname = _converse.xmppstatus.get('fullname');
+ } else {
+ attrs.sender = 'them';
+ attrs.fullname = this.model.get('fullname')
+ }
+ }
+ const msg = chat.messages.create(attrs);
+ chat.incrementUnreadMsgCounter(msg);
+ }
+ }
+ });
+
+ function rejectMessage (stanza, text) {
+ // Reject an incoming message by replying with an error message of type "cancel".
+ _converse.api.send(
+ $msg({
+ 'to': stanza.getAttribute('from'),
+ 'type': 'error',
+ 'id': stanza.getAttribute('id')
+ }).c('error', {'type': 'cancel'})
+ .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up()
+ .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text)
+ );
+ _converse.log(`Rejecting message stanza with the following reason: ${text}`, Strophe.LogLevel.WARN);
+ _converse.log(stanza, Strophe.LogLevel.WARN);
+ }
+
+ // copy of the converse-muc onMessage-method with some changes to make it work
+ async function onMucMessage (stanza, forwarded_attrs) {
+ const room = await _converse.api.rooms.get(Strophe.getBareJidFromJid(stanza.getAttribute('from')));
+ const original_stanza = stanza;
+
+ const is_carbon = u.isCarbonMessage(stanza);
+ if (is_carbon) {
+ // XEP-280: groupchat messages SHOULD NOT be carbon copied, so we're discarding it.
+ return _converse.log(
+ 'onMessage: Ignoring XEP-0280 "groupchat" message carbon, '+
+ 'according to the XEP groupchat messages SHOULD NOT be carbon copied',
+ Strophe.LogLevel.WARN
+ );
+ }
+ const is_mam = u.isMAMMessage(stanza);
+ if (is_mam) {
+ if (original_stanza.getAttribute('from') === room.get('jid')) {
+ const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+ stanza = sizzle(selector, stanza).pop();
+ } else {
+ return _converse.log(
+ `onMessage: Ignoring alleged MAM groupchat message from ${stanza.getAttribute('from')}`,
+ Strophe.LogLevel.WARN
+ );
+ }
+ }
+
+ room.createInfoMessages(stanza);
+ room.fetchFeaturesIfConfigurationChanged(stanza);
+
+ const message = await room.getDuplicateMessage(original_stanza);
+ if (message) {
+ room.updateMessage(message, original_stanza);
+ return;
+ }
+ let attrs = await room.getMessageAttributesFromStanza(stanza, original_stanza);
+ attrs = Object.assign(attrs, forwarded_attrs);
+ room.setEditable(attrs, attrs.time);
+ if (attrs.nick &&
+ !room.subjectChangeHandled(attrs) &&
+ !room.ignorableCSN(attrs) &&
+ (attrs['chat_state'] || !u.isEmptyMessage(attrs))) {
+
+ const msg = room.correctMessage(attrs) ||
+ await new Promise((success, reject) => {
+ room.messages.create(
+ attrs,
+ { success, 'erorr': (m, e) => reject(e) }
+ )
+ });
+ room.incrementUnreadMsgCounter(msg);
+ }
+ _converse.api.trigger('message', {'stanza': original_stanza, 'chatbox': this});
+ }
+
+ // "copy" from "handleMessageStanza" and "onMessage" with some small changes to make it work
+ async function onChatMessage (stanza, forwarded_attrs) {
+ const original_stanza = stanza;
+ let to_jid = stanza.getAttribute('to');
+ const to_resource = Strophe.getResourceFromJid(to_jid);
+
+ if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
+ return _converse.log(
+ `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
+ Strophe.LogLevel.INFO
+ );
+ }
+
+ let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
+ if (u.isCarbonMessage(stanza)) {
+ if (from_jid === _converse.bare_jid) {
+ const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+ stanza = sizzle(selector, stanza).pop();
+ to_jid = stanza.getAttribute('to');
+ from_jid = stanza.getAttribute('from');
+ } else {
+ // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
+ return rejectMessage(stanza, 'Rejecting carbon from invalid JID');
+ }
+ }
+
+ if (u.isMAMMessage(stanza)) {
+ if (from_jid === _converse.bare_jid) {
+ const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+ stanza = sizzle(selector, stanza).pop();
+ to_jid = stanza.getAttribute('to');
+ from_jid = stanza.getAttribute('from');
+ } else {
+ return _converse.log(
+ `onMessage: Ignoring alleged MAM message from ${stanza.getAttribute('from')}`,
+ Strophe.LogLevel.WARN
+ );
+ }
+ }
+
+ const from_bare_jid = Strophe.getBareJidFromJid(from_jid);
+ const is_me = from_bare_jid === _converse.bare_jid;
+ if (is_me && to_jid === null) {
+ return _converse.log(
+ `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
+ Strophe.LogLevel.ERROR
+ );
+ }
+
+ const contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid;
+ const contact = await _converse.api.contacts.get(contact_jid);
+ if (contact === undefined && !_converse.allow_non_roster_messaging) {
+ _converse.log(
+ `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`,
+ Strophe.LogLevel.ERROR
+ );
+ return _converse.log(stanza, Strophe.LogLevel.ERROR);
+ }
+
+ // Get chat box, but only create when the message has something to show to the user
+ const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length > 0;
+ const roster_nick = contact.get('nickname');
+ const chatbox = await _converse.api.chats.get(contact_jid, {'nickname': roster_nick}, has_body);
+
+ const message = await chatbox.getDuplicateMessage(stanza);
+ if (message) {
+ chatbox.updateMessage(message, original_stanza);
+ } else {
+ if (
+ !chatbox.handleReceipt (stanza, from_jid) &&
+ !chatbox.handleChatMarker(stanza, from_jid)
+ ) {
+ let attrs = await chatbox.getMessageAttributesFromStanza(stanza, original_stanza);
+ chatbox.setEditable(attrs, attrs.time, stanza);
+ attrs = Object.assign(attrs, forwarded_attrs);
+ if (attrs['chat_state'] || !u.isEmptyMessage(attrs)) {
+ const msg = chatbox.correctMessage(attrs) || chatbox.messages.create(attrs);
+ chatbox.incrementUnreadMsgCounter(msg);
+ }
+ }
+ }
+ }
+
+ function getForwardedMessageAttributesFromStanza (stanza) {
+ const forwarded_message = sizzle(`forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).pop();
+ const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, forwarded_message).pop();
+ // read all the attribtues from the forwarded-message and add them to the attrs-variable
+ return Object.assign({
+ 'is_forwarded_message': true,
+ 'original_time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(),
+ 'original_id': forwarded_message.querySelector('message').getAttribute('id'),
+ 'original_message': forwarded_message.querySelector('body').innerHTML,
+ 'original_from': forwarded_message.querySelector('message').getAttribute('from'),
+ 'original_to': forwarded_message.querySelector('message').getAttribute('to'),
+ 'original_type': forwarded_message.querySelector('message').getAttribute('type')
+ });
+ }
+
+ function onForwardedMessage (stanza) {
+ const forwarded_attrs = getForwardedMessageAttributesFromStanza(stanza);
+
+ // remove forwarded message from stanza
+ // prevents reading attributes from the inner message if the attribute is missing in the outer message
+ const forwarded_message = sizzle(`forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).pop();
+ forwarded_message.parentElement.removeChild(forwarded_message);
+
+ const chat_type = stanza.getAttribute('type');
+ if (chat_type === 'groupchat'){
+ onMucMessage(stanza, forwarded_attrs);
+ } else {
+ onChatMessage(stanza, forwarded_attrs);
+ }
+ }
+
+ function registerMessageForwardingHandlerForChat () {
+ _converse.connection.addHandler(stanza => {
+ if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).pop()) {
+ // clone Node so changes to the original stanza and this stanza interfere with each other
+ onForwardedMessage(stanza.cloneNode(true));
+ }
+ return true;
+ }, null, 'message');
+ }
+ _converse.api.listen.on('connected', registerMessageForwardingHandlerForChat);
+ _converse.api.listen.on('reconnected', registerMessageForwardingHandlerForChat);
+
+ _converse.api.listen.on('addClientFeatures', () => _converse.api.disco.own.features.add(Strophe.NS.FORWARD));
+ }
+});
diff --git a/src/converse.js b/src/converse.js
index ce856485dd..5366d116d9 100644
--- a/src/converse.js
+++ b/src/converse.js
@@ -28,6 +28,7 @@ import "converse-push"; // XEP-0357 Push Notifications
import "converse-register"; // XEP-0077 In-band registration
import "converse-roomslist"; // Show currently open chat rooms
import "converse-rosterview";
+import "converse-forward-message"; // allows to redirect messages to other users or MUCs (XEP-0297)
import "converse-singleton";
import "converse-uniview";
/* END: Removable components */
@@ -59,6 +60,7 @@ const WHITELISTED_PLUGINS = [
'converse-register',
'converse-roomslist',
'converse-rosterview',
+ 'converse-forward-message',
'converse-singleton',
'converse-uniview'
];
diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js
index 54a18807a0..4f374ed42e 100644
--- a/src/headless/converse-chat.js
+++ b/src/headless/converse-chat.js
@@ -1138,10 +1138,14 @@ converse.plugins.add('converse-chat', {
const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
if (bare_forward) {
- return rejectMessage(
- stanza,
- 'Forwarded messages not part of an encapsulating protocol are not supported'
- );
+ if (!_converse.whitelisted_plugins.includes('converse-forward-message')) {
+ return rejectMessage(
+ stanza,
+ 'Forwarded messages not part of an encapsulating protocol are not supported'
+ );
+ } else {
+ return;
+ }
}
let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
if (u.isCarbonMessage(stanza)) {
diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js
index a5be512277..4b9010fbf2 100644
--- a/src/headless/utils/core.js
+++ b/src/headless/utils/core.js
@@ -137,6 +137,7 @@ u.isEmptyMessage = function (attrs) {
}
return !attrs['oob_url'] &&
!attrs['file'] &&
+ !attrs['is_forwarded_message'] &&
!(attrs['is_encrypted'] && attrs['plaintext']) &&
!attrs['message'];
};
diff --git a/src/templates/forward_message_modal.html b/src/templates/forward_message_modal.html
new file mode 100644
index 0000000000..88a1637951
--- /dev/null
+++ b/src/templates/forward_message_modal.html
@@ -0,0 +1,34 @@
+
+
+
+
+
{{{o.__('forward this message')}}}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/templates/forwarded_message_view.html b/src/templates/forwarded_message_view.html
new file mode 100644
index 0000000000..be6d079500
--- /dev/null
+++ b/src/templates/forwarded_message_view.html
@@ -0,0 +1,6 @@
+
+