Commit a19e7aef authored by JC Brand's avatar JC Brand

Add support for XEP-0066 Out of band data

parent d048cff9
......@@ -57,7 +57,7 @@
"property"
],
"dot-notation": [
"error",
"off",
{
"allowKeywords": true
}
......
......@@ -5,7 +5,8 @@
## New Features
- #161 XEP-0363: HTTP File Upload
- mp4 and mp3 files will now be playable directly in chat
- Support for rendering URLs sent according to XEP-0066 Out of Band Data.
- mp4 and mp3 files when sent as XEP-0066 Out of Band Data, will now be playable directly in chat
## 4.0.0 (Unreleased)
......
......@@ -7382,8 +7382,7 @@ body.reset {
font-style: italic; }
#converse-embedded-chat .chatbox .chat-body .chat-message,
#conversejs .chatbox .chat-body .chat-message {
overflow: auto;
margin: 0; }
overflow: auto; }
#converse-embedded-chat .chatbox .chat-body .chat-message.onload,
#conversejs .chatbox .chat-body .chat-message.onload {
animation: colorchange-chatmessage 1s;
......@@ -7444,6 +7443,9 @@ body.reset {
#conversejs .chatbox .chat-content .toggle-spoiler:before {
padding-right: 0.25em;
whitespace: nowrap; }
#converse-embedded-chat .chatbox .chat-content video,
#conversejs .chatbox .chat-content video {
width: 100%; }
#converse-embedded-chat .chatbox .chat-content progress,
#conversejs .chatbox .chat-content progress {
margin: 0.5em 0;
......
......@@ -7435,8 +7435,7 @@ body {
font-style: italic; }
#converse-embedded-chat .chatbox .chat-body .chat-message,
#conversejs .chatbox .chat-body .chat-message {
overflow: auto;
margin: 0; }
overflow: auto; }
#converse-embedded-chat .chatbox .chat-body .chat-message.onload,
#conversejs .chatbox .chat-body .chat-message.onload {
animation: colorchange-chatmessage 1s;
......@@ -7497,6 +7496,9 @@ body {
#conversejs .chatbox .chat-content .toggle-spoiler:before {
padding-right: 0.25em;
whitespace: nowrap; }
#converse-embedded-chat .chatbox .chat-content video,
#conversejs .chatbox .chat-content video {
width: 100%; }
#converse-embedded-chat .chatbox .chat-content progress,
#conversejs .chatbox .chat-content progress {
margin: 0.5em 0;
......
......@@ -167,6 +167,7 @@
<li>Custom status messages</li>
<li>Typing and chat state notifications (<a href="http://xmpp.org/extensions/xep-0085.html" target="_blank" rel="noopener">XEP 85</a>)</li>
<li>Desktop notifications</li>
<li>File sharing (<a href="http://xmpp.org/extensions/xep-0363.html" target="_blank" rel="noopener">XEP 363</a>)</li>
<li>Messages appear in all connected chat clients (<a href="http://xmpp.org/extensions/xep-0280.html" target="_blank" rel="noopener">XEP 280</a>)</li>
<li>Third person "/me" messages (<a href="http://xmpp.org/extensions/xep-0245.html" target="_blank" rel="noopener">XEP 245</a>)</li>
<li>XMPP Ping (<a href="http://xmpp.org/extensions/xep-0199.html" target="_blank" rel="noopener">XEP 199</a>)</li>
......
......@@ -211,7 +211,6 @@
}
.chat-message {
overflow: auto; // Ensures that content stays inside
margin: 0;
&.onload {
animation: colorchange-chatmessage 1s;
......@@ -281,7 +280,9 @@
padding-right: 0.25em;
whitespace: nowrap;
}
video {
width: 100%
}
progress {
margin: 0.5em 0;
width: 100%
......
......@@ -1578,6 +1578,171 @@
done();
}));
it("will render audio from oob mp3 URLs",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
function (done, _converse) {
test_utils.createContacts(_converse, 'current');
var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.openChatBoxFor(_converse, contact_jid);
var view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>Have you heard this funny audio?</body>"+
" <x xmlns='jabber:x:oob'><url>http://localhost/audio.mp3</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
test_utils.waitUntil(function () {
return view.el.querySelectorAll('.chat-content .chat-message audio').length;
}, 1000).then(function () {
var msg = view.el.querySelector('.chat-message .chat-msg-content');
expect(msg.outerHTML).toEqual('<span class="chat-msg-content">Have you heard this funny audio?</span>');
var media = view.el.querySelector('.chat-message .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<audio controls=""><source src="http://localhost/audio.mp3" type="audio/mpeg"></audio>'+
'<a target="_blank" rel="noopener" href="http://localhost/audio.mp3">Download audio file</a>');
// If the <url> and <body> contents is the same, don't duplicate.
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>http://localhost/audio.mp3</body>"+
" <x xmlns='jabber:x:oob'><url>http://localhost/audio.mp3</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
msg = view.el.querySelector('.chat-message:last-child .chat-msg-content');
expect(msg.innerHTML).toEqual('');
media = view.el.querySelector('.chat-message:last-child .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<audio controls=""><source src="http://localhost/audio.mp3" type="audio/mpeg"></audio>'+
'<a target="_blank" rel="noopener" href="http://localhost/audio.mp3">Download audio file</a>');
done();
});
}));
it("will render video from oob mp4 URLs",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
function (done, _converse) {
test_utils.createContacts(_converse, 'current');
var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.openChatBoxFor(_converse, contact_jid);
var view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>Have you seen this funny video?</body>"+
" <x xmlns='jabber:x:oob'><url>http://localhost/video.mp4</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
test_utils.waitUntil(function () {
return view.el.querySelectorAll('.chat-content .chat-message video').length;
}, 1000).then(function () {
var msg = view.el.querySelector('.chat-message .chat-msg-content');
expect(msg.outerHTML).toEqual('<span class="chat-msg-content">Have you seen this funny video?</span>');
var media = view.el.querySelector('.chat-message .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<video controls=""><source src="http://localhost/video.mp4" type="video/mp4"></video>'+
'<a target="_blank" rel="noopener" href="http://localhost/video.mp4">Download video file</a>');
// If the <url> and <body> contents is the same, don't duplicate.
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>http://localhost/video.mp4</body>"+
" <x xmlns='jabber:x:oob'><url>http://localhost/video.mp4</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
msg = view.el.querySelector('.chat-message:last-child .chat-msg-content');
expect(msg.innerHTML).toEqual('');
media = view.el.querySelector('.chat-message:last-child .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<video controls=""><source src="http://localhost/video.mp4" type="video/mp4"></video>'+
'<a target="_blank" rel="noopener" href="http://localhost/video.mp4">Download video file</a>');
done();
});
}));
it("will render download links for files from oob URLs",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
function (done, _converse) {
test_utils.createContacts(_converse, 'current');
var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.openChatBoxFor(_converse, contact_jid);
var view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>Have you downloaded this funny file?</body>"+
" <x xmlns='jabber:x:oob'><url>http://localhost/funny.pdf</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
test_utils.waitUntil(function () {
return view.el.querySelectorAll('.chat-content .chat-message a').length;
}, 1000).then(function () {
var msg = view.el.querySelector('.chat-message .chat-msg-content');
expect(msg.outerHTML).toEqual('<span class="chat-msg-content">Have you downloaded this funny file?</span>');
var media = view.el.querySelector('.chat-message .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<a target="_blank" rel="noopener" href="http://localhost/funny.pdf">Download file: "funny.pdf</a>');
done();
});
}));
it("will render images from oob URLs",
mock.initConverseWithPromises(
null, ['rosterGroupsFetched'], {},
function (done, _converse) {
test_utils.createContacts(_converse, 'current');
var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
test_utils.openChatBoxFor(_converse, contact_jid);
var view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();
var base_url = document.URL.split(window.location.pathname)[0];
var url = base_url+"/logo/conversejs-filled.svg";
var stanza = Strophe.xmlHtmlNode(
"<message from='"+contact_jid+"'"+
" type='chat'"+
" to='dummy@localhost/resource'>"+
" <body>Have you seen this funny image?</body>"+
" <x xmlns='jabber:x:oob'><url>"+url+"</url></x>"+
"</message>").firstChild;
_converse.connection._dataRecv(test_utils.createRequest(stanza));
test_utils.waitUntil(function () {
return view.el.querySelectorAll('.chat-content .chat-message img').length;
}, 1000).then(function () {
var msg = view.el.querySelector('.chat-message .chat-msg-content');
expect(msg.outerHTML).toEqual('<span class="chat-msg-content">Have you seen this funny image?</span>');
var media = view.el.querySelector('.chat-message .chat-msg-media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
'<img class="chat-image" src="http://localhost:8000/logo/conversejs-filled.svg">');
done();
});
}));
it("will render images from their URLs",
mock.initConverseWithPromises(
......@@ -1672,10 +1837,10 @@
var sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
// <composing> state
var msg = $msg({
from: sender_jid,
to: _converse.connection.jid,
type: 'chat',
id: (new Date()).getTime()
'from': sender_jid,
'to': _converse.connection.jid,
'type': 'chat',
'id': (new Date()).getTime()
}).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
_converse.chatboxes.onMessage(msg);
expect(_converse.emit).toHaveBeenCalledWith('message', jasmine.any(Object));
......
......@@ -10,16 +10,15 @@
"emojione",
"filesize",
"tpl!chatboxes",
"backbone.overview"
"backbone.overview",
"form-utils"
], factory);
}(this, function (converse, emojione, filesize, tpl_chatboxes) {
"use strict";
const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, utils, _ } = converse.env;
const { $msg, Backbone, Promise, Strophe, b64_sha1, moment, sizzle, utils, _ } = converse.env;
const u = converse.env.utils;
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
converse.plugins.add('converse-chatboxes', {
......@@ -373,6 +372,7 @@
sender = 'them';
fullname = this.get('fullname');
}
const spoiler = message.querySelector(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`);
const attrs = {
'type': type,
......@@ -386,6 +386,10 @@
'time': time,
'is_spoiler': !_.isNull(spoiler)
};
_.each(sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, message), (xform) => {
attrs['oob_url'] = xform.querySelector('url').textContent;
attrs['oob_desc'] = xform.querySelector('url').textContent;
});
if (spoiler) {
attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
}
......@@ -679,7 +683,13 @@
return _converse.chatboxviews.get(chatbox.get('id'));
};
/************************ BEGIN Event Handlers ************************/
_converse.on('addClientFeatures', () => {
_converse.connection.disco.addFeature(Strophe.NS.HTTPUPLOAD);
_converse.connection.disco.addFeature(Strophe.NS.OUTOFBAND);
});
_converse.api.listen.on('pluginsInitialized', () => {
_converse.chatboxes = new _converse.ChatBoxes();
_converse.chatboxviews = new _converse.ChatBoxViews({
......
......@@ -41,6 +41,7 @@
Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
......
......@@ -161,7 +161,7 @@
});
});
_.each(sizzle('x[type="result"][xmlns="jabber:x:data"]', stanza), (form) => {
_.each(sizzle(`x[type="result"][xmlns="${Strophe.NS.XFORM}"]`, stanza), (form) => {
const data = {};
_.each(form.querySelectorAll('field'), (field) => {
data[field.getAttribute('var')] = {
......@@ -172,7 +172,7 @@
this.dataforms.create(data);
});
if (stanza.querySelector('feature[var="'+Strophe.NS.DISCO_ITEMS+'"]')) {
if (stanza.querySelector(`feature[var="${Strophe.NS.DISCO_ITEMS}"]`)) {
this.queryForItems();
}
_.forEach(stanza.querySelectorAll('feature'), (feature) => {
......
......@@ -11,7 +11,7 @@
"emojione",
"filesize",
"tpl!action",
"tpl!file",
"tpl!file_progress",
"tpl!info",
"tpl!message",
"tpl!spoiler_message"
......@@ -22,7 +22,7 @@
emojione,
filesize,
tpl_action,
tpl_file,
tpl_file_progress,
tpl_info,
tpl_message,
tpl_spoiler_message
......@@ -85,16 +85,27 @@
'label_show': __('Show hidden message')
})
));
const msg_content = msg.querySelector('.chat-msg-content');
text = xss.filterXSS(text, {'whiteList': {}});
msg_content.innerHTML = _.flow(
_.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
_.partial(u.addHyperlinks, _),
_.partial(u.addEmoji, _converse, emojione, _),
u.renderMovieURLs,
u.renderAudioURLs
)(text);
var url = this.model.get('oob_url');
if (url) {
const msg_media = msg.querySelector('.chat-msg-media');
msg_media.innerHTML = _.flow(
_.partial(u.renderFileURL, _converse),
_.partial(u.renderMovieURL, _converse),
_.partial(u.renderAudioURL, _converse),
_.partial(u.renderImageURL, _converse)
)(url);
}
const msg_content = msg.querySelector('.chat-msg-content');
if (text !== url) {
text = xss.filterXSS(text, {'whiteList': {}});
msg_content.innerHTML = _.flow(
_.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
u.addHyperlinks,
_.partial(u.addEmoji, _converse, emojione, _)
)(text);
}
u.renderImageURLs(msg_content).then(() => {
this.model.collection.trigger('rendered');
});
......@@ -121,7 +132,7 @@
},
renderFileUploadProgresBar () {
const msg = u.stringToElement(tpl_file(
const msg = u.stringToElement(tpl_file_progress(
_.extend(this.model.toJSON(),
{'filesize': filesize(this.model.get('file').size)}
)));
......
<audio controls><source src="{{{o.url}}}" type="audio/mpeg"></audio>
<a target="_blank" rel="noopener" href="{{{o.url}}}">{{{o.label_download}}}</a>
<div class="message" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
<span class="chat-msg-content">Uploading file: <strong>{{{o.file.name}}}</strong>, {{{o.filesize}}}</span>
<progress value="{{{o.progress}}}"/>
</div>
<a target="_blank" rel="noopener" href="{{{o.url}}}">{{{o.label_download}}}</a>
<div class="message" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
<span class="chat-msg-content">Uploading file: <strong>{{{o.file.name}}}</strong>, {{{o.filesize}}}</span>
<progress value="{{{o.progress}}}"/>
</div>
<img class="chat-image" src="{{{o.url}}}"/>
<div class="message chat-message {{{o.extra_classes}}}" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
<span class="chat-msg-author chat-msg-{{{o.sender}}}">{{{o.pretty_time}}} {{{o.username}}}:&nbsp;</span>
<span class="chat-msg-content"><!-- message gets added here via renderMessage --></span>
<span class="chat-msg-content"></span>
<div class="chat-msg-media"></div>
</div>
<video controls><source src="{{{o.url}}}" type="video/mp4"></video>
<a target="_blank" rel="noopener" href="{{{o.url}}}">{{{o.label_download}}}</a>
......@@ -13,12 +13,20 @@
"es6-promise",
"lodash.noconflict",
"strophe",
"tpl!audio",
"tpl!file",
"tpl!image",
"tpl!video"
], factory);
}(this, function (
sizzle,
Promise,
_,
Strophe
Strophe,
tpl_audio,
tpl_file,
tpl_image,
tpl_video
) {
"use strict";
const b64_sha1 = Strophe.SHA1.b64_sha1;
......@@ -213,18 +221,56 @@
))
};
u.renderMovieURLs = function (text) {
if (text.endsWith('mp4')) {
return "<video controls><source src=\"" + text + "\" type=\"video/mp4\"></video>";
u.renderFileURL = function (_converse, url) {
if (url.endsWith('mp3') || url.endsWith('mp4') ||
url.endsWith('jpg') || url.endsWith('jpeg') ||
url.endsWith('png') || url.endsWith('gif') ||
url.endsWith('svg')) {
return url;
}
return text;
const name = url.split('/').pop(),
{ __ } = _converse;
return tpl_file({
'url': url,
'label_download': __('Download file: "%1$s', name)
})
};
u.renderAudioURLs = function (text) {
if (text.endsWith('mp3')) {
return "<audio controls><source src=\"" + text+ "\" type=\"audio/mpeg\"></audio>";
u.renderImageURL = function (_converse, url) {
const { __ } = _converse;
if (url.endsWith('jpg') || url.endsWith('jpeg') || url.endsWith('png') ||
url.endsWith('gif') || url.endsWith('svg')) {
return tpl_image({
'url': url,
'label_download': __('Download image file')
})
}
return text;
return url;
};
u.renderMovieURL = function (_converse, url) {
const { __ } = _converse;
if (url.endsWith('mp4')) {
return tpl_video({
'url': url,
'label_download': __('Download video file')
})
}
return url;
};
u.renderAudioURL = function (_converse, url) {
const { __ } = _converse;
if (url.endsWith('mp3')) {
return tpl_audio({
'url': url,
'label_download': __('Download audio file')
})
}
return url;
};
u.slideInAllElements = function (elements, duration=300) {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment