Updated and refactored the work from @worlword
* Use Promises instead of callbacks * Update to latest (Last Call) version of XEP-0363 * Move non-view specific methods to models instead * Add more tests updates #161
This commit is contained in:
parent
9c2a5bd3b8
commit
584f293d05
|
@ -91,7 +91,7 @@
|
|||
"max-depth": "error",
|
||||
"max-len": "off",
|
||||
"max-lines": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-nested-callbacks": "off",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": "off",
|
||||
|
|
|
@ -173,6 +173,7 @@
|
|||
|
||||
describe("When supported", function () {
|
||||
|
||||
|
||||
describe("A file upload toolbar button", function () {
|
||||
|
||||
it("appears in private chats", mock.initConverseWithAsync(function (done, _converse) {
|
||||
|
@ -201,6 +202,92 @@
|
|||
it("appears in MUC chats", mock.initConverseWithAsync(function (done, _converse) {
|
||||
done();
|
||||
}));
|
||||
|
||||
describe("when clicked", function () {
|
||||
it("a file upload slot is requested", mock.initConverseWithAsync(function (done, _converse) {
|
||||
test_utils.waitUntilDiscoConfirmed(
|
||||
_converse, _converse.domain,
|
||||
[{'category': 'server', 'type':'IM'}],
|
||||
['http://jabber.org/protocol/disco#items'], [], 'info').then(function () {
|
||||
|
||||
var IQ_stanzas = _converse.connection.IQ_stanzas;
|
||||
|
||||
test_utils.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items').then(function () {
|
||||
test_utils.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []).then(function () {
|
||||
test_utils.createContacts(_converse, 'current');
|
||||
var contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@localhost';
|
||||
test_utils.openChatBoxFor(_converse, contact_jid);
|
||||
var view = _converse.chatboxviews.get(contact_jid);
|
||||
var file = {
|
||||
'type': 'image/jpeg',
|
||||
'size': '23456' ,
|
||||
'lastModifiedDate': "",
|
||||
'name': "my-juliet.jpg"
|
||||
};
|
||||
view.model.sendFile(file);
|
||||
return test_utils.waitUntil(function () {
|
||||
return _.filter(IQ_stanzas, function (iq) {
|
||||
return iq.nodeTree.querySelector('iq[to="upload.montague.tld"] request');
|
||||
});
|
||||
}).then(function () {
|
||||
var iq = IQ_stanzas.pop();
|
||||
expect(iq.toLocaleString()).toBe(
|
||||
"<iq from='dummy@localhost/resource' "+
|
||||
"to='upload.montague.tld' "+
|
||||
"type='get' "+
|
||||
"xmlns='jabber:client' "+
|
||||
"id='"+iq.nodeTree.getAttribute('id')+"'>"+
|
||||
"<request xmlns='urn:xmpp:http:upload:0' "+
|
||||
"filename='my-juliet.jpg' "+
|
||||
"size='23456' "+
|
||||
"content-type='image/jpeg'/>"+
|
||||
"</iq>");
|
||||
|
||||
var stanza = Strophe.xmlHtmlNode(
|
||||
"<iq from='upload.montague.tld'"+
|
||||
" id='"+iq.nodeTree.getAttribute('id')+"'"+
|
||||
" to='dummy@localhost/resource'"+
|
||||
" type='result'>"+
|
||||
"<slot xmlns='urn:xmpp:http:upload:0'>"+
|
||||
" <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'>"+
|
||||
" <header name='Authorization'>Basic Base64String==</header>"+
|
||||
" <header name='Cookie'>foo=bar; user=romeo</header>"+
|
||||
" </put>"+
|
||||
" <get url='https://download.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg' />"+
|
||||
"</slot>"+
|
||||
"</iq>").firstElementChild;
|
||||
spyOn(view.model, 'uploadFile').and.callFake(function () {
|
||||
return new window.Promise((resolve, reject) => { resolve(); });
|
||||
});
|
||||
var sent_stanza;
|
||||
spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
|
||||
sent_stanza = stanza;
|
||||
});
|
||||
_converse.connection._dataRecv(test_utils.createRequest(stanza));
|
||||
|
||||
return test_utils.waitUntil(function () {
|
||||
return sent_stanza;
|
||||
}).then(function () {
|
||||
expect(view.model.uploadFile).toHaveBeenCalled();
|
||||
expect(sent_stanza.toLocaleString()).toBe(
|
||||
"<message from='dummy@localhost/resource' "+
|
||||
"to='irini.vlastuin@localhost' "+
|
||||
"type='chat' "+
|
||||
"id='"+sent_stanza.nodeTree.getAttribute('id')+"' xmlns='jabber:client'>"+
|
||||
"<body>https://download.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg</body>"+
|
||||
"<active xmlns='http://jabber.org/protocol/chatstates'/>"+
|
||||
"<x xmlns='jabber:x:oob'>"+
|
||||
"<url>https://download.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg</url>"+
|
||||
"</x>"+
|
||||
"</message>");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -37,6 +37,7 @@ require.config({
|
|||
"lodash.converter": "3rdparty/lodash.fp",
|
||||
"lodash.fp": "src/lodash.fp",
|
||||
"lodash.noconflict": "src/lodash.noconflict",
|
||||
"message-utils": "src/utils/message",
|
||||
"muc-utils": "src/utils/muc",
|
||||
"pluggable": "node_modules/pluggable.js/dist/pluggable",
|
||||
"polyfill": "src/polyfill",
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
|
||||
function openChat (jid) {
|
||||
if (!utils.isValidJID(jid)) {
|
||||
return converse.log(
|
||||
return _converse.log(
|
||||
`Invalid JID "${jid}" provided in URL fragment`,
|
||||
Strophe.LogLevel.WARN
|
||||
);
|
||||
|
@ -116,99 +116,137 @@
|
|||
});
|
||||
},
|
||||
|
||||
createFileMessageStanza (message, to) {
|
||||
createMessageStanza (message) {
|
||||
/* Given a _converse.Message Backbone.Model, return the XML
|
||||
* stanza that represents it.
|
||||
*
|
||||
* Parameters:
|
||||
* (Object) message - The Backbone.Model representing the message
|
||||
*/
|
||||
const stanza = $msg({
|
||||
'from': _converse.connection.jid,
|
||||
'to': to,
|
||||
'type': 'chat',
|
||||
'id': message.get('msgid')
|
||||
}).c('body').t(message.get('message')).up()
|
||||
.c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up()
|
||||
.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
|
||||
'from': _converse.connection.jid,
|
||||
'to': this.get('jid'),
|
||||
'type': 'chat',
|
||||
'id': message.get('msgid')
|
||||
}).c('body').t(message.get('message')).up()
|
||||
.c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
|
||||
|
||||
if (message.get('is_spoiler')) {
|
||||
if (message.get('spoiler_hint')) {
|
||||
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER }, message.get('spoiler_hint')).up();
|
||||
} else {
|
||||
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER }).up();
|
||||
}
|
||||
}
|
||||
if (message.get('file')) {
|
||||
stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
|
||||
}
|
||||
return stanza;
|
||||
},
|
||||
|
||||
sendFile (file, chatbox) {
|
||||
const self = this;
|
||||
const request_slot_url = 'upload.' + _converse.domain;
|
||||
_converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, request_slot_url)
|
||||
.then((result) => {
|
||||
chatbox.showHelpMessages([__('The file upload starts now')],'info');
|
||||
self.requestSlot(file, request_slot_url, function(data) {
|
||||
if (!data) {
|
||||
alert(__('File upload failed. Please check the log.'));
|
||||
} else if (data.error) {
|
||||
alert(__('File upload failed. Please check the log.'));
|
||||
} else if (data.get && data.put) {
|
||||
self.uploadFile(data.put, file, function() {
|
||||
console.log(data.put);
|
||||
chatbox.onMessageSubmitted(data.put, null, file);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
sendMessageStanza (message, file) {
|
||||
const messageStanza = this.createMessageStanza(message, file);
|
||||
_converse.connection.send(messageStanza);
|
||||
if (_converse.forward_messages) {
|
||||
// Forward the message, so that other connected resources are also aware of it.
|
||||
_converse.connection.send(
|
||||
$msg({ to: _converse.bare_jid, type: 'chat', id: message.get('msgid') })
|
||||
.c('forwarded', {'xmlns': Strophe.NS.FORWARD})
|
||||
.c('delay', {
|
||||
'xmns': Strophe.NS.DELAY,
|
||||
'stamp': moment().format()
|
||||
}).up()
|
||||
.cnode(messageStanza.tree())
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
requestSlot (file, request_slot_url, cb) {
|
||||
const self = this;
|
||||
console.log("try sending file to: " + request_slot_url);
|
||||
const iq = converse.env.$iq({
|
||||
to: request_slot_url,
|
||||
type: 'get'
|
||||
}).c('request', {
|
||||
xmlns: Strophe.NS.HTTPUPLOAD
|
||||
}).c('filename').t(file.name)
|
||||
.up()
|
||||
.c('size').t(file.size);
|
||||
|
||||
_converse.connection.sendIQ(iq, function(stanza) {
|
||||
self.successfulRequestSlotCB(stanza, cb);
|
||||
}, function(stanza) {
|
||||
self.failedRequestSlotCB(stanza, cb);
|
||||
sendMessage (attrs) {
|
||||
/* Responsible for sending off a text message.
|
||||
*
|
||||
* Parameters:
|
||||
* (Message) message - The chat message
|
||||
*/
|
||||
this.sendMessageStanza(this.messages.create(attrs));
|
||||
},
|
||||
|
||||
notifyUploadFailure (err_msg, error) {
|
||||
err_msg = err_msg || __("Sorry, failed to upload the file");
|
||||
this.trigger('showHelpMessages', [err_msg], 'error');
|
||||
if (error instanceof Error) {
|
||||
_converse.log(error, Strophe.LogLevel.ERROR);
|
||||
}
|
||||
},
|
||||
|
||||
sendFile (file) {
|
||||
_converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
|
||||
if (!result.length) {
|
||||
this.notifyUploadFailure(__("Sorry, file upload is not supported by your server."));
|
||||
}
|
||||
const request_slot_url = result[0].id;
|
||||
if (!request_slot_url) {
|
||||
return this.notifyUploadFailure(__("Could not determine request slot URL for file upload"));
|
||||
}
|
||||
this.trigger('showHelpMessages', [__('The file upload starts now')], 'info');
|
||||
this.requestSlot(file, request_slot_url).then((stanza) => {
|
||||
const slot = stanza.querySelector('slot');
|
||||
if (slot) {
|
||||
const put = slot.querySelector('put').getAttribute('url');
|
||||
const get = slot.querySelector('get').getAttribute('url');
|
||||
this.uploadFile(put, file)
|
||||
.then(_.bind(this.sendMessage, this, {'message': get, 'file': true}))
|
||||
.catch(this.notifyUploadFailure.bind(this, null));
|
||||
} else {
|
||||
this.notifyUploadFailure();
|
||||
}
|
||||
}).catch(this.notifyUploadFailure.bind(this, null));
|
||||
});
|
||||
},
|
||||
|
||||
sendFiles (files) {
|
||||
_.each(files, this.sendFile.bind(this));
|
||||
},
|
||||
|
||||
requestSlot (file, request_slot_url) {
|
||||
/* Send out an IQ stanza to request a file upload slot.
|
||||
*
|
||||
* https://xmpp.org/extensions/xep-0363.html#request
|
||||
*/
|
||||
return new Promise((resolve, reject) => {
|
||||
const iq = converse.env.$iq({
|
||||
'from': _converse.jid,
|
||||
'to': request_slot_url,
|
||||
'type': 'get'
|
||||
}).c('request', {
|
||||
'xmlns': Strophe.NS.HTTPUPLOAD,
|
||||
'filename': file.name,
|
||||
'size': file.size,
|
||||
'content-type': file.type
|
||||
})
|
||||
_converse.connection.sendIQ(iq, resolve, reject);
|
||||
});
|
||||
},
|
||||
|
||||
uploadFile (url, file, callback) {
|
||||
console.log("uploadFile start");
|
||||
const xmlhttp = new XMLHttpRequest();
|
||||
const contentType = 'application/octet-stream';
|
||||
xmlhttp.onreadystatechange = function() {
|
||||
if (xmlhttp.readyState === XMLHttpRequest.DONE) {
|
||||
console.log("Status: " + xmlhttp.status);
|
||||
if (xmlhttp.status === 200 || xmlhttp.status === 201) {
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
uploadFile (url, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
_converse.log("Status: " + xhr.status, Strophe.LogLevel.INFO);
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
resolve(url, file);
|
||||
} else {
|
||||
xhr.onerror();
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert(__('Could not upload File please try again.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xmlhttp.open('PUT', url, true);
|
||||
xmlhttp.setRequestHeader("Content-type", contentType);
|
||||
xmlhttp.send(file);
|
||||
},
|
||||
|
||||
successfulRequestSlotCB (stanza, cb) {
|
||||
const slot = stanza.getElementsByTagName('slot')[0];
|
||||
|
||||
if (slot != undefined) {
|
||||
var put = slot.getElementsByTagName('put')[0].textContent;
|
||||
var get = slot.getElementsByTagName('get')[0].textContent;
|
||||
cb({
|
||||
put: put,
|
||||
get: get
|
||||
});
|
||||
} else {
|
||||
this.failedRequestSlotCB(stanza, cb);
|
||||
}
|
||||
},
|
||||
|
||||
failedRequestSlotCB (stanza, cb) {
|
||||
alert(__('Could not upload File please try again.'));
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
reject(xhr.responseText);
|
||||
};
|
||||
xhr.open('PUT', url, true);
|
||||
xhr.setRequestHeader("Content-type", 'application/octet-stream');
|
||||
xhr.send(file);
|
||||
});
|
||||
},
|
||||
|
||||
getMessageBody (message) {
|
||||
|
|
|
@ -268,7 +268,6 @@
|
|||
this.model.on('change:chat_state', this.sendChatState, this);
|
||||
this.model.on('change:chat_status', this.onChatStatusChanged, this);
|
||||
this.model.on('showHelpMessages', this.showHelpMessages, this);
|
||||
this.model.on('sendMessage', this.sendMessage, this);
|
||||
this.render();
|
||||
this.fetchMessages();
|
||||
_converse.emit('chatBoxOpened', this);
|
||||
|
@ -372,8 +371,8 @@
|
|||
}
|
||||
return _.extend(options || {}, {
|
||||
'label_clear': __('Clear all messages'),
|
||||
'label_insert_smiley': __('Insert a smiley'),
|
||||
'label_start_call': __('Start a call'),
|
||||
'tooltip_insert_smiley': __('Insert emojis'),
|
||||
'tooltip_start_call': __('Start a call'),
|
||||
'label_toggle_spoiler': label_toggle_spoiler,
|
||||
'show_call_button': _converse.visible_toolbar_buttons.call,
|
||||
'show_spoiler_button': _converse.visible_toolbar_buttons.spoiler,
|
||||
|
@ -666,7 +665,7 @@
|
|||
'beforeend',
|
||||
tpl_help_message({
|
||||
'isodate': moment().format(),
|
||||
'type': type||'info',
|
||||
'type': type,
|
||||
'message': xss.filterXSS(msg, {'whiteList': {'strong': []}})
|
||||
})
|
||||
);
|
||||
|
@ -796,55 +795,6 @@
|
|||
});
|
||||
},
|
||||
|
||||
createMessageStanza (message) {
|
||||
const stanza = $msg({
|
||||
'from': _converse.connection.jid,
|
||||
'to': this.model.get('jid'),
|
||||
'type': 'chat',
|
||||
'id': message.get('msgid')
|
||||
}).c('body').t(message.get('message')).up()
|
||||
.c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
|
||||
|
||||
if (message.get('is_spoiler')) {
|
||||
if (message.get('spoiler_hint')) {
|
||||
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER }, message.get('spoiler_hint'));
|
||||
} else {
|
||||
stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER });
|
||||
}
|
||||
}
|
||||
return stanza;
|
||||
},
|
||||
|
||||
sendMessage (message, file = null) {
|
||||
/* Responsible for sending off a text message.
|
||||
*
|
||||
* Parameters:
|
||||
* (Message) message - The chat message
|
||||
*/
|
||||
// TODO: We might want to send to specfic resources.
|
||||
// Especially in the OTR case.
|
||||
var messageStanza;
|
||||
if (file !== null) {
|
||||
messageStanza = this.model.createFileMessageStanza(message, this.model.get('jid'));
|
||||
}
|
||||
else {
|
||||
messageStanza = this.createMessageStanza(message);
|
||||
}
|
||||
_converse.connection.send(messageStanza);
|
||||
if (_converse.forward_messages) {
|
||||
// Forward the message, so that other connected resources are also aware of it.
|
||||
_converse.connection.send(
|
||||
$msg({ to: _converse.bare_jid, type: 'chat', id: message.get('msgid') })
|
||||
.c('forwarded', {'xmlns': Strophe.NS.FORWARD})
|
||||
.c('delay', {
|
||||
'xmns': Strophe.NS.DELAY,
|
||||
'stamp': moment().format()
|
||||
}).up()
|
||||
.cnode(messageStanza.tree())
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
parseMessageForCommands (text) {
|
||||
const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
|
||||
if (match) {
|
||||
|
@ -864,7 +814,7 @@
|
|||
}
|
||||
},
|
||||
|
||||
onMessageSubmitted (text, spoiler_hint, file = null) {
|
||||
onMessageSubmitted (text, spoiler_hint, file=null) {
|
||||
/* This method gets called once the user has typed a message
|
||||
* and then pressed enter in a chat box.
|
||||
*
|
||||
|
@ -884,8 +834,7 @@
|
|||
return;
|
||||
}
|
||||
const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
|
||||
const message = this.model.messages.create(attrs);
|
||||
this.sendMessage(message, file);
|
||||
this.model.sendMessage(attrs, file);
|
||||
},
|
||||
|
||||
getOutgoingMessageAttributes (text, spoiler_hint) {
|
||||
|
|
|
@ -32,7 +32,16 @@
|
|||
ChatBoxView: {
|
||||
events: {
|
||||
'click .upload-file': 'toggleFileUpload',
|
||||
'change input.fileupload': 'handleFileSelect'
|
||||
'change input.fileupload': 'onFileSelection'
|
||||
},
|
||||
|
||||
|
||||
toggleFileUpload (ev) {
|
||||
this.el.querySelector('input.fileupload').click();
|
||||
},
|
||||
|
||||
onFileSelection (evt) {
|
||||
this.model.sendFiles(evt.target.files);
|
||||
},
|
||||
|
||||
addFileUploadButton (options) {
|
||||
|
@ -45,30 +54,19 @@
|
|||
renderToolbar (toolbar, options) {
|
||||
const { _converse } = this.__super__;
|
||||
const result = this.__super__.renderToolbar.apply(this, arguments);
|
||||
_converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain)
|
||||
.then((result) => {
|
||||
if (result.length) {
|
||||
this.addFileUploadButton();
|
||||
}
|
||||
});
|
||||
_converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain).then((result) => {
|
||||
if (result.length) {
|
||||
this.addFileUploadButton();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
toggleFileUpload (ev) {
|
||||
this.el.querySelector('.input.fileupload').click();
|
||||
},
|
||||
|
||||
handleFileSelect (evt) {
|
||||
var files = evt.target.files;
|
||||
var file = files[0];
|
||||
this.model.sendFile(file, this);
|
||||
}
|
||||
},
|
||||
|
||||
ChatRoomView: {
|
||||
events: {
|
||||
'click .upload-file': 'toggleFileUpload',
|
||||
'change .input.fileupload': 'handleFileSelect'
|
||||
'change .input.fileupload': 'onFileSelection'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -224,7 +224,7 @@
|
|||
this.trigger('showReceivedOTRMessage', msg);
|
||||
});
|
||||
this.otr.on('io', (msg) => {
|
||||
this.trigger('sendMessage', new _converse.Message({ message: msg }));
|
||||
this.sendMessage(new _converse.Message({'message':msg}));
|
||||
});
|
||||
this.otr.on('error', (msg) => {
|
||||
this.trigger('showOTRError', msg);
|
||||
|
|
|
@ -1 +1 @@
|
|||
<div class="message chat-{{{o.type}}}" data-isodate="{{{o.isodate}}}">{{o.message}}</div>
|
||||
<div class="message chat-info {[ if (o.type !== 'info') { ]} chat-{{{o.type}}} {[ } ]}" data-isodate="{{{o.isodate}}}">{{o.message}}</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{[ if (o.use_emoji) { ]}
|
||||
<li class="toggle-toolbar-menu toggle-smiley fa fa-smile-o dropup">
|
||||
<li class="toggle-toolbar-menu toggle-smiley dropup">
|
||||
<a class="btn toggle-smiley fa fa-smile-o" title="{{{o.tooltip_insert_smiley}}}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></a>
|
||||
<div class="emoji-picker dropdown-menu toolbar-menu"></div>
|
||||
</li>
|
||||
{[ } ]}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<input type="file" class="fileupload" style="display:none"/>
|
||||
<input type="file" class="fileupload" multiple style="display:none"/>
|
||||
<li class="upload-file">
|
||||
<a class="fa fa-paperclip" title="{{{o.tooltip_upload_file}}}"></a>
|
||||
</li>
|
||||
|
|
Loading…
Reference in New Issue