Render chat messages as web components

- Render chat content as a <converse-chat-content> component
- Create new component for rendering the message body
- Get rid of `showMessage` method
This commit is contained in:
JC Brand 2020-05-15 14:33:31 +02:00
parent a497e8df3a
commit 7651d58470
54 changed files with 1592 additions and 1443 deletions

View File

@ -35,6 +35,7 @@
"lodash/prefer-startswith": "off",
"lodash/preferred-alias": "off",
"lodash/matches-prop-shorthand": "off",
"lodash/prop-shorthand": "off",
"accessor-pairs": "error",
"array-bracket-spacing": "off",
"array-callback-return": "error",

View File

@ -13,6 +13,12 @@ module.exports = function(config) {
"dist/converse.js",
"dist/converse.css",
{ pattern: "dist/webfonts/**/*.*", included: false },
{ pattern: "dist/\@fortawesome/fontawesome-free/sprites/solid.svg",
watched: false,
included: false,
served: true,
nocache: false
},
{ pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
{ pattern: "spec/mock.js", type: 'module' },
@ -50,9 +56,13 @@ module.exports = function(config) {
{ pattern: "spec/hats.js", type: 'module' },
{ pattern: "spec/http-file-upload.js", type: 'module' },
{ pattern: "spec/emojis.js", type: 'module' },
{ pattern: "spec/xss.js", type: 'module' },
{ pattern: "spec/xss.js", type: 'module' }
],
proxies: {
"/dist/\@fortawesome/fontawesome-free/sprites/solid.svg": "/base/dist/\@fortawesome/fontawesome-free/sprites/solid.svg"
},
exclude: ['**/*.sw?'],
// preprocess matching files before serving them to the browser

View File

@ -219,13 +219,46 @@
font-size: var(--message-font-size);
height: 100%;
line-height: 1.3em;
overflow-y: auto;
padding: 1em 0 0 0;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
converse-chat-content {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
converse-chat-message {
.spinner {
width: 100%;
overflow-y: hidden;
}
}
.chat-content__help {
converse-chat-help {
border-top: 1px solid var(--chat-head-color);
display: block;
padding: 0.5em 0;
}
.close-chat-help {
float: right;
padding-right: 1em;
cursor: pointer;
color: var(--chat-content-background-color);
}
}
.chat-content__messages {
overflow-x: hidden;
overflow-y: auto;
height: 100%;
}
.chat-content__notifications {
height: 1.7em;
white-space: pre;
@ -235,7 +268,6 @@
font-style: italic;
line-height: var(--line-height-small);
padding: 0 1em 0.3em;
&:before {
content: " ";
}

View File

@ -97,6 +97,16 @@
}
}
.empty-history-feedback {
position: relative;
span {
width: 100%;
text-align: center;
position: absolute;
margin-top: 50%;
}
}
.chatroom {
width: var(--chatroom-width);
@media screen and (max-height: $mobile-landscape-height){
@ -166,6 +176,16 @@
.chat-content {
height: 100%;
}
.chat-content__help {
converse-chat-help {
border-top: 1px solid var(--chatroom-head-bg-color);
}
.close-chat-help {
svg {
fill: 1px solid var(--chatroom-head-bg-color) !important;
}
}
}
}
.occupants {
display: flex;
@ -330,18 +350,6 @@
}
}
.empty-history-feedback {
position: relative;
height: 100%;
color: var(--text-color-lighten-15-percent);
span {
width: 100%;
text-align: center;
position: absolute;
margin-top: 50%;
}
}
.muc-bottom-panel {
border-top: var(--message-input-border-top);
height: 3em;

View File

@ -340,7 +340,7 @@ body.converse-fullscreen {
q {
quotes: "" "" "" "";
&.reason {
display: block;
display: inline;
}
}
q:before {

View File

@ -196,7 +196,7 @@
a {
word-wrap: break-word;
word-break: break-all;
display: inline-block;
display: inline;
&.chat-image__link {
display: block;
}
@ -222,7 +222,6 @@
.chat-msg__error {
color: var(--error-color);
font-weight: bold;
}
.chat-msg__media {

View File

@ -5,9 +5,13 @@ const $msg = converse.env.$msg;
const Strophe = converse.env.Strophe;
const u = converse.env.utils;
const sizzle = converse.env.sizzle;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("Chatboxes", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
describe("A Chatbox", function () {
it("has a /help command to show the available commands", mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) {
@ -20,7 +24,8 @@ describe("Chatboxes", function () {
const view = _converse.chatboxviews.get(contact_jid);
mock.sendMessage(view, '/help');
const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view.el).length);
const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view.el));
expect(info_messages.length).toBe(4);
expect(info_messages.pop().textContent).toBe('/help: Show this menu');
expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
@ -35,7 +40,8 @@ describe("Chatboxes", function () {
}).c('body').t('hello world').tree();
await _converse.handleMessageStanza(msg);
await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
expect(view.msgs_container.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === 'hello world');
done();
}));
@ -58,30 +64,36 @@ describe("Chatboxes", function () {
await _converse.handleMessageStanza(msg);
const view = _converse.chatboxviews.get(sender_jid);
await new Promise(resolve => view.once('messageInserted', resolve));
await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Mercutio')).toBeTruthy();
expect(view.el.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
expect(view.el.querySelector('.chat-msg__text').textContent).toBe('is tired');
message = '/me is as well';
await mock.sendMessage(view, message);
expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(2);
await u.waitUntil(() => sizzle('.chat-msg__author:last', view.el).pop().textContent.trim() === '**Romeo Montague');
const last_el = sizzle('.chat-msg__text:last', view.el).pop();
expect(last_el.textContent).toBe('is as well');
await u.waitUntil(() => last_el.textContent === 'is as well');
expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
// Check that /me messages after a normal message don't
// get the 'chat-msg--followup' class.
message = 'This a normal message';
await mock.sendMessage(view, message);
let message_el = view.el.querySelector('.message:last-child');
expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === message);
let el = view.el.querySelector('converse-chat-message:last-child .chat-msg__body');
expect(u.hasClass('chat-msg--followup', el)).toBeFalsy();
message = '/me wrote a 3rd person message';
await mock.sendMessage(view, message);
message_el = view.el.querySelector('.message:last-child');
await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === message.replace('/me ', ''));
el = view.el.querySelector('converse-chat-message:last-child .chat-msg__body');
expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(3);
expect(sizzle('.chat-msg__text:last', view.el).pop().textContent).toBe('wrote a 3rd person message');
expect(u.isVisible(sizzle('.chat-msg__author:last', view.el).pop())).toBeTruthy();
expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
done();
}));
@ -451,7 +463,7 @@ describe("Chatboxes", function () {
keyCode: 13 // Enter
};
view.onKeyDown(ev);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
view.onKeyUp(ev);
expect(counter.textContent).toBe('200');
@ -1166,8 +1178,6 @@ describe("Chatboxes", function () {
expect(document.title).toBe('Converse Tests');
const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await mock.openChatBoxFor(_converse, sender_jid)
const previous_state = _converse.windowState;
const message = 'This message will increment the message counter';
const msg = $msg({
@ -1184,7 +1194,6 @@ describe("Chatboxes", function () {
spyOn(_converse, 'clearMsgCounter').and.callThrough();
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
expect(_converse.incrementMsgCounter).toHaveBeenCalled();
expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
expect(document.title).toBe('Messages (1) Converse Tests');
@ -1604,9 +1613,8 @@ describe("Chatboxes", function () {
await mock.waitForRoster(_converse, 'current', 1);
const message = "geo:37.786971,-122.399677",
contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const message = "geo:37.786971,-122.399677";
const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid);
spyOn(view.model, 'sendMessage').and.callThrough();
@ -1614,10 +1622,9 @@ describe("Chatboxes", function () {
await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
expect(view.model.sendMessage).toHaveBeenCalled();
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.innerHTML).toEqual(
expect(msg.innerHTML.replace(/\<!----\>/g, '')).toEqual(
'<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.7869'+
'71&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
done();
}));
});

View File

@ -170,9 +170,8 @@ describe("Emojis", function () {
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(sender_jid);
await new Promise(resolve => view.once('messageInserted', resolve));
let message = view.content.querySelector('.chat-msg__text');
expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector('.chat-msg__text')));
_converse.handleMessageStanza($msg({
'from': sender_jid,
@ -181,9 +180,10 @@ describe("Emojis", function () {
'id': _converse.connection.getUniqueId()
}).c('body').t('😇 Hello world! 😇 😇').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
message = view.content.querySelector('.message:last-child .chat-msg__text');
expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
let sel = '.message:last-child .chat-msg__text';
await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector(sel)));
// Test that a modified message that no longer contains only
// emojis now renders normally again.
@ -194,9 +194,11 @@ describe("Emojis", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
expect(view.content.querySelector('.message:last-child .chat-msg__text').textContent).toBe('💩 😇');
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
await u.waitUntil(() => view.content.querySelector(last_msg_sel).textContent === '💩 😇');
expect(textarea.value).toBe('');
view.onKeyDown({
target: textarea,
@ -204,7 +206,8 @@ describe("Emojis", function () {
});
expect(textarea.value).toBe('💩 😇');
expect(view.model.messages.at(2).get('correcting')).toBe(true);
await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg:last-child')), 500);
sel = 'converse-chat-message:last-child .chat-msg'
await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector(sel)), 500);
textarea.value = textarea.value += 'This is no longer an emoji-only message';
view.onKeyDown({
target: textarea,
@ -213,7 +216,7 @@ describe("Emojis", function () {
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.models.length).toBe(3);
message = view.content.querySelector('.message:last-child .chat-msg__text');
let message = view.content.querySelector(last_msg_sel);
expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
textarea.value = ':smile: Hello world!';
@ -222,7 +225,7 @@ describe("Emojis", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
textarea.value = ':smile: :smiley: :imp:';
view.onKeyDown({
@ -230,7 +233,7 @@ describe("Emojis", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
message = view.content.querySelector('.message:last-child .chat-msg__text');
expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);

View File

@ -60,7 +60,7 @@ describe("A XEP-0317 MUC Hat", function () {
await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
hats = view.model.getOccupant("Terry").get('hats');
expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter");
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3, 1000);
badges = Array.from(view.el.querySelectorAll('.chat-msg .badge'));
expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");

View File

@ -247,7 +247,7 @@ describe("XEP-0363: HTTP File Upload", function () {
'name': "my-juliet.jpg"
};
view.model.sendFiles([file]);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
const iq = IQ_stanzas.pop();
@ -352,7 +352,7 @@ describe("XEP-0363: HTTP File Upload", function () {
'name': "my-juliet.jpg"
};
view.model.sendFiles([file]);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
const iq = IQ_stanzas.pop();
@ -575,7 +575,7 @@ describe("XEP-0363: HTTP File Upload", function () {
'name': "my-juliet.jpg"
};
view.model.sendFiles([file]);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
const iq = IQ_stanzas.pop();
expect(Strophe.serialize(iq)).toBe(
@ -606,18 +606,16 @@ describe("XEP-0363: HTTP File Upload", function () {
<get url="${message}" />
</slot>
</iq>`);
spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
const message = view.model.messages.at(0);
expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
message.set('progress', 0.5);
u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
.then(() => {
message.set('progress', 1);
u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
}).then(() => {
expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
done();
});
await u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5');
message.set('progress', 1);
await u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1');
expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
done();
});
_converse.connection._dataRecv(mock.createRequest(stanza));
}));

View File

@ -7,11 +7,15 @@ const $msg = converse.env.$msg;
const dayjs = converse.env.dayjs;
const u = converse.env.utils;
const sizzle = converse.env.sizzle;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
// See: https://xmpp.org/rfcs/rfc3921.html
// Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
describe("Message Archive Management", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
describe("The XEP-0313 Archive", function () {
it("is queried when the user enters a new MUC",
@ -194,8 +198,12 @@ describe("Message Archive Management", function () {
</iq>`);
_converse.connection._dataRecv(mock.createRequest(result));
await u.waitUntil(() => view.model.messages.length === 5);
const msg_els = view.content.querySelectorAll('.chat-msg__text');
expect(Array.from(msg_els).map(e => e.textContent).join(' ')).toBe("2nd Message 3rd Message 4th Message 5th Message 6th Message");
await u.waitUntil(() => view.content.querySelectorAll('.chat-msg__text').length);
const msg_els = Array.from(view.content.querySelectorAll('.chat-msg__text'));
await u.waitUntil(
() => msg_els.map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message",
1000
);
done();
}));
});
@ -253,7 +261,7 @@ describe("Message Archive Management", function () {
.c('count').t('16');
_converse.connection._dataRecv(mock.createRequest(iq_result));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
done();
@ -1038,9 +1046,8 @@ describe("Chatboxes", function () {
expect(view.model.messages.at(0).get('type')).toBe('error');
expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
let err_message = view.el.querySelector('.message.chat-error');
let err_message = await u.waitUntil(() => view.el.querySelector('.message.chat-error'));
err_message.querySelector('.retry').click();
expect(err_message.querySelector('.spinner')).not.toBe(null);
while (_converse.connection.IQ_stanzas.length) {
_converse.connection.IQ_stanzas.pop();
@ -1058,6 +1065,8 @@ describe("Chatboxes", function () {
`</query>`+
`</iq>`);
await u.waitUntil(() => view.el.querySelector('converse-chat-message .spinner'), 1000);
const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
.c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73623'})
.c('forwarded', {'xmlns':'urn:xmpp:forward:0'})

View File

@ -69,7 +69,7 @@ describe("A Chat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelector('.chat-msg__text').textContent)
@ -87,7 +87,6 @@ describe("A Chat Message", function () {
expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
expect(view.model.messages.at(0).get('correcting')).toBe(true);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
spyOn(_converse.connection, 'send');
@ -98,7 +97,6 @@ describe("A Chat Message", function () {
keyCode: 13 // Enter
});
expect(_converse.connection.send).toHaveBeenCalled();
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = _converse.connection.send.calls.all()[0].args[0];
expect(msg.toLocaleString())
@ -121,14 +119,13 @@ describe("A Chat Message", function () {
expect(keys.length).toBe(1);
expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
// Test that clicking the pencil icon a second time cancels editing.
action = view.el.querySelector('.chat-msg .chat-msg__action');
action.style.opacity = 1;
action.click();
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
expect(view.model.messages.at(0).get('correcting')).toBe(true);
@ -153,7 +150,7 @@ describe("A Chat Message", function () {
}).c('body').t('Hello').up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
// Test confirmation dialog
@ -203,7 +200,7 @@ describe("A Chat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelector('.chat-msg__text').textContent)
.toBe('But soft, what light through yonder airlock breaks?');
@ -279,7 +276,7 @@ describe("A Chat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
textarea.value = 'Arise, fair sun, and kill the envious moon';
@ -288,7 +285,7 @@ describe("A Chat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
view.onKeyDown({
@ -372,7 +369,7 @@ describe("A Chat Message", function () {
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = $msg({
'xmlns': 'jabber:client',
@ -384,7 +381,7 @@ describe("A Chat Message", function () {
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = $msg({
'xmlns': 'jabber:client',
@ -396,7 +393,7 @@ describe("A Chat Message", function () {
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = $msg({
'xmlns': 'jabber:client',
@ -408,7 +405,7 @@ describe("A Chat Message", function () {
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = $msg({
'xmlns': 'jabber:client',
@ -420,7 +417,7 @@ describe("A Chat Message", function () {
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
// Insert <composing> message, to also check that
// text messages are inserted correctly with
@ -434,7 +431,8 @@ describe("A Chat Message", function () {
.c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
.tree();
_converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
expect(csntext.trim()).toEqual('Mercutio is typing');
msg = $msg({
'id': _converse.connection.getUniqueId(),
@ -446,7 +444,7 @@ describe("A Chat Message", function () {
.c('body').t("latest message")
.tree();
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
view.clearSpinner(); //cleanup
expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
@ -473,7 +471,7 @@ describe("A Chat Message", function () {
el = sizzle('.chat-msg:eq(1)', view.content).pop();
expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
el = sizzle('.chat-msg:eq(2)', view.content).pop();
expect(el.querySelector('.chat-msg__text').textContent)
.toEqual('another inbetween message');
@ -492,7 +490,7 @@ describe("A Chat Message", function () {
el = sizzle('.chat-msg:eq(4)', view.content).pop();
expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
expect(u.hasClass('chat-msg--followup', el)).toBe(false);
day = sizzle('.date-separator:last', view.content).pop();
@ -624,8 +622,8 @@ describe("A Chat Message", function () {
expect(msg_obj.get('sender')).toEqual('me');
expect(msg_obj.get('is_delayed')).toEqual(false);
// Now check that the message appears inside the chatbox in the DOM
const msg_txt = view.el.querySelector('.chat-content .chat-msg .chat-msg__text').textContent;
expect(msg_txt).toEqual(msgtext);
const msg_el = await u.waitUntil(() => view.el.querySelector('.chat-content .chat-msg .chat-msg__text'));
expect(msg_el.textContent).toEqual(msgtext);
done();
}));
@ -751,7 +749,7 @@ describe("A Chat Message", function () {
await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
await mock.openChatBoxFor(_converse, contact_jid);
await mock.clearChatBoxMessages(_converse, contact_jid);
const one_day_ago = dayjs().subtract(1, 'day');
const chatbox = _converse.chatboxes.get(contact_jid);
const view = _converse.chatboxviews.get(contact_jid);
@ -766,7 +764,7 @@ describe("A Chat Message", function () {
.c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
expect(chatbox.messages.length).toEqual(1);
@ -798,7 +796,7 @@ describe("A Chat Message", function () {
}).c('body').t(message).up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
await _converse.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
// Check that there is a <time> element, with the required props.
@ -823,9 +821,9 @@ describe("A Chat Message", function () {
const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.msgs_container).pop().textContent;
expect(msg_txt).toEqual(message);
expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__text').textContent).toEqual(message);
expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message);
expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
done();
}));
@ -845,7 +843,7 @@ describe("A Chat Message", function () {
expect(view.model.sendMessage).toHaveBeenCalled();
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
done();
}));
@ -863,10 +861,10 @@ describe("A Chat Message", function () {
spyOn(view.model, 'sendMessage').and.callThrough();
mock.sendMessage(view, message);
expect(view.model.sendMessage).toHaveBeenCalled();
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML)
expect(msg.innerHTML.replace(/<!---->/g, ''))
.toEqual('This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
done();
}));
@ -886,8 +884,8 @@ describe("A Chat Message", function () {
<body>Hey\nHave you heard the news?</body>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelector('.chat-msg__text').innerHTML).toBe('Hey<br>Have you heard the news?');
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelector('.chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard the news?');
stanza = u.toStanza(`
<message from="${contact_jid}"
type="chat"
@ -895,8 +893,8 @@ describe("A Chat Message", function () {
<body>Hey\n\n\nHave you heard the news?</body>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br><br>Have you heard the news?');
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\n\nHave you heard the news?');
stanza = u.toStanza(`
<message from="${contact_jid}"
type="chat"
@ -904,8 +902,8 @@ describe("A Chat Message", function () {
<body>Hey\nHave you heard\nthe news?</body>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br>Have you heard<br>the news?');
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard\nthe news?');
done();
}));
@ -925,16 +923,20 @@ describe("A Chat Message", function () {
await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length, 1000)
expect(view.model.sendMessage).toHaveBeenCalled();
let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
expect(msg.innerHTML.trim()).toEqual(
`<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg" class="chat-image__link"><img src="${message}" class="chat-image img-thumbnail"></a>`);
expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(
`<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
`<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg">`+
`</a>`);
message += "?param1=val1&param2=val2";
mock.sendMessage(view, message);
await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
expect(view.model.sendMessage).toHaveBeenCalled();
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
expect(msg.innerHTML.trim()).toEqual(
'<a target="_blank" rel="noopener" href="'+base_url+'/logo/conversejs-filled.svg?param1=val1&amp;param2=val2" class="chat-image__link"><img'+
' src="'+message.replace(/&/g, '&amp;')+'" class="chat-image img-thumbnail"></a>')
expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(
`<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&amp;param2=val2">`+
`<img class="chat-image img-thumbnail" src="${message.replace(/&/g, '&amp;')}">`+
`</a>`);
// Test now with two images in one message
message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
@ -981,7 +983,7 @@ describe("A Chat Message", function () {
it("will be correctly identified and rendered as a followup message",
mock.initConverse(
['rosterGroupsFetched'], {},
['rosterGroupsFetched'], {'debounced_content_rendering': false},
async function (done, _converse) {
await mock.waitForRoster(_converse, 'current');
@ -1006,7 +1008,7 @@ describe("A Chat Message", function () {
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
const view = _converse.api.chatviews.get(sender_jid);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
jasmine.clock().tick(3*ONE_MINUTE_LATER);
_converse.handleMessageStanza($msg({
@ -1016,7 +1018,7 @@ describe("A Chat Message", function () {
'id': u.getUniqueId()
}).c('body').t("Another message 3 minutes later").up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
jasmine.clock().tick(11*ONE_MINUTE_LATER);
_converse.handleMessageStanza($msg({
@ -1026,7 +1028,7 @@ describe("A Chat Message", function () {
'id': u.getUniqueId()
}).c('body').t("Another message 14 minutes since we started").up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
jasmine.clock().tick(1*ONE_MINUTE_LATER);
@ -1037,26 +1039,29 @@ describe("A Chat Message", function () {
'id': _converse.connection.getUniqueId()
}).c('body').t("Another message 1 minute and 1 second since the previous one").up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
jasmine.clock().tick(1*ONE_MINUTE_LATER);
await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
expect(view.content.querySelectorAll('.message').length).toBe(6);
expect(view.content.querySelectorAll('.chat-msg').length).toBe(5);
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
"Another message 3 minutes later");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(false);
expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
"Another message 14 minutes since we started");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(true);
expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
"Another message 1 minute and 1 second since the previous one");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(false);
expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
"Another message within 10 minutes, but from a different person");
// Let's add a delayed, inbetween message
@ -1070,26 +1075,32 @@ describe("A Chat Message", function () {
}).c('body').t("A delayed message, sent 5 minutes since we started").up()
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
.tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelectorAll('.message').length).toBe(7);
expect(view.content.querySelectorAll('.chat-msg').length).toBe(6);
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
"Another message 3 minutes later");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(true);
expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
"A delayed message, sent 5 minutes since we started");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(true);
expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
"Another message 14 minutes since we started");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(true);
expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
"Another message 1 minute and 1 second since the previous one");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(false);
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(7)))).toBe(false);
expect(view.content.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
"Another message within 10 minutes, but from a different person");
_converse.handleMessageStanza(
$msg({
@ -1101,29 +1112,28 @@ describe("A Chat Message", function () {
.c('body').t("A carbon message 4 minutes later").up()
.c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
.tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelectorAll('.message').length).toBe(8);
expect(view.content.querySelectorAll('.chat-msg').length).toBe(7);
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
"Another message 3 minutes later");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(false);
expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
"A carbon message 4 minutes later");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(false);
expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
"A delayed message, sent 5 minutes since we started");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(true);
expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
"Another message 14 minutes since we started");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(true);
expect(view.content.querySelector('.message:nth-child(7) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(7)))).toBe(true);
expect(view.content.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
"Another message 1 minute and 1 second since the previous one");
expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(8)'))).toBe(false);
expect(view.content.querySelector('.message:nth-child(8) .chat-msg__text').textContent).toBe(
expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(8)))).toBe(false);
expect(view.content.querySelector(`${nth_child(8)} .chat-msg__text`).textContent).toBe(
"Another message within 10 minutes, but from a different person");
jasmine.clock().uninstall();
@ -1205,7 +1215,7 @@ describe("A Chat Message", function () {
});
const chatbox = _converse.chatboxes.get(contact_jid);
expect(chatbox).toBeDefined();
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
let msg_obj = chatbox.messages.models[0];
let msg_id = msg_obj.get('msgid');
let msg = $msg({
@ -1214,8 +1224,7 @@ describe("A Chat Message", function () {
'id': u.getUniqueId(),
}).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
_converse.connection._dataRecv(mock.createRequest(msg));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__receipt').length === 1);
// Also handle receipts with type 'chat'. See #1353
spyOn(_converse, 'handleMessageStanza').and.callThrough();
@ -1225,7 +1234,7 @@ describe("A Chat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg_obj = chatbox.messages.models[1];
msg_id = msg_obj.get('msgid');
@ -1236,8 +1245,7 @@ describe("A Chat Message", function () {
'id': u.getUniqueId(),
}).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
_converse.connection._dataRecv(mock.createRequest(msg));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(2);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__receipt').length === 2);
expect(_converse.handleMessageStanza.calls.count()).toBe(1);
done();
}));
@ -1377,7 +1385,7 @@ describe("A Chat Message", function () {
'type': 'chat',
'id': msg_id,
}).c('body').t('But soft, what light through yonder airlock breaks?').tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelector('.chat-msg__text').textContent)
.toBe('But soft, what light through yonder airlock breaks?');
@ -1411,7 +1419,7 @@ describe("A Chat Message", function () {
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
view.el.querySelector('.chat-msg__content .fa-edit').click();
const modal = view.model.messages.at(0).message_versions_modal;
const modal = await u.waitUntil(() => view.el.querySelector('converse-chat-message').message_versions_modal);
await u.waitUntil(() => u.isVisible(modal.el), 1000);
const older_msgs = modal.el.querySelectorAll('.older-msg');
expect(older_msgs.length).toBe(2);
@ -1456,7 +1464,7 @@ describe("A Chat Message", function () {
await _converse.handleMessageStanza(msg);
const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
// Check that the chatbox and its view now exist
@ -1508,7 +1516,7 @@ describe("A Chat Message", function () {
_converse.allow_non_roster_messaging = true;
await _converse.handleMessageStanza(msg);
view = _converse.chatboxviews.get(sender_jid);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
// Check that the chatbox and its view now exist
chatbox = await _converse.api.chats.get(sender_jid);
@ -1563,7 +1571,7 @@ describe("A Chat Message", function () {
let msg_text = 'This message will not be sent, due to an error';
const view = _converse.api.chatviews.get(sender_jid);
const message = await view.model.sendMessage(msg_text);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
expect(msg_txt).toEqual(msg_text);
@ -1598,8 +1606,9 @@ describe("A Chat Message", function () {
.c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
.t('Server-to-server connection failed: Connecting failed: connection timeout');
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelector('.chat-error').textContent.trim()).toEqual(error_txt);
await u.waitUntil(() => view.content.querySelector('.chat-msg__error').textContent.trim() === error_txt);
const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
stanza = $msg({
'to': _converse.connection.jid,
'type': 'error',
@ -1609,10 +1618,10 @@ describe("A Chat Message", function () {
.c('error', {'type': 'cancel'})
.c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
.c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
.t('Server-to-server connection failed: Connecting failed: connection timeout');
.t(other_error_txt);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
await u.waitUntil(() =>
view.content.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt);
// We don't render duplicates
stanza = $msg({
@ -1626,13 +1635,11 @@ describe("A Chat Message", function () {
.c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
.t('Server-to-server connection failed: Connecting failed: connection timeout');
_converse.connection._dataRecv(mock.createRequest(stanza));
expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
expect(view.content.querySelectorAll('.chat-msg__error').length).toEqual(2);
msg_text = 'This message will be sent, and also receive an error';
const third_message = await view.model.sendMessage(msg_text);
await new Promise(resolve => view.once('messageInserted', resolve));
msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
expect(msg_txt).toEqual(msg_text);
await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view.content).pop()?.textContent === msg_text);
// A different error message will however render
stanza = $msg({
@ -1647,8 +1654,8 @@ describe("A Chat Message", function () {
.t('Something else went wrong as well');
_converse.connection._dataRecv(mock.createRequest(stanza));
await u.waitUntil(() => view.model.messages.length > 3);
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.content.querySelectorAll('.chat-error').length).toEqual(3);
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.content.querySelectorAll('.chat-error').length).toEqual(1);
done();
}));
@ -1709,7 +1716,7 @@ describe("A Chat Message", function () {
id: _converse.connection.getUniqueId(),
}).c('body').t('Message: '+i).up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
promises.push(new Promise(resolve => view.once('messageInserted', resolve)));
promises.push(new Promise(resolve => view.model.messages.once('rendered', resolve)));
}
await Promise.all(promises);
// XXX Fails on Travis
@ -1728,7 +1735,7 @@ describe("A Chat Message", function () {
id: u.getUniqueId()
}).c('body').t(message).up()
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.model.messages.length > 20, 1000);
// Now check that the message appears inside the chatbox in the DOM
const msg_txt = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent;
@ -1813,16 +1820,16 @@ describe("A Chat Message", function () {
<x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
</message>`)
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
let msg = view.el.querySelector('.chat-msg .chat-msg__text');
expect(msg.classList.length).toEqual(1);
expect(u.hasClass('chat-msg__text', msg)).toBe(true);
expect(msg.textContent).toEqual('Have you heard this funny audio?');
let media = view.el.querySelector('.chat-msg .chat-msg__media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
`<!----> <audio controls="" src="https://montague.lit/audio.mp3"></audio> `+
`<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3"><!---->Download audio file "audio.mp3"<!----></a><!---->`);
expect(media.innerHTML.replace(/<!---->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
`<audio controls="" src="https://montague.lit/audio.mp3"></audio> `+
`<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">Download audio file "audio.mp3"</a>`);
// If the <url> and <body> contents is the same, don't duplicate.
stanza = u.toStanza(`
@ -1833,14 +1840,14 @@ describe("A Chat Message", function () {
<x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('Have you heard this funny audio?'); // Emtpy
media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
`<!----> <audio controls="" src="https://montague.lit/audio.mp3"></audio> `+
expect(media.innerHTML.replace(/<!---->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
`<audio controls="" src="https://montague.lit/audio.mp3"></audio> `+
`<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">`+
`<!---->Download audio file "audio.mp3"<!----></a><!---->`);
`Download audio file "audio.mp3"</a>`);
done();
}));
@ -1881,9 +1888,9 @@ describe("A Chat Message", function () {
<x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('Have you seen this funny video?');
media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
`<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
@ -1908,7 +1915,7 @@ describe("A Chat Message", function () {
<x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg a').length, 1000);
const msg = view.el.querySelector('.chat-msg .chat-msg__text');
expect(u.hasClass('chat-msg__text', msg)).toBe(true);
@ -2048,7 +2055,7 @@ describe("A XEP-0333 Chat Marker", function () {
<stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.model.messages.length).toBe(1);

View File

@ -4,6 +4,8 @@ let _converse, initConverse;
const converseLoaded = new Promise(resolve => window.addEventListener('converse-loaded', resolve));
jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000;
mock.initConverse = function (promise_names=[], settings=null, func) {
if (typeof promise_names === "function") {
func = promise_names;
@ -337,12 +339,6 @@ window.addEventListener('converse-loaded', () => {
await view.model.messages.fetched;
};
mock.clearChatBoxMessages = function (converse, jid) {
const view = converse.chatboxviews.get(jid);
view.msgs_container.innerHTML = '';
return view.model.messages.clearStore();
};
mock.createContact = async function (_converse, name, ask, requesting, subscription) {
const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
if (_converse.roster.get(jid)) {
@ -449,7 +445,7 @@ window.addEventListener('converse-loaded', () => {
}
mock.sendMessage = function (view, message) {
const promise = new Promise(resolve => view.once('messageInserted', resolve));
const promise = new Promise(resolve => view.model.messages.once('rendered', resolve));
view.el.querySelector('.chat-textarea').value = message;
view.onKeyDown({
target: view.el.querySelector('textarea.chat-textarea'),

View File

@ -1,14 +1,14 @@
/*global mock */
const _ = converse.env._,
$pres = converse.env.$pres,
$iq = converse.env.$iq,
$msg = converse.env.$msg,
Model = converse.env.Model,
Strophe = converse.env.Strophe,
Promise = converse.env.Promise,
sizzle = converse.env.sizzle,
u = converse.env.utils;
const _ = converse.env._;
const $pres = converse.env.$pres;
const $iq = converse.env.$iq;
const $msg = converse.env.$msg;
const Model = converse.env.Model;
const Strophe = converse.env.Strophe;
const Promise = converse.env.Promise;
const sizzle = converse.env.sizzle;
const u = converse.env.utils;
describe("Groupchats", function () {
@ -527,7 +527,7 @@ describe("Groupchats", function () {
<body>This is a message</body>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
expect(sizzle('.chat-msg__text').length).toBe(1);
@ -562,7 +562,7 @@ describe("Groupchats", function () {
<body>This is a message</body>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
expect(sizzle('.chat-msg__text').length).toBe(1);
@ -645,8 +645,7 @@ describe("Groupchats", function () {
</message>`)));
await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2);
let el = sizzle('.chat-info__message', view.el).pop();
expect(el.textContent.trim()).toBe('Topic set by ralphm');
await u.waitUntil(() => sizzle('.chat-info__message', view.el).pop()?.textContent.trim() === 'Topic set by ralphm');
await u.waitUntil(() => desc.textContent.trim() === 'This is a new topic');
// Doesn't show multiple subsequent topic change notifications
@ -666,7 +665,7 @@ describe("Groupchats", function () {
await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4);
await u.waitUntil(() => desc.textContent.trim() === "Some1's topic");
expect(sizzle('.chat-info__message', view.el).length).toBe(2);
el = sizzle('.chat-info__message', view.el).pop();
const el = sizzle('.chat-info__message', view.el).pop();
expect(el.textContent.trim()).toBe('Topic set by some1');
// Removes current topic
@ -676,8 +675,8 @@ describe("Groupchats", function () {
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5);
await u.waitUntil(() => view.el.querySelector('.chat-head__desc') === null);
expect(view.el.querySelector('.chat-info:last-child').textContent.trim()).toBe("Topic cleared by some1");
await u.waitUntil(() => view.el.querySelector('.chat-head__desc').textContent.replace(/<!---->/g, '') === '');
await u.waitUntil(() => view.el.querySelector('converse-chat-message:last-child .chat-info').textContent.trim() === "Topic cleared by some1");
done();
}));
});
@ -701,12 +700,11 @@ describe("Groupchats", function () {
}).c('body').t(message).tree();
await view.model.handleMessageStanza(msg);
spyOn(view.model, 'clearMessages').and.callThrough();
await view.model.close();
await u.waitUntil(() => view.model.clearMessages.calls.count());
expect(view.model.messages.length).toBe(0);
expect(view.msgs_container.innerHTML).toBe('');
expect(view.el.querySelector('converse-chat-history')).toBe(null);
done()
}));
@ -861,7 +859,7 @@ describe("Groupchats", function () {
'type': 'groupchat'
}).c('body').t('hello world').tree();
_converse.connection._dataRecv(mock.createRequest(msg));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
// Add another entrant, otherwise the above message will be
// collapsed if "newguy" leaves immediately again
@ -1082,7 +1080,6 @@ describe("Groupchats", function () {
</presence>`);
_converse.connection._dataRecv(mock.createRequest(presence));
await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat");
presence = u.toStanza(
`<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand">
<x xmlns="http://jabber.org/protocol/muc#user">
@ -1158,10 +1155,6 @@ describe("Groupchats", function () {
await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
"romeo, jcbrand and others have entered the groupchat\nfuvuv has left the groupchat");
// XXX: hack so that we can test leave/enter of occupants
// who were already in the room when we joined.
view.msgs_container.innerHTML = '';
presence = u.toStanza(
`<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
<status>Disconnected: closed</status>
@ -2042,7 +2035,7 @@ describe("Groupchats", function () {
preventDefault: function preventDefault () {},
keyCode: 13
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
@ -2102,7 +2095,7 @@ describe("Groupchats", function () {
type: 'groupchat',
id: u.getUniqueId(),
}).c('body').t(message).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
// Now check that the message appears inside the chatbox in the DOM
const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
expect(msg_txt).toEqual(message);
@ -2898,8 +2891,10 @@ describe("Groupchats", function () {
textarea.value = '/help';
view.onKeyDown(enter);
let info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
expect(info_messages.length).toBe(20);
await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view.el).length);
const chat_help_el = view.el.querySelector('converse-chat-help');
let info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(19);
expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
@ -2919,47 +2914,49 @@ describe("Groupchats", function () {
expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
occupant.set('affiliation', 'admin');
textarea = view.el.querySelector('.chat-textarea');
textarea.value = '/clear';
view.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
view.el.querySelector('.close-chat-help').click();
await u.waitUntil(() => chat_help_el.hidden);
textarea.value = '/help';
view.onKeyDown(enter);
info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
expect(info_messages.length).toBe(19);
await u.waitUntil(() => !chat_help_el.hidden);
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(18);
let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
expect(commands).toEqual([
"You can run the following commands",
"/admin", "/ban", "/clear", "/deop", "/destroy",
"/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
"/op", "/register", "/revoke", "/subject", "/topic", "/voice"
]);
occupant.set('affiliation', 'member');
textarea.value = '/clear';
view.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
view.el.querySelector('.close-chat-help').click();
await u.waitUntil(() => chat_help_el.hidden);
textarea.value = '/help';
view.onKeyDown(enter);
info_messages = sizzle('.chat-info', view.el).slice(1);
await u.waitUntil(() => !chat_help_el.hidden);
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(9);
commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
view.el.querySelector('.close-chat-help').click();
await u.waitUntil(() => chat_help_el.hidden);
expect(view.model.get('show_help_messages')).toBe(false);
occupant.set('role', 'participant');
// Role changes causes rerender, so we need to get the new textarea
textarea = view.el.querySelector('.chat-textarea');
textarea.value = '/clear';
view.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
textarea.value = '/help';
view.onKeyDown(enter);
info_messages = sizzle('.chat-info', view.el).slice(1);
await u.waitUntil(() => view.model.get('show_help_messages'));
await u.waitUntil(() => !chat_help_el.hidden);
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(5);
commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
@ -2967,13 +2964,13 @@ describe("Groupchats", function () {
// Test that /topic is available if all users may change the subject
// Note: we're making a shortcut here, this value should never be set manually
view.model.config.set('changesubject', true);
textarea.value = '/clear';
view.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
view.el.querySelector('.close-chat-help').click();
await u.waitUntil(() => chat_help_el.hidden);
textarea.value = '/help';
view.onKeyDown(enter);
info_messages = sizzle('.chat-info', view.el).slice(1);
await u.waitUntil(() => !chat_help_el.hidden, 1000);
info_messages = sizzle('.chat-info', chat_help_el);
expect(info_messages.length).toBe(7);
commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
@ -2995,8 +2992,9 @@ describe("Groupchats", function () {
textarea.value = '/help';
view.onKeyDown(enter);
await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length);
const info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
expect(info_messages.length).toBe(18);
expect(info_messages.length).toBe(17);
expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
@ -3014,7 +3012,6 @@ describe("Groupchats", function () {
expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
done();
}));
@ -3432,7 +3429,7 @@ describe("Groupchats", function () {
textarea.value = '/ban joe22';
view.onFormSubmitted(new Event('submit'));
await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
await u.waitUntil(() => view.el.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
"Error: couldn't find a groupchat participant based on your arguments");
done();
}));
@ -3520,6 +3517,7 @@ describe("Groupchats", function () {
}).c('actor', {'nick': 'romeo'}).up()
.c('reason').t("You're annoying").up().up()
.c('status', {'code': '307'});
_converse.connection._dataRecv(mock.createRequest(presence));
await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
@ -4996,11 +4994,13 @@ describe("Groupchats", function () {
// See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
const timeout_functions = [];
spyOn(window, 'setTimeout').and.callFake(f => {
const remove_notifications_timeouts = [];
const setTimeout = window.setTimeout;
spyOn(window, 'setTimeout').and.callFake((f, w) => {
if (f.toString() === "() => this.removeNotification(actor, state)") {
timeout_functions.push(f)
remove_notifications_timeouts.push(f)
}
setTimeout(f, w);
});
// <composing> state
@ -5014,7 +5014,7 @@ describe("Groupchats", function () {
csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
expect(csntext.trim()).toEqual('newguy is typing');
expect(timeout_functions.length).toBe(1);
expect(remove_notifications_timeouts.length).toBe(1);
expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
@ -5048,7 +5048,6 @@ describe("Groupchats", function () {
await view.model.handleMessageStanza(msg);
await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
// Check that new messages appear under the chat state notifications
msg = $msg({
from: `${muc_jid}/some1`,
id: u.getUniqueId(),
@ -5056,7 +5055,7 @@ describe("Groupchats", function () {
type: 'groupchat'
}).c('body').t('hello world').tree();
await view.model.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve), 1000);
const messages = view.el.querySelectorAll('.message');
expect(messages.length).toBe(2);
@ -5064,7 +5063,7 @@ describe("Groupchats", function () {
expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
// Test that the composing notifications get removed via timeout.
timeout_functions[0]();
remove_notifications_timeouts[0]();
await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
done();
}));
@ -5186,34 +5185,42 @@ describe("Groupchats", function () {
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'Hello world';
view.onFormSubmitted(new Event('submit'));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
let stanza = u.toStanza(`
<message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
<message id="${view.model.messages.at(0).get('msgid')}"
xmlns="jabber:client"
type="error"
to="troll@montague.lit/resource"
from="trollbox@montague.lit">
<error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
await u.waitUntil(() => view.el.querySelector('.chat-msg__error')?.textContent.trim(), 1000);
expect(view.el.querySelector('.chat-msg__error').textContent.trim()).toBe(
"Your message was not delivered because you weren't allowed to send it.");
textarea.value = 'Hello again';
view.onFormSubmitted(new Event('submit'));
await new Promise(resolve => view.once('messageInserted', resolve));
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__text').length === 2);
stanza = u.toStanza(`
<message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
<message id="${view.model.messages.at(1).get('msgid')}"
xmlns="jabber:client"
type="error"
to="troll@montague.lit/resource"
from="trollbox@montague.lit">
<error type="auth">
<forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
</error>
</message>`);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelector('.message:last-child').textContent.trim()).toBe(
'Your message was not delivered because you weren\'t allowed to send it. '+
'The message from the server is: "Thou shalt not!"')
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__error').length === 2);
const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error';
await u.waitUntil(() => view.el.querySelector(sel)?.textContent.trim());
expect(view.el.querySelector(sel).textContent.trim()).toBe('Thou shalt not!')
done();
}));

View File

@ -2,9 +2,12 @@
const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env;
const u = converse.env.utils;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("A Groupchat Message", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
describe("which is succeeded by an error message", function () {
@ -25,7 +28,7 @@ describe("A Groupchat Message", function () {
'keyCode': 13 // Enter
}
view.onKeyDown(enter_event);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = view.model.messages.at(0);
const err_msg_text = "Message rejected because you're sending messages too quickly";
@ -44,7 +47,7 @@ describe("A Groupchat Message", function () {
const message = view.model.messages.at(0);
expect(message.get('received')).toBeUndefined();
expect(message.get('body')).toBe('hello world');
expect(message.get('error')).toBe(err_msg_text);
expect(message.get('error_text')).toBe(err_msg_text);
done();
}));
});
@ -180,7 +183,7 @@ describe("A Groupchat Message", function () {
.c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
.tree();
await view.model.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelector('.chat-msg')).not.toBe(null);
done();
}));
@ -203,7 +206,7 @@ describe("A Groupchat Message", function () {
type: 'groupchat'
}).c('body').t(message).tree();
await view.model.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
done();
}));
@ -435,7 +438,7 @@ describe("A Groupchat Message", function () {
type: 'groupchat'
}).c('body').t('Another message!').tree();
await view.model.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
expect(view.model.messages.last().occupant.get('role')).toBe('participant');
expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
@ -472,7 +475,7 @@ describe("A Groupchat Message", function () {
type: 'groupchat'
}).c('body').t('Message from someone not in the MUC right now').tree();
await view.model.handleMessageStanza(msg);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.last().occupant).toBeUndefined();
// Check that there's a new "add" event handler, for when the occupant appears.
expect(view.model.occupants._events.add.length).toBe(add_events+1);
@ -583,7 +586,7 @@ describe("A Groupchat Message", function () {
await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
'But soft, what light through yonder chimney breaks?', 500);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
await u.waitUntil(() => view.el.querySelector('.chat-msg__content .fa-edit'));
await view.model.handleMessageStanza($msg({
'from': 'lounge@montague.lit/newguy',
@ -597,8 +600,9 @@ describe("A Groupchat Message", function () {
'But soft, what light through yonder window breaks?', 500);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
view.el.querySelector('.chat-msg__content .fa-edit').click();
const modal = view.model.messages.at(0).message_versions_modal;
const edit = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .fa-edit'));
edit.click();
const modal = await u.waitUntil(() => view.el.querySelector('converse-chat-message').message_versions_modal);
await u.waitUntil(() => u.isVisible(modal.el), 1000);
const older_msgs = modal.el.querySelectorAll('.older-msg');
expect(older_msgs.length).toBe(2);
@ -641,11 +645,10 @@ describe("A Groupchat Message", function () {
target: textarea,
keyCode: 38 // Up arrow
});
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
expect(view.model.messages.at(0).get('correcting')).toBe(true);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
spyOn(_converse.connection, 'send');
textarea.value = 'But soft, what light through yonder window breaks?';
@ -688,7 +691,7 @@ describe("A Groupchat Message", function () {
'to': 'romeo@montague.lit',
'type': 'groupchat'
}).c('body').t('Hello world').tree());
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
// Test that pressing the down arrow cancels message correction
@ -729,7 +732,7 @@ describe("A Groupchat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
const msg_obj = view.model.messages.at(0);
@ -807,7 +810,7 @@ describe("A Groupchat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
const msg_obj = view.model.messages.at(0);
@ -841,7 +844,7 @@ describe("A Groupchat Message", function () {
preventDefault: function preventDefault () {},
keyCode: 13 // Enter
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
.toBe("But soft, what light through yonder airlock breaks?");
@ -929,7 +932,7 @@ describe("A Groupchat Message", function () {
await view.model.handleMessageStanza(msg);
const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
expect(message.classList.length).toEqual(1);
expect(message.innerHTML).toBe(
expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
'hello <span class="mention">z3r0</span> '+
'<span class="mention mention--self badge badge-info">tom</span> '+
'<span class="mention">mr.robot</span>, how are you?');
@ -970,7 +973,7 @@ describe("A Groupchat Message", function () {
await view.model.handleMessageStanza(msg);
const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
expect(message.classList.length).toEqual(1);
expect(message.innerHTML).toBe(
expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
'&gt;hello <span class="mention">z3r0</span> '+
'<span class="mention mention--self badge badge-info">tom</span> '+
'<span class="mention">mr.robot</span>, how are you?');
@ -1144,7 +1147,7 @@ describe("A Groupchat Message", function () {
}
spyOn(_converse.connection, 'send');
view.onKeyDown(enter_event);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = _converse.connection.send.calls.all()[0].args[0];
expect(msg.toLocaleString())
.toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
@ -1191,7 +1194,14 @@ describe("A Groupchat Message", function () {
}
spyOn(_converse.connection, 'send');
view.onKeyDown(enter_event);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
await u.waitUntil(() =>
view.content.querySelector(last_msg_sel).innerHTML.replace(/<!---->/g, '') ===
'hello <span class="mention">z3r0</span> <span class="mention">gibson</span> <span class="mention">mr.robot</span>, how are you?'
);
const msg = _converse.connection.send.calls.all()[0].args[0];
expect(msg.toLocaleString())
.toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
@ -1269,7 +1279,7 @@ describe("A Groupchat Message", function () {
'keyCode': 13 // Enter
}
view.onKeyDown(enter_event);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
const msg = _converse.connection.send.calls.all()[0].args[0];
expect(msg.toLocaleString())

View File

@ -65,7 +65,7 @@ describe("Notifications", function () {
type: 'groupchat'
}).c('body').t(message).tree();
_converse.connection._dataRecv(mock.createRequest(msg));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
expect(_converse.showMessageNotification).toHaveBeenCalled();
@ -94,7 +94,7 @@ describe("Notifications", function () {
_converse.connection._dataRecv(mock.createRequest(stanza));
await u.waitUntil(() => _converse.chatboxviews.keys().length);
const view = _converse.chatboxviews.get('notify.example.com');
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy();
expect(_converse.showMessageNotification).toHaveBeenCalled();
done();

View File

@ -199,7 +199,7 @@ describe("The OMEMO module", function() {
.up().up()
.c('payload').t(obj.payload);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.length).toBe(2);
expect(view.el.querySelectorAll('.chat-msg__body')[1].textContent.trim())
.toBe('This is an encrypted message from the contact');
@ -218,7 +218,7 @@ describe("The OMEMO module", function() {
.up().up()
.c('payload').t(obj.payload);
_converse.connection._dataRecv(mock.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.model.messages.length > 1);
expect(view.model.messages.length).toBe(3);
expect(view.el.querySelectorAll('.chat-msg__body')[2].textContent.trim())
@ -435,7 +435,7 @@ describe("The OMEMO module", function() {
</message>
`);
_converse.connection._dataRecv(mock.createRequest(carbon));
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
expect(view.model.messages.length).toBe(1);
expect(view.el.querySelector('.chat-msg__body').textContent.trim())
.toBe('This is an encrypted carbon message from another device of mine');
@ -1258,7 +1258,7 @@ describe("The OMEMO module", function() {
it("adds a toolbar button for starting an encrypted groupchat session",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {'view_mode': 'fullscreen'},
['rosterGroupsFetched', 'chatBoxesFetched'], {},
async function (done, _converse) {
await mock.waitUntilDiscoConfirmed(
@ -1416,8 +1416,7 @@ describe("The OMEMO module", function() {
_converse.connection._dataRecv(mock.createRequest(stanza));
await u.waitUntil(() => !view.model.get('omemo_supported'));
expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
await u.waitUntil(() => view.el.querySelector('.chat-error .chat-info__message')?.textContent.trim() ===
"oldguy doesn't appear to have a client that supports OMEMO. "+
"Encrypted chat will no longer be possible in this grouchat."
);

View File

@ -5,9 +5,13 @@ const Strophe = converse.env.Strophe;
const _ = converse.env._;
const sizzle = converse.env.sizzle;
const u = converse.env.utils;
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("XEP-0357 Push Notifications", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
it("can be enabled",
mock.initConverse(
['rosterGroupsFetched'], {

View File

@ -180,6 +180,7 @@ describe("Message Retractions", function () {
_converse.connection._dataRecv(mock.createRequest(received_stanza));
await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.model.messages.length).toBe(1);
@ -221,10 +222,8 @@ describe("Message Retractions", function () {
</message>
`);
const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve));
_converse.connection._dataRecv(mock.createRequest(retraction_stanza));
await u.waitUntil(() => view.model.messages.length === 1);
await promise;
const message = view.model.messages.at(0);
expect(message.get('dangling_retraction')).toBe(true);
expect(message.get('is_ephemeral')).toBe(false);
@ -628,8 +627,8 @@ describe("Message Retractions", function () {
`</apply-to>`+
`</message>`);
await u.waitUntil(() => view.model.messages.last().get('retracted'));
const message = view.model.messages.last();
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_ephemeral')).toBe(false);
expect(message.get('editable')).toBeFalsy();
@ -648,7 +647,7 @@ describe("Message Retractions", function () {
_converse.connection._dataRecv(mock.createRequest(reflection));
await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
expect(view.model.messages.length).toBe(1);
await u.waitUntil(() => view.model.messages.length === 1);
expect(view.model.messages.last().get('retracted')).toBeTruthy();
expect(view.model.messages.last().get('is_ephemeral')).toBe(false);
expect(view.model.messages.last().get('editable')).toBe(false);
@ -675,7 +674,7 @@ describe("Message Retractions", function () {
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.last().get('retracted')).toBeTruthy();
await u.waitUntil(() => view.model.messages.last().get('retracted'));
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('romeo has removed this message');
@ -695,20 +694,15 @@ describe("Message Retractions", function () {
</message>`);
_converse.connection._dataRecv(mock.createRequest(error));
await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__error').length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
expect(view.model.messages.length).toBe(2);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
expect(view.model.messages.at(0).get('editable')).toBeTruthy();
const err_msg = "Sorry, something went wrong while trying to retract your message."
expect(view.model.messages.at(1).get('message')).toBe(err_msg);
expect(view.model.messages.at(1).get('type')).toBe('error');
expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
const errmsg = view.el.querySelector('.chat-error');
expect(errmsg.textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
const errmsg = view.el.querySelector('.chat-msg__error');
expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message.");
done();
}));
@ -728,25 +722,23 @@ describe("Message Retractions", function () {
occupant.save('role', 'member');
await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator"))
await sendAndThenRetractMessage(_converse, view);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.last().get('retracted')).toBeTruthy();
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
expect(el.textContent.trim()).toBe('romeo has removed this message');
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
expect(view.model.messages.length).toBe(3);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
expect(view.model.messages.at(0).get('editable')).toBeTruthy();
const error_messages = view.el.querySelectorAll('.chat-error');
expect(error_messages.length).toBe(2);
expect(error_messages[0].textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
expect(error_messages[1].textContent.trim()).toBe("Timeout Error: No response from server");
const error_messages = view.el.querySelectorAll('.chat-msg__error');
expect(error_messages.length).toBe(1);
expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.');
done();
}));
@ -1009,7 +1001,6 @@ describe("Message Retractions", function () {
</message>
`);
spyOn(view.model, 'handleRetraction').and.callThrough();
const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
_converse.connection._dataRecv(mock.createRequest(tombstone));
const last_id = u.getUniqueId();
@ -1037,8 +1028,7 @@ describe("Message Retractions", function () {
.c('count').t('2');
_converse.connection._dataRecv(mock.createRequest(iq_result));
await promise;
expect(view.model.messages.length).toBe(1);
await u.waitUntil(() => view.model.messages.length === 1);
let message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
@ -1050,6 +1040,7 @@ describe("Message Retractions", function () {
message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
@ -1088,7 +1079,6 @@ describe("Message Retractions", function () {
</message>
`);
spyOn(view.model, 'handleModeration').and.callThrough();
const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
_converse.connection._dataRecv(mock.createRequest(tombstone));
const last_id = u.getUniqueId();
@ -1119,10 +1109,10 @@ describe("Message Retractions", function () {
.c('count').t('2');
_converse.connection._dataRecv(mock.createRequest(iq_result));
await promise;
await u.waitUntil(() => view.model.messages.length);
expect(view.model.messages.length).toBe(1);
let message = view.model.messages.at(0);
expect(message.get('retracted')).toBeTruthy();
await u.waitUntil(() => message.get('retracted'));
expect(message.get('is_tombstone')).toBe(true);
await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
@ -1134,6 +1124,8 @@ describe("Message Retractions", function () {
expect(message.get('retracted')).toBeTruthy();
expect(message.get('is_tombstone')).toBe(true);
expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length, 500);
expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);

View File

@ -1,7 +1,12 @@
/* global mock */
const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
describe("A spoiler message", function () {
beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
it("can be received with a hint",
mock.initConverse(
['rosterGroupsFetched', 'chatBoxesFetched'], {},
@ -32,11 +37,11 @@ describe("A spoiler message", function () {
_converse.connection._dataRecv(mock.createRequest(msg));
await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
const view = _converse.chatboxviews.get(sender_jid);
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
const message_content = view.el.querySelector('.chat-msg__text');
expect(message_content.textContent).toBe(spoiler);
await u.waitUntil(() => message_content.textContent === spoiler);
const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
done();
@ -72,9 +77,10 @@ describe("A spoiler message", function () {
await new Promise(resolve => view.model.messages.once('rendered', resolve));
await u.waitUntil(() => u.isVisible(view.el));
await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
await u.waitUntil(() => u.isVisible(view.el.querySelector('.chat-msg__author')));
expect(view.el.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
const message_content = view.el.querySelector('.chat-msg__text');
expect(message_content.textContent).toBe(spoiler);
await u.waitUntil(() => message_content.textContent === spoiler);
const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
expect(spoiler_hint_el.textContent).toBe('');
done();
@ -117,7 +123,7 @@ describe("A spoiler message", function () {
preventDefault: function preventDefault () {},
keyCode: 13
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
/* Test the XML stanza
*
@ -136,23 +142,26 @@ describe("A spoiler message", function () {
expect(spoiler_el === null).toBeFalsy();
expect(spoiler_el.textContent).toBe('');
const spoiler = 'This is the spoiler';
const body_el = stanza.querySelector('body');
expect(body_el.textContent).toBe('This is the spoiler');
expect(body_el.textContent).toBe(spoiler);
/* Test the HTML spoiler message */
expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
const message_content = view.el.querySelector('.chat-msg__text');
await u.waitUntil(() => message_content.textContent === spoiler);
const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
spoiler_toggle = view.el.querySelector('.spoiler-toggle');
expect(spoiler_toggle.textContent).toBe('Show more');
expect(spoiler_toggle.textContent.trim()).toBe('Show more');
spoiler_toggle.click();
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
expect(spoiler_toggle.textContent).toBe('Show less');
await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('collapsed'));
expect(spoiler_toggle.textContent.trim()).toBe('Show less');
spoiler_toggle.click();
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('collapsed'));
done();
}));
@ -197,7 +206,7 @@ describe("A spoiler message", function () {
preventDefault: function preventDefault () {},
keyCode: 13
});
await new Promise(resolve => view.once('messageInserted', resolve));
await new Promise(resolve => view.model.messages.once('rendered', resolve));
/* Test the XML stanza
*
@ -217,23 +226,26 @@ describe("A spoiler message", function () {
expect(spoiler_el === null).toBeFalsy();
expect(spoiler_el.textContent).toBe('This is the hint');
const spoiler = 'This is the spoiler'
const body_el = stanza.querySelector('body');
expect(body_el.textContent).toBe('This is the spoiler');
expect(body_el.textContent).toBe(spoiler);
/* Test the HTML spoiler message */
expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
const message_content = view.el.querySelector('.chat-msg__text');
await u.waitUntil(() => message_content.textContent === spoiler);
const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
spoiler_toggle = view.el.querySelector('.spoiler-toggle');
expect(spoiler_toggle.textContent).toBe('Show more');
expect(spoiler_toggle.textContent.trim()).toBe('Show more');
spoiler_toggle.click();
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
expect(spoiler_toggle.textContent).toBe('Show less');
await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('collapsed'));
expect(spoiler_toggle.textContent.trim()).toBe('Show less');
spoiler_toggle.click();
expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('collapsed'));
done();
}));
});

View File

@ -24,44 +24,44 @@ describe("XSS", function () {
await mock.sendMessage(view, message);
let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
expect(window.alert).not.toHaveBeenCalled();
message = "<img src=x onerror=alert('XSS')//";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS')//");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS')//");
message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
message = "><img src=x onerror=alert('XSS');>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
expect(window.alert).not.toHaveBeenCalled();
done();
@ -84,43 +84,43 @@ describe("XSS", function () {
await mock.sendMessage(view, message);
let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('&lt;svg onload=alert(1)&gt;');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('&lt;svg onload=alert(1)&gt;');
message = "<svg/onload=alert('XSS')>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;svg/onload=alert('XSS')&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg/onload=alert('XSS')&gt;");
message = "<svg onload=alert(1)//";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;svg onload=alert(1)//");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg onload=alert(1)//");
message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
message = "<svg id=alert(1) onload=eval(id)>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
message = '"><svg/onload=alert(/XSS/)';
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
expect(window.alert).not.toHaveBeenCalled();
done();
@ -143,7 +143,7 @@ describe("XSS", function () {
let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML)
expect(msg.innerHTML.replace(/<!---->/g, ''))
.toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
@ -151,21 +151,21 @@ describe("XSS", function () {
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
message = "https://en.wikipedia.org/wiki/Ender's_Game";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
await mock.sendMessage(view, message);
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual(
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
`&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
@ -173,7 +173,7 @@ describe("XSS", function () {
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual(
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
'&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
@ -181,7 +181,7 @@ describe("XSS", function () {
msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(message);
expect(msg.innerHTML).toEqual(
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
`<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
done();
}));
@ -226,19 +226,19 @@ describe("XSS", function () {
function checkNonParsedURL (url) {
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(url);
expect(msg.innerHTML).toEqual(url);
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(url);
}
function checkParsedURL ({ entered, href }) {
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(entered);
expect(msg.innerHTML).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
}
function checkParsedXMPPURL ({ entered, href }) {
const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
expect(msg.textContent).toEqual(entered);
expect(msg.innerHTML).toEqual(`<a target="_blank" rel="noopener" class="open-chatroom" href="${href}">${entered}</a>`);
expect(msg.textContent.trim()).toEqual(entered);
expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
}
await mock.sendMessage(view, bad_urls[0]);

View File

@ -1,11 +1,11 @@
import "./autocomplete.js"
import log from "@converse/headless/log";
import sizzle from "sizzle";
import { CustomElement } from './element.js';
import { __ } from '@converse/headless/i18n';
import { api, converse } from "@converse/headless/converse-core";
import { html } from "lit-html";
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import log from "@converse/headless/log";
import sizzle from "sizzle";
const { Strophe, $iq } = converse.env;
const u = converse.env.utils;

View File

@ -0,0 +1,41 @@
import "../components/message-history";
import xss from "xss/dist/xss";
import { CustomElement } from './element.js';
import { html } from 'lit-element';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
class ChatContent extends CustomElement {
static get properties () {
return {
chatview: { type: Object},
messages: { type: Array},
notifications: { type: String }
}
}
render () {
const notifications = xss.filterXSS(this.notifications, {'whiteList': {}});
return html`
<converse-message-history
.chatview=${this.chatview}
.messages=${this.messages}>
</converse-message-history>
<div class="chat-content__notifications">${unsafeHTML(notifications)}</div>
`;
}
scrollDown () {
if (!this.chatview.model.get('scrolled')) {
this.parentElement.scrollTop = this.parentElement.scrollHeight;
}
this.parentElement.scrollTop = this.parentElement.scrollHeight;
}
updated () {
this.scrollDown();
}
}
customElements.define('converse-chat-content', ChatContent);

View File

@ -1,8 +1,8 @@
import { html } from 'lit-element';
import { CustomElement } from './element.js';
import { until } from 'lit-html/directives/until.js';
import DOMNavigator from "../dom-navigator";
import { CustomElement } from './element.js';
import { converse } from "@converse/headless/converse-core";
import { html } from 'lit-element';
import { until } from 'lit-html/directives/until.js';
const u = converse.env.utils;

View File

@ -0,0 +1,43 @@
import 'fa-icons';
import xss from "xss/dist/xss";
import { CustomElement } from './element.js';
import { _converse, converse } from "@converse/headless/converse-core";
import { html } from 'lit-element';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
const u = converse.env.utils;
class ChatHelp extends CustomElement {
static get properties () {
return {
chat_type: { type: String },
messages: { type: Array },
model: { type: Object },
type: { type: String }
}
}
render () {
const icon_color = this.chat_type === _converse.CHATROOMS_TYPE ? 'var(--chatroom-head-bg-color)' : 'var(--chat-head-color)';
const isodate = (new Date()).toISOString();
return [
html`<fa-icon class="fas fa-times close-chat-help" @click=${this.close} path-prefix="dist" color="${icon_color}" size="1em"></fa-icon>`,
...this.messages.map(m => this.renderHelpMessage({
isodate,
'markup': xss.filterXSS(m, {'whiteList': {'strong': []}})
}))
];
}
close () {
this.model.set({'show_help_messages': false});
}
renderHelpMessage (o) {
return html`<div class="message chat-${this.type}" data-isodate="${o.isodate}">${unsafeHTML(o.markup)}</div>`;
}
}
customElements.define('converse-chat-help', ChatHelp);

View File

@ -0,0 +1,29 @@
import { CustomElement } from './element.js';
import { renderBodyText } from './../templates/directives/body';
import { html } from 'lit-element';
class MessageBody extends CustomElement {
static get properties () {
return {
is_only_emojis: { type: Boolean },
is_spoiler: { type: Boolean },
is_spoiler_visible: { type: Boolean },
is_me_message: { type: Boolean },
model: { type: Object },
text: { type: String },
}
}
render () {
const spoiler_classes = this.is_spoiler ? `spoiler ${this.is_spoiler_visible ? '' : 'collapsed'}` : '';
return html`
<div class="chat-msg__text ${this.is_only_emojis ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
>${renderBodyText(this)}</div>
`;
}
}
customElements.define('converse-chat-message-body', MessageBody);

View File

@ -0,0 +1,124 @@
import "../components/message";
import dayjs from 'dayjs';
import tpl_new_day from "../templates//new_day.js";
import { CustomElement } from './element.js';
import { __ } from '@converse/headless/i18n';
import { api } from "@converse/headless/converse-core";
import { html } from 'lit-element';
import { repeat } from 'lit-html/directives/repeat.js';
const i18n_no_history = __('No message history available.');
const tpl_message = (o) => html`
<converse-chat-message
.chatview=${o.chatview}
.hats=${o.hats}
.model=${o.model}
?allow_retry=${o.retry}
?correcting=${o.correcting}
?editable=${o.editable}
?has_mentions=${o.has_mentions}
?is_delayed=${o.is_delayed}
?is_encrypted=${o.is_encrypted}
?is_me_message=${o.is_me_message}
?is_only_emojis=${o.is_only_emojis}
?is_retracted=${o.is_retracted}
?is_spoiler=${o.is_spoiler}
?is_spoiler_visible=${o.is_spoiler_visible}
?retractable=${o.retractable}
edited=${o.edited || ''}
error=${o.error || ''}
error_text=${o.error_text || ''}
filename=${o.filename || ''}
filesize=${o.filesize || ''}
from=${o.from}
message_type=${o.type || ''}
moderated_by=${o.moderated_by || ''}
moderation_reason=${o.moderation_reason || ''}
msgid=${o.msgid}
occupant_affiliation=${o.model.occupant ? o.model.occupant.get('affiliation') : ''}
occupant_role=${o.model.occupant ? o.model.occupant.get('role') : ''}
oob_url=${o.oob_url || ''}
pretty_type=${o.pretty_type}
progress=${o.progress || 0 }
reason=${o.reason || ''}
received=${o.received || ''}
sender=${o.sender}
spoiler_hint=${o.spoiler_hint || ''}
subject=${o.subject || ''}
time=${o.time}
username=${o.username}></converse-chat-message>
`;
// Return a TemplateResult indicating a new day if the passed in message is
// more than a day later than its predecessor.
function getDayIndicator (model) {
const models = model.collection.models;
const idx = models.indexOf(model);
const prev_model = models[idx-1];
if (!prev_model || dayjs(model.get('time')).isAfter(dayjs(prev_model.get('time')), 'day')) {
const day_date = dayjs(model.get('time')).startOf('day');
return tpl_new_day({
'type': 'date',
'time': day_date.toISOString(),
'datestring': day_date.format("dddd MMM Do YYYY")
});
}
}
class MessageHistory extends CustomElement {
static get properties () {
return {
chatview: { type: Object},
messages: { type: Array}
}
}
render () {
const msgs = this.messages;
return msgs.length ?
html`${repeat(msgs, m => m.get('id'), m => this.renderMessage(m)) }` :
html`<div class="empty-history-feedback form-help"><span>${i18n_no_history}</span></div>`;
}
renderMessage (model) {
// XXX: leaky abstraction "is_only_key" from converse-omemo
if (model.get('dangling_retraction') || model.get('is_only_key')) {
return '';
}
const day = getDayIndicator(model);
const templates = day ? [day] : [];
const is_retracted = model.get('retracted') || model.get('moderated') === 'retracted';
const is_groupchat = model.get('type') === 'groupchat';
let hats = [];
if (is_groupchat) {
if (api.settings.get('muc_hats_from_vcard')) {
const role = model.vcard ? model.vcard.get('role') : null;
hats = role ? role.split(',') : [];
} else {
hats = model.occupant?.get('hats') || [];
}
}
const chatbox = this.chatview.model;
const has_mentions = is_groupchat && model.get('sender') === 'them' && chatbox.isUserMentioned(model);
const message = tpl_message(
Object.assign(model.toJSON(), {
'chatview': this.chatview,
'is_me_message': model.isMeCommand(),
'occupant': model.occupant,
'username': model.getDisplayName(),
has_mentions,
hats,
is_retracted,
model,
}));
return [...templates, message];
}
}
customElements.define('converse-message-history', MessageHistory);

288
src/components/message.js Normal file
View File

@ -0,0 +1,288 @@
import "./message-body.js";
import MessageVersionsModal from '../modals/message-versions.js';
import dayjs from 'dayjs';
import filesize from "filesize";
import tpl_spinner from '../templates/spinner.js';
import { CustomElement } from './element.js';
import { __ } from '@converse/headless/i18n';
import { _converse, api, converse } from "@converse/headless/converse-core";
import { html } from 'lit-element';
import { renderAvatar } from './../templates/directives/avatar';
import { renderRetractionLink } from './../templates/directives/retraction';
const { Strophe } = converse.env;
const u = converse.env.utils;
const i18n_edit_message = __('Edit this message');
const i18n_edited = __('This message has been edited');
const i18n_show = __('Show more');
const i18n_show_less = __('Show less');
const i18n_uploading = __('Uploading file:')
class Message extends CustomElement {
static get properties () {
return {
allow_retry: { type: Boolean },
chatview: { type: Object},
correcting: { type: Boolean },
editable: { type: Boolean },
error: { type: String },
error_text: { type: String },
first_unread: { type: Boolean },
from: { type: String },
has_mentions: { type: Boolean },
hats: { type: Array },
is_delayed: { type: Boolean },
is_encrypted: { type: Boolean },
is_me_message: { type: Boolean },
is_only_emojis: { type: Boolean },
is_retracted: { type: Boolean },
is_spoiler: { type: Boolean },
is_spoiler_visible: { type: Boolean },
message_type: { type: String },
edited: { type: String },
model: { type: Object },
moderated_by: { type: String },
moderation_reason: { type: String },
msgid: { type: String },
occupant_affiliation: { type: String },
occupant_role: { type: String },
oob_url: { type: String },
progress: { type: Number },
reason: { type: String },
received: { type: String },
retractable: { type: Boolean },
sender: { type: String },
show_spinner: { type: Boolean },
spoiler_hint: { type: String },
subject: { type: String },
time: { type: String },
username: { type: String }
}
}
render () {
const format = api.settings.get('time_format');
this.pretty_time = dayjs(this.time).format(format);
if (this.show_spinner) {
return tpl_spinner();
} else if (this.model.get('file') && !this.model.get('oob_url')) {
return this.renderFileProgress();
} else if (['error', 'info'].includes(this.message_type)) {
return this.renderInfoMessage();
} else {
return this.renderChatMessage();
}
}
updated () {
// XXX: This is ugly but tests rely on this event.
// For "normal" chat messages the event is fired in
// src/templates/directives/body.js
if (
this.show_spinner ||
(this.model.get('file') && !this.model.get('oob_url')) ||
(['error', 'info'].includes(this.message_type))
) {
this.model.collection?.trigger('rendered', this.model);
}
}
renderInfoMessage () {
const isodate = dayjs(this.model.get('time')).toISOString();
const i18n_retry = __('Retry');
return html`
<div class="message chat-info chat-${this.message_type}"
data-isodate="${isodate}"
data-type="${this.data_name}"
data-value="${this.data_value}">
<div class="chat-info__message">
${ this.model.getMessageText() }
</div>
${ this.reason ? html`<q class="reason">${this.reason}</q>` : `` }
${ this.error_text ? html`<q class="reason">${this.error_text}</q>` : `` }
${ this.allow_retry ? html`<a class="retry" @click=${this.onRetryClicked}>${i18n_retry}</a>` : '' }
</div>
`;
}
renderFileProgress () {
const filename = this.model.file.name;
const size = filesize(this.model.file.size);
return html`
<div class="message chat-msg">
${ renderAvatar(this) }
<div class="chat-msg__content">
<span class="chat-msg__text">${i18n_uploading} <strong>${filename}</strong>, ${size}</span>
<progress value="${this.progress}"/>
</div>
</div>`;
}
renderChatMessage () {
const is_groupchat_message = (this.message_type === 'groupchat');
return html`
<div class="message chat-msg ${this.message_type} ${this.getExtraMessageClasses()}
${ this.is_me_message ? 'chat-msg--action' : '' }
${this.isFollowup() ? 'chat-msg--followup' : ''}"
data-isodate="${this.time}" data-msgid="${this.msgid}" data-from="${this.from}" data-encrypted="${this.is_encrypted}">
${ renderAvatar(this) }
<div class="chat-msg__content chat-msg__content--${this.sender} ${this.is_me_message ? 'chat-msg__content--action' : ''}">
${this.first_unread ? html`<div class="message date-separator"><hr class="separator"><span class="separator-text">{{{this.__('unread messages')}}}</span></div>` : '' }
<span class="chat-msg__heading">
${ (this.is_me_message) ? html`
<time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
${this.hats.map(hat => html`<span class="badge badge-secondary">${hat}</span>`)}
` : '' }
<span class="chat-msg__author">${ this.is_me_message ? '**' : ''}${this.username}</span>
${ !this.is_me_message ? this.renderAvatarByline() : '' }
${ this.is_encrypted ? html`<span class="fa fa-lock"></span>` : '' }
</span>
<div class="chat-msg__body chat-msg__body--${this.message_type} ${this.received ? 'chat-msg__body--received' : '' } ${this.is_delayed ? 'chat-msg__body--delayed' : '' }">
<div class="chat-msg__message">
${ this.is_retracted ? this.renderRetraction() : this.renderMessageText() }
</div>
${ (this.received && !this.is_me_message && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
${ (this.edited) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
<div class="chat-msg__actions">
${ this.editable ?
html`<button
class="chat-msg__action chat-msg__action-edit"
title="${i18n_edit_message}"
@click=${this.onMessageEditButtonClicked}
>
<fa-icon class="fas fa-pencil-alt" path-prefix="dist" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
</button>` : '' }
${ renderRetractionLink(this) }
</div>
</div>
</div>
</div>`;
}
async onRetryClicked () {
this.show_spinner = true;
await this.model.error.retry();
this.model.destroy();
this.parentElement.removeChild(this);
}
onMessageRetractButtonClicked (ev) {
ev.preventDefault();
this.chatview.onMessageRetractButtonClicked(this.model);
}
onMessageEditButtonClicked (ev) {
ev.preventDefault();
this.chatview.onMessageEditButtonClicked(this.model);
}
isFollowup () {
const messages = this.model.collection.models;
const idx = messages.indexOf(this.model);
const prev_model = idx ? messages[idx-1] : null;
if (prev_model === null) {
return false;
}
const date = dayjs(this.time);
return this.from === prev_model.get('from') &&
!this.is_me_message &&
!prev_model.isMeCommand() &&
this.message_type !== 'info' &&
prev_model.get('type') !== 'info' &&
date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
this.is_encrypted === prev_model.get('is_encrypted');
}
getExtraMessageClasses () {
const extra_classes = [
...(this.is_delayed ? ['delayed'] : []),
...(this.is_retracted ? ['chat-msg--retracted'] : [])
];
if (this.message_type === 'groupchat') {
this.occupant_role && extra_classes.push(this.occupant_role);
this.occupant_affiliation && extra_classes.push(this.occupant_affiliation);
if (this.sender === 'them' && this.has_mentions) {
extra_classes.push('mentioned');
}
}
this.correcting && extra_classes.push('correcting');
return extra_classes.filter(c => c).join(" ");
}
getRetractionText () {
if (this.message_type === 'groupchat' && this.moderated_by) {
const retracted_by_mod = this.moderated_by;
const chatbox = this.model.collection.chatbox;
if (!this.model.mod) {
this.model.mod =
chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
}
const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
return __('%1$s has removed this message', modname);
} else {
return __('%1$s has removed this message', this.model.getDisplayName());
}
}
renderRetraction () {
const retraction_text = this.is_retracted ? this.getRetractionText() : null;
return html`
<div>${retraction_text}</div>
${ this.moderation_reason ? html`<q class="chat-msg--retracted__reason">${this.moderation_reason}</q>` : '' }
`;
}
renderMessageText () {
const tpl_spoiler_hint = html`
<div class="chat-msg__spoiler-hint">
<span class="spoiler-hint">${this.spoiler_hint}</span>
<a class="badge badge-info spoiler-toggle" href="#" @click=${this.toggleSpoilerMessage}>
<i class="fa ${this.is_spoiler_visible ? 'fa-eye-slash' : 'fa-eye'}"></i>
${ this.is_spoiler_visible ? i18n_show_less : i18n_show }
</a>
</div>
`;
return html`
${ this.is_spoiler ? tpl_spoiler_hint : '' }
${ this.subject ? html`<div class="chat-msg__subject">${this.subject}</div>` : '' }
<converse-chat-message-body
.model="${this.model}"
?is_me_message="${this.is_me_message}"
?is_only_emojis="${this.is_only_emojis}"
?is_spoiler="${this.is_spoiler}"
?is_spoiler_visible="${this.is_spoiler_visible}"
text="${this.model.getMessageText()}"></converse-chat-message-body>
${ this.oob_url ? html`<div class="chat-msg__media">${u.getOOBURLMarkup(_converse, this.oob_url)}</div>` : '' }
<div class="chat-msg__error">${ this.error_text || this.error }</div>
`;
}
renderAvatarByline () {
return html`
${ this.hats.map(h => html`<span class="badge badge-secondary">${h.title}</span>`) }
<time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
`;
}
showMessageVersionsModal (ev) {
ev.preventDefault();
if (this.message_versions_modal === undefined) {
this.message_versions_modal = new MessageVersionsModal({'model': this.model});
}
this.message_versions_modal.show(ev);
}
toggleSpoilerMessage (ev) {
ev?.preventDefault();
this.model.save({'is_spoiler_visible': !this.model.get('is_spoiler_visible')});
}
}
customElements.define('converse-chat-message', Message);

View File

@ -3,31 +3,29 @@
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import "./components/chat_content.js";
import "./components/help_messages.js";
import "converse-chatboxviews";
import "converse-message-view";
import "converse-modal";
import log from "@converse/headless/log";
import tpl_chatbox from "templates/chatbox.js";
import tpl_chatbox_head from "templates/chatbox_head.js";
import tpl_chatbox_message_form from "templates/chatbox_message_form.html";
import tpl_help_message from "templates/help_message.html";
import tpl_info from "templates/info.html";
import tpl_new_day from "templates/new_day.html";
import tpl_spinner from "templates/spinner.html";
import tpl_spoiler_button from "templates/spoiler_button.html";
import tpl_toolbar from "templates/toolbar.html";
import tpl_toolbar_fileupload from "templates/toolbar_fileupload.html";
import tpl_user_details_modal from "templates/user_details_modal.js";
import xss from "xss/dist/xss";
import { BootstrapModal } from "./converse-modal.js";
import { Overview } from "skeletor.js/src/overview";
import { View } from 'skeletor.js/src/view.js';
import { __ } from '@converse/headless/i18n';
import { _converse, api, converse } from "@converse/headless/converse-core";
import { debounce, isString } from "lodash";
import { html, render } from "lit-html";
const { Strophe, sizzle, dayjs } = converse.env;
const { Strophe, dayjs } = converse.env;
const u = converse.env.utils;
@ -46,7 +44,6 @@ converse.plugins.add('converse-chatview', {
"converse-chatboxviews",
"converse-chat",
"converse-disco",
"converse-message-view",
"converse-modal"
],
@ -57,9 +54,13 @@ converse.plugins.add('converse-chatview', {
api.settings.update({
'auto_focus': true,
'message_limit': 0,
'show_send_button': true,
'muc_hats_from_vcard': false,
'show_images_inline': true,
'show_retraction_warning': true,
'show_send_button': true,
'show_toolbar': true,
'time_format': 'HH:mm',
'debounced_content_rendering': true,
'visible_toolbar_buttons': {
'call': false,
'clear': true,
@ -163,19 +164,16 @@ converse.plugins.add('converse-chatview', {
* @namespace _converse.ChatBoxView
* @memberOf _converse
*/
_converse.ChatBoxView = Overview.extend({
_converse.ChatBoxView = View.extend({
length: 200,
className: 'chatbox hidden',
is_chatroom: false, // Leaky abstraction from MUC
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .new-msgs-indicator': 'viewUnreadMessages',
'click .send-button': 'onFormSubmitted',
'click .spoiler-toggle': 'toggleSpoilerMessage',
'click .toggle-call': 'toggleCall',
'click .toggle-clear': 'clearMessages',
'click .toggle-compose-spoiler': 'toggleComposeSpoilerMessage',
@ -191,15 +189,6 @@ converse.plugins.add('converse-chatview', {
async initialize () {
this.initDebounced();
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'rendered', this.scrollDown);
this.model.messages.on('reset', () => {
this.msgs_container.innerHTML = '';
this.removeAll();
});
this.listenTo(this.model.notifications, 'change', this.renderChatStateNotification);
this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
this.listenTo(this.model, 'destroy', this.remove);
this.listenTo(this.model, 'show', this.show);
@ -217,6 +206,14 @@ converse.plugins.add('converse-chatview', {
this.listenTo(this.model.presence, 'change:show', this.onPresenceChanged);
this.render();
// Need to be registered after render has been called.
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'change', this.renderChatHistory);
this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
this.listenTo(this.model.notifications, 'change', this.renderNotifications);
this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
await this.updateAfterMessagesFetched();
this.model.maybeShow();
/**
@ -229,11 +226,19 @@ converse.plugins.add('converse-chatview', {
},
initDebounced () {
this.scrollDown = debounce(this._scrollDown, 50);
this.markScrolled = debounce(this._markScrolled, 100);
// For tests that use Jasmine.Clock we want to turn of
// debouncing, since setTimeout breaks.
if (api.settings.get('debounced_content_rendering')) {
this.renderChatHistory = debounce(() => this.renderChatContent(false), 100);
this.renderNotifications = debounce(() => this.renderChatContent(true), 100);
} else {
this.renderChatHistory = () => this.renderChatContent(false);
this.renderNotifications = () => this.renderChatContent(true);
}
},
render () {
async render () {
const result = tpl_chatbox(
Object.assign(
this.model.toJSON(), {
@ -244,26 +249,87 @@ converse.plugins.add('converse-chatview', {
);
render(result, this.el);
this.content = this.el.querySelector('.chat-content');
this.notifications = this.el.querySelector('.chat-content__notifications');
this.msgs_container = this.el.querySelector('.chat-content__messages');
this.renderChatStateNotification();
this.help_container = this.el.querySelector('.chat-content__help');
await api.waitUntil('emojisInitialized');
this.renderChatContent();
this.renderMessageForm();
this.renderHeading();
return this;
},
renderChatStateNotification () {
if (this.model.notifications.get('chat_state') === _converse.COMPOSING) {
this.notifications.innerText = __('%1$s is typing', this.model.getDisplayName());
} else if (this.model.notifications.get('chat_state') === _converse.PAUSED) {
this.notifications.innerText = __('%1$s has stopped typing', this.model.getDisplayName());
} else if (this.model.notifications.get('chat_state') === _converse.GONE) {
this.notifications.innerText = __('%1$s has gone away', this.model.getDisplayName());
} else {
this.notifications.innerText = '';
onMessageAdded (message) {
this.renderChatHistory();
if (u.isNewMessage(message)) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.model.get('scrolled', true)) {
this.showNewMessagesIndicator();
}
}
},
getNotifications () {
if (this.model.notifications.get('chat_state') === _converse.COMPOSING) {
return __('%1$s is typing', this.model.getDisplayName());
} else if (this.model.notifications.get('chat_state') === _converse.PAUSED) {
return __('%1$s has stopped typing', this.model.getDisplayName());
} else if (this.model.notifications.get('chat_state') === _converse.GONE) {
return __('%1$s has gone away', this.model.getDisplayName());
} else {
return '';
}
},
getHelpMessages () {
return [
`<strong>/clear</strong>: ${__('Remove messages')}`,
`<strong>/close</strong>: ${__('Close this chat')}`,
`<strong>/me</strong>: ${__('Write in the third person')}`,
`<strong>/help</strong>: ${__('Show this menu')}`
];
},
renderHelpMessages () {
render(
html`<converse-chat-help
.model=${this.model}
.messages=${this.getHelpMessages()}
?hidden=${!this.model.get('show_help_messages')}
type="info"
chat_type="${this.model.get('type')}"></converse-chat-help>`,
this.help_container
);
},
renderChatContent (msgs_by_ref=false) {
if (!this.tpl_chat_content) {
this.tpl_chat_content = (o) => {
return html`
<converse-chat-content
.chatview=${this}
.messages=${o.messages}
notifications=${o.notifications}>
</converse-chat-content>`
};
}
const msg_models = this.model.messages.models;
const messages = msgs_by_ref ? msg_models : Array.from(msg_models);
render(
this.tpl_chat_content({ messages, 'notifications': this.getNotifications() }),
this.msgs_container
);
},
renderToolbar () {
if (!api.settings.get('show_toolbar')) {
return this;
@ -473,10 +539,9 @@ converse.plugins.add('converse-chatview', {
async updateAfterMessagesFetched () {
await this.model.messages.fetched;
await Promise.all(this.model.messages.map(m => this.onMessageAdded(m)));
this.renderChatContent();
this.insertIntoDOM();
this.scrollDown();
this.content.addEventListener('scroll', () => this.markScrolled());
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
* `sessionStorage` but **NOT** from the server.
@ -484,7 +549,12 @@ converse.plugins.add('converse-chatview', {
* @type {_converse.ChatBoxView | _converse.ChatRoomView}
* @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
*/
api.trigger('afterMessagesFetched', this);
api.trigger('afterMessagesFetched', this.model);
},
scrollDown () {
const el = this.msgs_container.firstElementChild;
el && el.scrollDown();
},
insertIntoDOM () {
@ -499,20 +569,6 @@ converse.plugins.add('converse-chatview', {
return this;
},
showChatEvent (message) {
const isodate = (new Date()).toISOString();
this.msgs_container.insertAdjacentHTML(
'beforeend',
tpl_info({
'extra_classes': 'chat-event',
'message': message,
'isodate': isodate,
}));
this.insertDayIndicator(this.msgs_container.lastElementChild);
this.scrollDown();
return isodate;
},
addSpinner (append=false) {
if (this.el.querySelector('.spinner') === null) {
if (append) {
@ -557,47 +613,6 @@ converse.plugins.add('converse-chatview', {
}
},
/**
* Return the ISO8601 format date of the latest message.
* @private
* @method _converse.ChatBoxView#getLastMessageDate
* @param { Date } cutoff - Moment Date cutoff date. The last
* message received cutoff this date will be returned.
* @returns { Date }
*/
getLastMessageDate (cutoff) {
const first_msg = u.getFirstChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
const oldest_date = first_msg ? first_msg.getAttribute('data-isodate') : null;
if (oldest_date !== null && dayjs(oldest_date).isAfter(cutoff)) {
return null;
}
const last_msg = u.getLastChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
const most_recent_date = last_msg ? last_msg.getAttribute('data-isodate') : null;
if (most_recent_date === null) {
return null;
}
if (dayjs(most_recent_date).isBefore(cutoff)) {
return dayjs(most_recent_date).toDate();
}
/* XXX: We avoid .chat-state-notification messages, since they are
* temporary and get removed once a new element is
* inserted into the chat area, so we don't query for
* them here, otherwise we get a null reference later
* upon element insertion.
*/
const sel = '.message:not(.chat-state-notification)';
const msg_dates = sizzle(sel, this.msgs_container).map(e => e.getAttribute('data-isodate'));
const cutoff_iso = cutoff.toISOString();
msg_dates.push(cutoff_iso);
msg_dates.sort();
const idx = msg_dates.lastIndexOf(cutoff_iso);
if (idx === 0) {
return null;
} else {
return dayjs(msg_dates[idx-1]).toDate();
}
},
setScrollPosition (message_el) {
/* Given a newly inserted message, determine whether we
* should keep the scrollbar in place (so as to not scroll
@ -637,63 +652,10 @@ converse.plugins.add('converse-chatview', {
});
},
showHelpMessages (msgs, type='info', spinner) {
msgs.forEach(msg => {
this.msgs_container.insertAdjacentHTML(
'beforeend',
tpl_help_message({
'isodate': (new Date()).toISOString(),
'type': type,
'message': xss.filterXSS(msg, {'whiteList': {'strong': []}})
})
);
});
if (spinner === true) {
this.addSpinner();
} else if (spinner === false) {
this.clearSpinner();
}
return this.scrollDown();
},
shouldShowOnTextMessage () {
return !u.isVisible(this.el);
},
/**
* Given a view representing a message, insert it into the
* content area of the chat box.
* @private
* @method _converse.ChatBoxView#insertMessage
* @param { View } message - The message View
*/
insertMessage (view) {
if (view.model.get('type') === 'error') {
const previous_msg_el = this.msgs_container.querySelector(`[data-msgid="${view.model.get('msgid')}"]`);
if (previous_msg_el) {
previous_msg_el.insertAdjacentElement('afterend', view.el);
return this.trigger('messageInserted', view.el);
}
}
const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date();
const previous_msg_date = this.getLastMessageDate(current_msg_date);
if (previous_msg_date === null) {
this.msgs_container.insertAdjacentElement('afterbegin', view.el);
} else {
const previous_msg_el = sizzle(`[data-isodate="${previous_msg_date.toISOString()}"]:last`, this.msgs_container).pop();
if (view.model.get('type') === 'error' &&
u.hasClass('chat-error', previous_msg_el) &&
previous_msg_el.textContent === view.model.get('message')) {
// We don't show a duplicate error message
return;
}
previous_msg_el.insertAdjacentElement('afterend', view.el);
this.markFollowups(view.el);
}
return this.trigger('messageInserted', view.el);
},
/**
* Given a message element, determine wether it should be
* marked as a followup message to the previous element.
@ -733,71 +695,6 @@ converse.plugins.add('converse-chatview', {
}
},
/**
* Inserts a chat message into the content area of the chat box.
* Will also insert a new day indicator if the message is on a different day.
* @private
* @method _converse.ChatBoxView#showMessage
* @param { _converse.Message } message - The message object
*/
async showMessage (message) {
await message.initialized;
const view = this.add(message.get('id'), new _converse.MessageView({'model': message}));
await view.render();
this.insertMessage(view);
this.insertDayIndicator(view.el);
this.setScrollPosition(view.el);
if (u.isNewMessage(message)) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.model.get('scrolled', true)) {
this.showNewMessagesIndicator();
}
}
if (this.shouldShowOnTextMessage()) {
this.show();
} else {
this.scrollDown();
}
if (message.get('correcting')) {
this.insertIntoTextArea(message.get('message'), true, true);
}
},
/**
* Handler that gets called when a new message object is created.
* @private
* @method _converse.ChatBoxView#onMessageAdded
* @param { object } message - The message object that was added.
*/
async onMessageAdded (message) {
const id = message.get('id');
if (id && this.get(id)) {
// We already have a view for this message
return;
}
if (!message.get('dangling_retraction')) {
await this.showMessage(message);
}
/**
* Triggered once a message has been added to a chatbox.
* @event _converse#messageAdded
* @type {object}
* @property { _converse.Message } message - The message instance
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('messageAdded', data => { ... });
*/
api.trigger('messageAdded', {
'message': message,
'chatbox': this.model
});
},
parseMessageForCommands (text) {
const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
if (match) {
@ -808,13 +705,7 @@ converse.plugins.add('converse-chatview', {
this.close();
return true;
} else if (match[1] === "help") {
const msgs = [
`<strong>/clear</strong>: ${__('Remove messages')}`,
`<strong>/close</strong>: ${__('Close this chat')}`,
`<strong>/me</strong>: ${__('Write in the third person')}`,
`<strong>/help</strong>: ${__('Show this menu')}`
];
this.showHelpMessages(msgs);
this.model.set({'show_help_messages': true});
return true;
}
}
@ -829,10 +720,8 @@ converse.plugins.add('converse-chatview', {
return;
}
if (!_converse.connection.authenticated) {
this.showHelpMessages(
['Sorry, the connection has been lost, and your message could not be sent'],
'error'
);
const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
api.alert('error', __('Error'), err_msg);
api.connection.reconnect();
return;
}
@ -977,14 +866,9 @@ converse.plugins.add('converse-chatview', {
this.insertIntoTextArea('', true, false);
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
async onMessageRetractButtonClicked (message) {
if (message.get('sender') !== 'me') {
return log.error("onMessageEditButtonClicked called for someone else's message!");
return log.error("onMessageRetractButtonClicked called for someone else's message!");
}
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
@ -1001,26 +885,17 @@ converse.plugins.add('converse-chatview', {
}
},
onMessageEditButtonClicked (ev) {
ev.preventDefault();
const idx = this.model.messages.findLastIndex('correcting'),
currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
message_el = u.ancestor(ev.target, '.chat-msg'),
message = this.model.messages.findWhere({'msgid': message_el.getAttribute('data-msgid')});
const textarea = this.el.querySelector('.chat-textarea');
if (textarea.value &&
((currently_correcting === null) || currently_correcting.get('message') !== textarea.value)) {
onMessageEditButtonClicked (message) {
const currently_correcting = this.model.messages.findWhere('correcting');
const unsent_text = this.el.querySelector('.chat-textarea')?.value;
if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) {
if (! confirm(__("You have an unsent message which will be lost if you continue. Are you sure?"))) {
return;
}
}
if (currently_correcting !== message) {
if (currently_correcting !== null) {
currently_correcting.save('correcting', false);
}
currently_correcting?.save('correcting', false);
message.save('correcting', true);
this.insertIntoTextArea(u.prefixMentions(message), true, true);
} else {
@ -1150,34 +1025,9 @@ converse.plugins.add('converse-chatview', {
this.focus();
},
toggleSpoilerMessage (ev) {
if (ev && ev.preventDefault) {
ev.preventDefault();
}
const toggle_el = ev.target,
icon_el = toggle_el.firstElementChild;
u.slideToggleElement(
toggle_el.parentElement.parentElement.querySelector('.spoiler')
);
if (toggle_el.getAttribute("data-toggle-state") == "closed") {
toggle_el.textContent = 'Show less';
icon_el.classList.remove("fa-eye");
icon_el.classList.add("fa-eye-slash");
toggle_el.insertAdjacentElement('afterBegin', icon_el);
toggle_el.setAttribute("data-toggle-state", "open");
} else {
toggle_el.textContent = 'Show more';
icon_el.classList.remove("fa-eye-slash");
icon_el.classList.add("fa-eye");
toggle_el.insertAdjacentElement('afterBegin', icon_el);
toggle_el.setAttribute("data-toggle-state", "closed");
}
},
onPresenceChanged (item) {
const show = item.get('show'),
fullname = this.model.getDisplayName();
const show = item.get('show');
const fullname = this.model.getDisplayName();
let text;
if (u.isVisible(this.el)) {
@ -1333,21 +1183,6 @@ converse.plugins.add('converse-chatview', {
this.scrollDown();
},
_scrollDown () {
/* Inner method that gets debounced */
if (this.content === undefined) {
return;
}
if (u.isVisible(this.content) && !this.model.get('scrolled')) {
if ((this.content.scrollTop === 0 || this.content.scrollTop < this.content.scrollHeight/2)) {
u.removeClass('smooth-scroll', this.content);
} else if (api.settings.get('animate')) {
u.addClass('smooth-scroll', this.content);
}
this.content.scrollTop = this.content.scrollHeight;
}
},
onScrolledDown () {
this.hideNewMessagesIndicator();
if (_converse.windowState !== 'hidden') {

View File

@ -132,7 +132,7 @@ converse.plugins.add('converse-headlines-view', {
this.initDebounced();
this.model.disable_mam = true; // Don't do MAM queries for this box
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'add', this.renderChatHistory);
this.listenTo(this.model, 'show', this.show);
this.listenTo(this.model, 'destroy', this.hide);
this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
@ -168,6 +168,12 @@ converse.plugins.add('converse-headlines-view', {
return this;
},
getNotifications () {
// Override method in ChatBox. We don't show notifications for
// headlines boxes.
return [];
},
/**
* Returns a list of objects which represent buttons for the headlines header.
* @async

View File

@ -1,386 +0,0 @@
/**
* @module converse-message-view
* @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2)
*/
import "./utils/html";
import "@converse/headless/converse-emoji";
import URI from "urijs";
import filesize from "filesize";
import log from "@converse/headless/log";
import tpl_file_progress from "templates/file_progress.html";
import tpl_info from "templates/info.html";
import tpl_message from "templates/message.html";
import tpl_message_versions_modal from "templates/message_versions_modal.js";
import tpl_spinner from "templates/spinner.html";
import xss from "xss/dist/xss";
import { BootstrapModal } from "./converse-modal.js";
import { __ } from '@converse/headless/i18n';
import { _converse, api, converse } from "@converse/headless/converse-core";
import { debounce } from 'lodash'
import { render } from "lit-html";
const { Strophe, dayjs } = converse.env;
const u = converse.env.utils;
converse.plugins.add('converse-message-view', {
dependencies: ["converse-modal", "converse-chatboxviews"],
initialize () {
/* The initialize function gets called as soon as the plugin is
* loaded by converse.js's plugin machinery.
*/
function onTagFoundDuringXSSFilter (tag, html, options) {
/* This function gets called by the XSS library whenever it finds
* what it thinks is a new HTML tag.
*
* It thinks that something like <https://example.com> is an HTML
* tag and then escapes the <> chars.
*
* We want to avoid this, because it prevents these URLs from being
* shown properly (whithout the trailing &gt;).
*
* The URI lib correctly trims a trailing >, but not a trailing &gt;
*/
if (options.isClosing) {
// Closing tags don't match our use-case
return;
}
const uri = new URI(tag);
const protocol = uri.protocol().toLowerCase();
if (!["https", "http", "xmpp", "ftp"].includes(protocol)) {
// Not a URL, the tag will get filtered as usual
return;
}
if (uri.equals(tag) && `<${tag}>` === html.toLocaleLowerCase()) {
// We have something like <https://example.com>, and don't want
// to filter it.
return html;
}
}
api.settings.update({
'muc_hats_from_vcard': false,
'show_images_inline': true,
'time_format': 'HH:mm',
});
_converse.MessageVersionsModal = BootstrapModal.extend({
id: "message-versions-modal",
toHTML () {
return tpl_message_versions_modal(this.model.toJSON());
}
});
/**
* @class
* @namespace _converse.MessageView
* @memberOf _converse
*/
_converse.MessageView = _converse.ViewWithAvatar.extend({
events: {
'click .chat-msg__edit-modal': 'showMessageVersionsModal',
'click .retry': 'onRetryClicked'
},
initialize () {
this.debouncedRender = debounce(() => {
// If the model gets destroyed in the meantime,
// it no longer has a collection
if (this.model.collection) {
this.render();
}
}, 50);
if (this.model.rosterContactAdded) {
this.model.rosterContactAdded.then(() => {
this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender);
this.debouncedRender();
});
}
this.model.occupant && this.addOccupantListeners();
this.listenTo(this.model, 'change', this.onChanged);
this.listenTo(this.model, 'destroy', this.fadeOut);
this.listenTo(this.model, 'occupantAdded', () => {
this.addOccupantListeners();
this.debouncedRender();
});
this.listenTo(this.model, 'vcard:change', this.debouncedRender);
this.debouncedRender();
},
async render () {
const is_followup = u.hasClass('chat-msg--followup', this.el);
if (this.model.get('file') && !this.model.get('oob_url')) {
if (!this.model.file) {
log.error("Attempted to render a file upload message with no file data");
return this.el;
}
this.renderFileUploadProgresBar();
} else if (this.model.get('type') === 'error') {
this.renderErrorMessage();
} else if (this.model.get('type') === 'info') {
this.renderInfoMessage();
} else {
await this.renderChatMessage();
}
is_followup && u.addClass('chat-msg--followup', this.el);
return this.el;
},
async onChanged (item) {
// Jot down whether it was edited because the `changed`
// attr gets removed when this.render() gets called further down.
const edited = item.changed.edited;
if (this.model.changed.progress) {
return this.renderFileUploadProgresBar();
}
// TODO: We can remove this once we render messages via lit-html
const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop);
const props = [
'correcting',
'editable',
'error',
'message',
'moderated',
'received',
'retracted',
'type',
'upload',
];
if (props.filter(isValidChange).length) {
await this.debouncedRender();
}
if (edited) {
this.onMessageEdited();
}
},
addOccupantListeners () {
this.listenTo(this.model.occupant, 'change:affiliation', this.debouncedRender);
this.listenTo(this.model.occupant, 'change:hats', this.debouncedRender);
this.listenTo(this.model.occupant, 'change:role', this.debouncedRender);
},
fadeOut () {
if (api.settings.get('animate')) {
setTimeout(() => this.remove(), 600);
u.addClass('fade-out', this.el);
} else {
this.remove();
}
},
async onRetryClicked () {
this.showSpinner();
await this.model.error.retry();
this.model.destroy();
},
showSpinner () {
this.el.innerHTML = tpl_spinner();
},
onMessageEdited () {
if (this.model.get('is_archived')) {
return;
}
this.el.addEventListener(
'animationend',
() => u.removeClass('onload', this.el),
{'once': true}
);
u.addClass('onload', this.el);
},
replaceElement (msg) {
if (this.el.parentElement) {
this.el.parentElement.replaceChild(msg, this.el);
}
this.setElement(msg);
return this.el;
},
transformOOBURL (url) {
return u.getOOBURLMarkup(_converse, url);
},
async transformBodyText (text) {
/**
* Synchronous event which provides a hook for transforming a chat message's body text
* before the default transformations have been applied.
* @event _converse#beforeMessageBodyTransformed
* @param { _converse.MessageView } view - The view representing the message
* @param { string } text - The message text
* @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
*/
await api.trigger('beforeMessageBodyTransformed', this, text, {'Synchronous': true});
text = this.model.isMeCommand() ? text.substring(4) : text;
text = xss.filterXSS(text, {'whiteList': {}, 'onTag': onTagFoundDuringXSSFilter});
text = u.geoUriToHttp(text, api.settings.get("geouri_replacement"));
text = u.addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
text = u.addHyperlinks(text);
text = u.renderNewLines(text);
text = u.addEmoji(text);
/**
* Synchronous event which provides a hook for transforming a chat message's body text
* after the default transformations have been applied.
* @event _converse#afterMessageBodyTransformed
* @param { _converse.MessageView } view - The view representing the message
* @param { string } text - The message text
* @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
*/
await api.trigger('afterMessageBodyTransformed', this, text, {'Synchronous': true});
return text;
},
async renderChatMessage () {
await api.waitUntil('emojisInitialized');
const time = dayjs(this.model.get('time'));
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const may_be_moderated = this.model.get('type') === 'groupchat' && await this.model.mayBeModerated();
const retractable= !is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
const is_groupchat_message = this.model.get('type') === 'groupchat';
let hats = [];
if (is_groupchat_message) {
if (api.settings.get('muc_hats_from_vcard')) {
const role = this.model.vcard ? this.model.vcard.get('role') : null;
hats = role ? role.split(',') : [];
} else {
hats = this.model.occupant?.get('hats') || [];
}
}
const msg = u.stringToElement(tpl_message(
Object.assign(
this.model.toJSON(), {
__,
hats,
is_groupchat_message,
is_retracted,
retractable,
'extra_classes': this.getExtraMessageClasses(),
'is_me_message': this.model.isMeCommand(),
'label_show': __('Show more'),
'occupant': this.model.occupant,
'pretty_time': time.format(api.settings.get('time_format')),
'retraction_text': is_retracted ? this.getRetractionText() : null,
'time': time.toISOString(),
'username': this.model.getDisplayName()
})
));
const url = this.model.get('oob_url');
url && render(this.transformOOBURL(url), msg.querySelector('.chat-msg__media'));
if (!is_retracted) {
const text = this.model.getMessageText();
const msg_content = msg.querySelector('.chat-msg__text');
if (text && text !== url) {
msg_content.innerHTML = await this.transformBodyText(text);
if (api.settings.get('show_images_inline')) {
u.renderImageURLs(_converse, msg_content).then(() => this.triggerRendered());
}
}
}
if (this.model.get('type') !== 'headline') {
this.renderAvatar(msg);
}
this.replaceElement(msg);
this.triggerRendered();
},
triggerRendered () {
if (this.model.collection) {
// If the model gets destroyed in the meantime, it no
// longer has a collection.
this.model.collection.trigger('rendered', this);
}
},
renderInfoMessage () {
const msg = u.stringToElement(
tpl_info(Object.assign(this.model.toJSON(), {
'extra_classes': 'chat-info',
'isodate': dayjs(this.model.get('time')).toISOString()
}))
);
return this.replaceElement(msg);
},
getRetractionText () {
if (this.model.get('type') === 'groupchat' && this.model.get('moderated_by')) {
const retracted_by_mod = this.model.get('moderated_by');
const chatbox = this.model.collection.chatbox;
if (!this.model.mod) {
this.model.mod =
chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
}
const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
return __('%1$s has removed this message', modname);
} else {
return __('%1$s has removed this message', this.model.getDisplayName());
}
},
renderErrorMessage () {
const msg = u.stringToElement(
tpl_info(Object.assign(this.model.toJSON(), {
'extra_classes': 'chat-error',
'isodate': dayjs(this.model.get('time')).toISOString()
}))
);
return this.replaceElement(msg);
},
renderFileUploadProgresBar () {
const msg = u.stringToElement(tpl_file_progress(
Object.assign(this.model.toJSON(), {
'__': __,
'filename': this.model.file.name,
'filesize': filesize(this.model.file.size)
})));
this.replaceElement(msg);
this.renderAvatar();
},
showMessageVersionsModal (ev) {
ev.preventDefault();
if (this.model.message_versions_modal === undefined) {
this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
}
this.model.message_versions_modal.show(ev);
},
getExtraMessageClasses () {
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const extra_classes = [
...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : [])
];
if (this.model.get('type') === 'groupchat') {
if (this.model.occupant) {
extra_classes.push(this.model.occupant.get('role'));
extra_classes.push(this.model.occupant.get('affiliation'));
}
if (this.model.get('sender') === 'them' && this.model.collection.chatbox.isUserMentioned(this.model)) {
// Add special class to mark groupchat messages
// in which we are mentioned.
extra_classes.push('mentioned');
}
}
if (this.model.get('correcting')) {
extra_classes.push('correcting');
}
return extra_classes.filter(c => c).join(" ");
}
});
}
});

View File

@ -17,7 +17,6 @@ import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
import tpl_chatroom_disconnect from "templates/chatroom_disconnect.html";
import tpl_chatroom_head from "templates/chatroom_head.js";
import tpl_chatroom_nickname_form from "templates/chatroom_nickname_form.html";
import tpl_info from "templates/info.html";
import tpl_list_chatrooms_modal from "templates/list_chatrooms_modal.js";
import tpl_muc_config_form from "templates/muc_config_form.js";
import tpl_muc_invite_modal from "templates/muc_invite_modal.js";
@ -438,8 +437,6 @@ converse.plugins.add('converse-muc-views', {
is_chatroom: true,
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .hide-occupants': 'hideOccupants',
'click .new-msgs-indicator': 'viewUnreadMessages',
@ -463,24 +460,15 @@ converse.plugins.add('converse-muc-views', {
async initialize () {
this.initDebounced();
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'change:edited', this.onMessageEdited);
this.listenTo(this.model.messages, 'rendered', this.scrollDown);
this.model.messages.on('reset', () => {
this.msgs_container.innerHTML = '';
this.removeAll();
});
this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
this.listenTo(this.model, 'destroy', this.hide);
this.listenTo(this.model, 'show', this.show);
this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel);
this.listenTo(this.model.features, 'change:open', this.renderHeading);
this.listenTo(this.model.messages, 'rendered', this.scrollDown);
this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
// Bind so that we can pass it to addEventListener and removeEventListener
this.onMouseMove = this.onMouseMove.bind(this);
@ -489,13 +477,19 @@ converse.plugins.add('converse-muc-views', {
await this.render();
// Need to be registered after render has been called.
this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'change', this.renderChatHistory);
this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
this.listenTo(this.model.notifications, 'change', this.renderNotifications);
this.model.occupants.forEach(o => this.onOccupantAdded(o));
this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
this.listenTo(this.model.occupants, 'change', this.renderChatHistory);
this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
this.listenTo(this.model.notifications, 'change', this.renderNotifications);
this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
this.createSidebarView();
await this.updateAfterMessagesFetched();
@ -522,9 +516,11 @@ converse.plugins.add('converse-muc-views', {
'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
'show_send_button': _converse.show_send_button
}), this.el);
this.notifications = this.el.querySelector('.chat-content__notifications');
this.content = this.el.querySelector('.chat-content');
this.msgs_container = this.el.querySelector('.chat-content__messages');
this.help_container = this.el.querySelector('.chat-content__help');
this.renderBottomPanel();
if (!_converse.muc_show_logs_before_join &&
@ -538,13 +534,13 @@ converse.plugins.add('converse-muc-views', {
!this.model.get('hidden') && this.show();
},
renderNotifications () {
getNotifications () {
const actors_per_state = this.model.notifications.toJSON();
const states = api.settings.get('muc_show_join_leave') ?
[...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] :
converse.CHAT_STATES;
const message = states.reduce((result, state) => {
return states.reduce((result, state) => {
const existing_actors = actors_per_state[state];
if (!(existing_actors?.length)) {
return result;
@ -601,8 +597,34 @@ converse.plugins.add('converse-muc-views', {
}
return result;
}, '');
this.notifications.innerHTML = message;
message.includes('\n') && this.scrollDown();
},
getHelpMessages () {
const setting = api.settings.get("muc_disable_slash_commands");
const disabled_commands = Array.isArray(setting) ? setting : [];
return [
`<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
`<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
`<strong>/clear</strong>: ${__('Clear the chat area')}`,
`<strong>/close</strong>: ${__('Close this groupchat')}`,
`<strong>/deop</strong>: ${__('Change user role to participant')}`,
`<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
`<strong>/help</strong>: ${__('Show this menu')}`,
`<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
`<strong>/me</strong>: ${__('Write in 3rd person')}`,
`<strong>/member</strong>: ${__('Grant membership to a user')}`,
`<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
`<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
`<strong>/nick</strong>: ${__('Change your nickname')}`,
`<strong>/op</strong>: ${__('Grant moderator role to user')}`,
`<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
`<strong>/register</strong>: ${__("Register your nickname")}`,
`<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
`<strong>/subject</strong>: ${__('Set groupchat subject')}`,
`<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
`<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
.filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9)));
},
/**
@ -785,12 +807,7 @@ converse.plugins.add('converse-muc-views', {
return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
async onMessageRetractButtonClicked (message) {
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
"not yet support retractions and that this message may not "+
@ -801,7 +818,7 @@ converse.plugins.add('converse-muc-views', {
if (_converse.show_retraction_warning) {
messages[1] = retraction_warning;
}
!!(await api.confirm(__('Confirm'), messages)) && this.retractOwnMessage(message);
!!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message);
} else if (await message.mayBeModerated()) {
if (message.get('sender') === 'me') {
let messages = [__('Are you sure you want to retract this message?')];
@ -830,22 +847,6 @@ converse.plugins.add('converse-muc-views', {
}
},
/**
* Retract one of your messages in this groupchat.
* @private
* @method _converse.ChatRoomView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.model.retractOwnMessage(message)
.catch(e => {
const message = __('Sorry, something went wrong while trying to retract your message.');
this.model.createMessage({message, 'type': 'error'});
!u.isErrorStanza(e) && this.model.createMessage({'message': e.message, 'type': 'error'});
log.error(e);
});
},
/**
* Retract someone else's message in this groupchat.
* @private
@ -1363,10 +1364,7 @@ converse.plugins.add('converse-muc-views', {
return false;
}
const args = text.slice(('/'+command).length+1).trim();
const disabled_commands = Array.isArray(_converse.muc_disable_slash_commands) ?
_converse.muc_disable_slash_commands : [];
const allowed_commands = this.getAllowedCommands();
if (!allowed_commands.includes(command)) {
if (!this.getAllowedCommands().includes(command)) {
return false;
}
@ -1401,31 +1399,7 @@ converse.plugins.add('converse-muc-views', {
break;
}
case 'help': {
this.showHelpMessages([`<strong>${__("You can run the following commands")}</strong>`]);
this.showHelpMessages([
`<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
`<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
`<strong>/clear</strong>: ${__('Clear the chat area')}`,
`<strong>/close</strong>: ${__('Close this groupchat')}`,
`<strong>/deop</strong>: ${__('Change user role to participant')}`,
`<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
`<strong>/help</strong>: ${__('Show this menu')}`,
`<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
`<strong>/me</strong>: ${__('Write in 3rd person')}`,
`<strong>/member</strong>: ${__('Grant membership to a user')}`,
`<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
`<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
`<strong>/nick</strong>: ${__('Change your nickname')}`,
`<strong>/op</strong>: ${__('Grant moderator role to user')}`,
`<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
`<strong>/register</strong>: ${__("Register your nickname")}`,
`<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
`<strong>/subject</strong>: ${__('Set groupchat subject')}`,
`<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
`<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
.filter(line => allowed_commands.some(c => line.startsWith(c+'<', 9)))
);
this.model.set({'show_help_messages': true});
break;
} case 'kick': {
this.setRole(command, args, [], ['moderator']);
@ -1673,35 +1647,6 @@ converse.plugins.add('converse-muc-views', {
u.showElement(container);
},
removeEmptyHistoryFeedback () {
const el = this.msgs_container.firstElementChild;
if (_converse.muc_show_logs_before_join && el && el.matches('.empty-history-feedback')) {
this.msgs_container.removeChild(this.msgs_container.firstElementChild);
}
},
insertDayIndicator () {
this.removeEmptyHistoryFeedback();
return _converse.ChatBoxView.prototype.insertDayIndicator.apply(this, arguments);
},
insertMessage (view) {
this.removeEmptyHistoryFeedback();
return _converse.ChatBoxView.prototype.insertMessage.call(this, view);
},
insertNotification (message) {
this.removeEmptyHistoryFeedback();
this.msgs_container.insertAdjacentHTML(
'beforeend',
tpl_info({
'isodate': (new Date()).toISOString(),
'extra_classes': 'chat-event',
'message': message
})
);
},
onOccupantAdded (occupant) {
if (occupant.get('jid') === _converse.bare_jid) {
this.renderHeading();

View File

@ -194,13 +194,6 @@ converse.plugins.add('converse-omemo', {
this.__super__.initialize.apply(this, arguments);
this.listenTo(this.model, 'change:omemo_active', this.renderOMEMOToolbarButton);
this.listenTo(this.model, 'change:omemo_supported', this.onOMEMOSupportedDetermined);
},
showMessage (message) {
// We don't show a message if it's only keying material
if (!message.get('is_only_key')) {
return this.__super__.showMessage.apply(this, arguments);
}
}
},

View File

@ -45,7 +45,6 @@ const WHITELISTED_PLUGINS = [
'converse-emoji-views',
'converse-fullscreen',
'converse-mam-views',
'converse-message-view',
'converse-minimize',
'converse-modal',
'converse-muc-views',

View File

@ -331,8 +331,8 @@ converse.plugins.add('converse-chat', {
return;
}
this.set({'box_id': `box-${btoa(jid)}`});
this.initMessages();
this.initNotifications();
this.initMessages();
if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@ -395,9 +395,39 @@ converse.plugins.add('converse-chat', {
return this.messages.fetched;
},
async handleErrormessageStanza (stanza) {
if (await this.shouldShowErrorMessage(stanza)) {
this.createMessage(await st.parseMessage(stanza, _converse));
async handleErrorMessageStanza (stanza) {
const attrs = await st.parseMessage(stanza, _converse);
if (!await this.shouldShowErrorMessage(attrs)) {
return;
}
const message = this.getMessageReferencedByError(attrs);
if (message) {
const new_attrs = {
'error': attrs.error,
'error_condition': attrs.error_condition,
'error_text': attrs.error_text,
'error_type': attrs.error_type,
};
if (attrs.msgid === message.get('retraction_id')) {
// The error message refers to a retraction
new_attrs.retraction_id = undefined;
if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("You're not allowed to retract your message.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
}
}
} else if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("You're not allowed to send a message.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
}
}
message.save(new_attrs);
} else {
this.createMessage(attrs);
}
},
@ -510,7 +540,11 @@ converse.plugins.add('converse-chat', {
async createMessageFromError (error) {
if (error instanceof _converse.TimeoutError) {
const msg = await this.createMessage({'type': 'error', 'message': error.message, 'retry': true});
const msg = await this.createMessage({
'type': 'error',
'message': error.message,
'retry': true
});
msg.error = error;
}
},
@ -579,27 +613,29 @@ converse.plugins.add('converse-chat', {
return this;
},
/**
* Given an error `<message>` stanza's attributes, find the saved message model which is
* referenced by that error.
* @param { Object } attrs
*/
getMessageReferencedByError (attrs) {
const id = attrs.msgid;
return id && this.messages.models.find(m => [m.get('msgid'), m.get('retraction_id')].includes(id));
},
/**
* @private
* @method _converse.ChatBox#shouldShowErrorMessage
* @returns {boolean}
*/
shouldShowErrorMessage (stanza) {
const id = stanza.getAttribute('id');
if (id) {
const msgs = this.messages.where({'msgid': id});
const referenced_msgs = msgs.filter(m => m.get('type') !== 'error');
if (!referenced_msgs.length && stanza.querySelector('body') === null) {
// If the error refers to a message not included in our store,
// and it doesn't have a <body> tag, we assume that this was a
// CSI message (which we don't store).
// See https://github.com/conversejs/converse.js/issues/1317
return;
}
const dupes = msgs.filter(m => m.get('type') === 'error');
if (dupes.length) {
return;
}
shouldShowErrorMessage (attrs) {
const msg = this.getMessageReferencedByError(attrs);
if (!msg && attrs.body === null) {
// If the error refers to a message not included in our store,
// and it doesn't have a <body> tag, we assume that this was a
// CSI message (which we don't store).
// See https://github.com/conversejs/converse.js/issues/1317
return;
}
// Gets overridden in ChatRoom
return true;
@ -765,6 +801,7 @@ converse.plugins.add('converse-chat', {
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id'),
'retraction_id': message.get('id'),
'is_ephemeral': true,
'editable': false
});
@ -1044,9 +1081,9 @@ converse.plugins.add('converse-chat', {
});
return;
}
const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
max_file_size = window.parseInt((data?.attributes || {})['max-file-size']?.value),
slot_request_url = item?.id;
const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop();
const max_file_size = window.parseInt((data?.attributes || {})['max-file-size']?.value);
const slot_request_url = item?.id;
if (!slot_request_url) {
this.createMessage({
@ -1147,7 +1184,7 @@ converse.plugins.add('converse-chat', {
return;
}
const chatbox = await api.chatboxes.get(from_jid);
chatbox?.handleErrormessageStanza(stanza);
chatbox?.handleErrorMessageStanza(stanza);
}

View File

@ -382,8 +382,8 @@ converse.plugins.add('converse-muc', {
this.initialized = u.getResolveablePromise();
this.debouncedRejoin = debounce(this.rejoin, 250);
this.set('box_id', `box-${btoa(this.get('jid'))}`);
this.initMessages();
this.initNotifications();
this.initMessages();
this.initOccupants();
this.initDiscoModels(); // sendChatState depends on this.features
this.registerHandlers();
@ -618,15 +618,43 @@ converse.plugins.add('converse-muc', {
}
},
async handleErrormessageStanza (stanza) {
if (await this.shouldShowErrorMessage(stanza)) {
const attrs = await st.parseMUCMessage(stanza, this, _converse);
const message = attrs.msgid && this.messages.findWhere({'msgid': attrs.msgid});
if (message) {
message.save({'error': attrs.error});
} else {
this.createMessage(attrs);
async handleErrorMessageStanza (stanza) {
const attrs = await st.parseMUCMessage(stanza, this, _converse);
if (!await this.shouldShowErrorMessage(attrs)) {
return;
}
const message = this.getMessageReferencedByError(attrs);
if (message) {
const new_attrs = {
'error': attrs.error,
'error_condition': attrs.error_condition,
'error_text': attrs.error_text,
'error_type': attrs.error_type,
};
if (attrs.msgid === message.get('retraction_id')) {
// The error message refers to a retraction
new_attrs.retraction_id = undefined;
if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("You're not allowed to retract your message.");
} else if (attrs.error_condition === 'not-acceptable') {
new_attrs.error = __("Your retraction was not delivered because you're not present in the groupchat.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
}
}
} else if (!attrs.error) {
if (attrs.error_condition === 'forbidden') {
new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
} else if (attrs.error_condition === 'not-acceptable') {
new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
} else {
new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
}
}
message.save(new_attrs);
} else {
this.createMessage(attrs);
}
},
@ -749,20 +777,38 @@ converse.plugins.add('converse-muc', {
* @param { _converse.Message } message - The message which we're retracting.
*/
async retractOwnMessage(message) {
const origin_id = message.get('origin_id');
if (!origin_id) {
throw new Error("Can't retract message without a XEP-0359 Origin ID");
}
const editable = message.get('editable');
const stanza = $msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': "groupchat"
})
.c('store', {xmlns: Strophe.NS.HINTS}).up()
.c("apply-to", {
'id': origin_id,
'xmlns': Strophe.NS.FASTEN
}).c('retract', {xmlns: Strophe.NS.RETRACT});
// Optimistic save
message.save({
message.set({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id'),
'retracted_id': origin_id,
'retraction_id': stanza.nodeTree.getAttribute('id'),
'editable': false
});
try {
await this.sendRetractionMessage(message)
await this.sendTimedMessage(stanza);
} catch (e) {
message.save({
editable,
'error_type': 'timeout',
'error': __('A timeout happened while while trying to retract your message.'),
'retracted': undefined,
'retracted_id': undefined,
'retracted_id': undefined
});
throw e;
}
@ -799,30 +845,6 @@ converse.plugins.add('converse-muc', {
return result;
},
/**
* Sends a message stanza to retract a message in this groupchat.
* @private
* @method _converse.ChatRoom#sendRetractionMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
sendRetractionMessage (message) {
const origin_id = message.get('origin_id');
if (!origin_id) {
throw new Error("Can't retract message without a XEP-0359 Origin ID");
}
const msg = $msg({
'id': u.getUniqueId(),
'to': this.get('jid'),
'type': "groupchat"
})
.c('store', {xmlns: Strophe.NS.HINTS}).up()
.c("apply-to", {
'id': origin_id,
'xmlns': Strophe.NS.FASTEN
}).c('retract', {xmlns: Strophe.NS.RETRACT});
return this.sendTimedMessage(msg);
},
/**
* Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
* @private
@ -1815,13 +1837,11 @@ converse.plugins.add('converse-muc', {
* @method _converse.ChatRoom#shouldShowErrorMessage
* @returns {Promise<boolean>}
*/
async shouldShowErrorMessage (stanza) {
if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
if (await this.rejoinIfNecessary()) {
return false;
}
async shouldShowErrorMessage (attrs) {
if (attrs['error_condition'] === 'not-acceptable' && await this.rejoinIfNecessary()) {
return false;
}
return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, stanza);
return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
},
/**

View File

@ -463,16 +463,6 @@ u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true
el.dispatchEvent(evt);
};
u.geoUriToHttp = function(text, geouri_replacement) {
const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
return text.replace(regex, geouri_replacement);
};
u.httpToGeoUri = function(text, _converse) {
const replacement = 'geo:$1,$2';
return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
};
u.getSelectValues = function (select) {
const result = [];
const options = select && select.options;

View File

@ -3,7 +3,6 @@ import dayjs from 'dayjs';
import sizzle from 'sizzle';
import u from '@converse/headless/utils/core';
import log from "../log";
import { __ } from '@converse/headless/i18n';
import { api } from "@converse/headless/converse-core";
const Strophe = strophe.default.Strophe;
@ -243,20 +242,6 @@ function getReferences (stanza) {
});
}
/**
* Returns the human readable error message contained in an message stanza of type 'error'.
* @private
* @param { XMLElement } stanza - The message stanza
*/
function getErrorMessage (stanza) {
if (stanza.getAttribute('type') === 'error') {
const error = stanza.querySelector('error');
return error.querySelector('text')?.textContent ||
__('Sorry, an error occurred:') + ' ' + error.innerHTML;
}
}
function rejectMessage (stanza, text) {
// Reject an incoming message by replying with an error message of type "cancel".
api.send(
@ -278,20 +263,18 @@ function rejectMessage (stanza, text) {
* @private
* @param { XMLElement } stanza - The message stanza
*/
function getMUCErrorMessage (stanza) {
function getErrorAttributes (stanza) {
if (stanza.getAttribute('type') === 'error') {
const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop();
const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop();
if (forbidden) {
const msg = __("Your message was not delivered because you weren't allowed to send it.");
const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : '';
return server_msg ? `${msg} ${server_msg}` : msg;
} else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
return __("Your message was not delivered because you're not present in the groupchat.");
} else {
return text?.textContent;
const error = stanza.querySelector('error');
const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
return {
'is_error': true,
'error_text': text?.textContent,
'error_type': error.getAttribute('type'),
'error_condition': error.firstElementChild.nodeName
}
}
return {};
}
@ -458,6 +441,7 @@ const st = {
* @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
* @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
* @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted?
* @property { Boolean } is_error - Whether an error was received for this message
* @property { Boolean } is_headline - Is this a "headline" message?
* @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
* @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
@ -469,8 +453,10 @@ const st = {
* @property { String } body - The contents of the <body> tag of the message stanza
* @property { String } chat_state - The XEP-0085 chat state notification contained in this message
* @property { String } contact_jid - The JID of the other person or entity
* @property { String } edit - An ISO8601 string recording the time that the message was edited per XEP-0308
* @property { String } error - The error message, in case it's an error stanza
* @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
* @property { String } error_condition - The defined error condition
* @property { String } error_text - The error text received from the server
* @property { String } error_type - The type of error received from the server
* @property { String } from - The sender JID
* @property { String } fullname - The full name of the sender
* @property { String } marker - The XEP-0333 Chat Marker value
@ -503,7 +489,6 @@ const st = {
is_server_message,
'body': stanza.querySelector('body')?.textContent?.trim(),
'chat_state': getChatState(stanza),
'error': getErrorMessage(stanza),
'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')),
'is_archived': st.isArchived(original_stanza),
'is_carbon': isCarbon(original_stanza),
@ -523,6 +508,7 @@ const st = {
'to': stanza.getAttribute('to'),
'type': stanza.getAttribute('type')
},
getErrorAttributes(stanza),
getOutOfBandAttributes(stanza),
getSpoilerAttributes(stanza),
getCorrectionAttributes(stanza, original_stanza),
@ -589,6 +575,7 @@ const st = {
* @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
* @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
* @property { Boolean } is_encrypted - Is this message XEP-0384 encrypted?
* @property { Boolean } is_error - Whether an error was received for this message
* @property { Boolean } is_headline - Is this a "headline" message?
* @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
* @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
@ -599,8 +586,10 @@ const st = {
* @property { Object } encrypted - XEP-0384 encryption payload attributes
* @property { String } body - The contents of the <body> tag of the message stanza
* @property { String } chat_state - The XEP-0085 chat state notification contained in this message
* @property { String } edit - An ISO8601 string recording the time that the message was edited per XEP-0308
* @property { String } error - The error message, in case it's an error stanza
* @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
* @property { String } error_condition - The defined error condition
* @property { String } error_text - The error text received from the server
* @property { String } error_type - The type of error received from the server
* @property { String } from - The sender JID
* @property { String } from_muc - The JID of the MUC from which this message was sent
* @property { String } fullname - The full name of the sender
@ -632,7 +621,6 @@ const st = {
from,
'body': stanza.querySelector('body')?.textContent?.trim(),
'chat_state': getChatState(stanza),
'error': getMUCErrorMessage(stanza),
'from_muc': Strophe.getBareJidFromJid(from),
'is_archived': st.isArchived(original_stanza),
'is_carbon': isCarbon(original_stanza),
@ -652,6 +640,7 @@ const st = {
'to': stanza.getAttribute('to'),
'type': stanza.getAttribute('type'),
},
getErrorAttributes(stanza),
getOutOfBandAttributes(stanza),
getSpoilerAttributes(stanza),
getCorrectionAttributes(stanza, original_stanza),

View File

@ -0,0 +1,11 @@
import { BootstrapModal } from "../converse-modal.js";
import tpl_message_versions_modal from "../templates/message_versions_modal.js";
export default BootstrapModal.extend({
// FIXME: this isn't globally unique
id: "message-versions-modal",
toHTML () {
return tpl_message_versions_modal(this.model.toJSON());
}
});

View File

@ -1,5 +1,6 @@
import { html } from "lit-html";
export default (o) => html`
<img alt="${o.alt_text}" class="avatar align-self-center ${o.extra_classes}"
height="${o.height}" width="${o.width}" src="data:${o.image_type};base64,${o.image}"/>`;
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="${o.classes}" width="${o.width}" height="${o.height}">
<image width="${o.width}" height="${o.height}" preserveAspectRatio="xMidYMid meet" xlink:href="${o.image}"/>
</svg>`;

View File

@ -4,9 +4,9 @@ export default (o) => html`
<div class="flyout box-flyout">
<div class="chat-head chat-head-chatbox row no-gutters"></div>
<div class="chat-body">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" @scroll=${o.markScrolled} aria-live="polite">
<div class="chat-content__messages"></div>
<div class="chat-content__notifications"></div>
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
<div class="chat-content__messages smooth-scroll" @scroll=${o.markScrolled}></div>
<div class="chat-content__help"></div>
</div>
<div class="bottom-panel">
<div class="emoji-picker__container dropup"></div>

View File

@ -1,8 +1,4 @@
import { html } from "lit-html";
import { __ } from '@converse/headless/i18n';
const i18n_no_history = __('No message history available.');
export default (o) => html`
<div class="flyout box-flyout">
@ -10,10 +6,8 @@ export default (o) => html`
<div class="chat-body chatroom-body row no-gutters">
<div class="chat-area col">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
<div class="chat-content__messages">
${ o.muc_show_logs_before_join ? html`<div class="empty-history-feedback"><span>${ i18n_no_history }</span></div>` : '' }
</div>
<div class="chat-content__notifications"></div>
<div class="chat-content__messages smooth-scroll" @scroll=${o.markScrolled}></div>
<div class="chat-content__help"></div>
</div>
<div class="bottom-panel"></div>
</div>

View File

@ -1,10 +1,8 @@
import '../components/dropdown.js';
import { __ } from '@converse/headless/i18n';
import { html } from "lit-html";
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import { until } from 'lit-html/directives/until.js';
import { converse } from "@converse/headless/converse-core";
import xss from "xss/dist/xss";
const u = converse.env.utils;
const i18n_hide_topic = __('Hide the groupchat topic');
@ -15,7 +13,7 @@ const tpl_standalone_btns = (o) => o.standalone_btns.reverse().map(b => until(b,
export default (o) => {
const subject = o.subject ? u.addHyperlinks(xss.filterXSS(o.subject.text, {'whiteList': {}})) : '';
const subject = o.subject ? u.addHyperlinks(o.subject.text) : '';
const show_subject = (subject && !o.subject_hidden);
return html`
<div class="chatbox-title ${ show_subject ? '' : "chatbox-title--no-desc"}">
@ -28,6 +26,6 @@ export default (o) => {
${ o.dropdown_btns.length ? html`<converse-dropdown .items=${o.dropdown_btns}></converse-dropdown>` : '' }
</div>
</div>
${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">${unsafeHTML(subject)}</p>` : '' }
${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">${subject}</p>` : '' }
`;
}

View File

@ -0,0 +1,31 @@
import tpl_avatar from "templates/avatar.svg";
import xss from "xss/dist/xss";
import { directive, html } from "lit-html";
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
export const renderAvatar = directive(o => part => {
if (o.type === 'headline' || o.is_me_message) {
part.setValue('');
return;
}
if (o.model.vcard) {
const data = {
'classes': 'avatar chat-msg__avatar',
'width': 36,
'height': 36,
}
const image_type = o.model.vcard.get('image_type');
const image = o.model.vcard.get('image');
data['image'] = "data:" + image_type + ";base64," + image;
const avatar = tpl_avatar(data);
const opts = {
'whiteList': {
'svg': ['xmlns', 'xmlns:xlink', 'class', 'width', 'height'],
'image': ['width', 'height', 'preserveAspectRatio', 'xlink:href']
}
};
part.setValue(html`${unsafeHTML(xss.filterXSS(avatar, opts))}`);
}
});

View File

@ -0,0 +1,111 @@
import { _converse, api, converse } from "@converse/headless/converse-core";
import { directive, html } from "lit-html";
import { isString } from "lodash";
const u = converse.env.utils;
class MessageBodyRenderer extends String {
constructor (component) {
super();
this.text = component.model.getMessageText();
this.model = component.model;
this.component = component;
}
async transform () {
/**
* Synchronous event which provides a hook for transforming a chat message's body text
* before the default transformations have been applied.
* @event _converse#beforeMessageBodyTransformed
* @param { _converse.Message } model - The model representing the message
* @param { string } text - The message text
* @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
*/
await api.trigger('beforeMessageBodyTransformed', this.model, this.text, {'Synchronous': true});
let text = this.component.is_me_message ? this.text.substring(4) : this.text;
// Collapse multiple line breaks into at most two
text = text.replace(/\n\n+/g, '\n\n');
text = u.geoUriToHttp(text, _converse.geouri_replacement);
const process = (text) => {
text = u.addEmoji(text);
return addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
}
const list = await Promise.all(u.addHyperlinks(text));
this.list = list.reduce((acc, i) => isString(i) ? [...acc, ...process(i)] : [...acc, i], []);
/**
* Synchronous event which provides a hook for transforming a chat message's body text
* after the default transformations have been applied.
* @event _converse#afterMessageBodyTransformed
* @param { _converse.Message } model - The model representing the message
* @param { string } text - The message text
* @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
*/
await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true});
return this.list;
}
async render () {
return html`${await this.transform()}`
}
get length () {
return this.text.length;
}
toString () {
return "" + this.text;
}
textOf () {
return this.toString();
}
}
const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
function addMentionsMarkup (text, references, chatbox) {
if (chatbox.get('message_type') === 'groupchat' && references.length) {
let list = [text];
const nick = chatbox.get('nick');
references
.sort((a, b) => b.begin - a.begin)
.forEach(ref => {
const text = list.shift();
const mention = text.slice(ref.begin, ref.end);
if (mention === nick) {
list = [
text.slice(0, ref.begin),
tpl_mention_with_nick({mention}),
text.slice(ref.end),
...list
];
} else {
list = [
text.slice(0, ref.begin),
tpl_mention({mention}),
text.slice(ref.end),
...list
];
}
});
return list;
} else {
return [text];
}
}
export const renderBodyText = directive(component => async part => {
const model = component.model;
const renderer = new MessageBodyRenderer(component);
part.setValue(await renderer.render());
part.commit();
model.collection?.trigger('rendered', model);
});

View File

@ -0,0 +1,23 @@
import { directive, html } from "lit-html";
import { __ } from '@converse/headless/i18n';
const i18n_retract_message = __('Retract this message');
const tpl_retract = (o) => html`
<button class="chat-msg__action chat-msg__action-retract" title="${i18n_retract_message}" @click=${o.onMessageRetractButtonClicked}>
<fa-icon class="fas fa-trash-alt" path-prefix="/dist" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
</button>
`;
export const renderRetractionLink = directive(o => async part => {
const may_be_moderated = o.model.get('type') === 'groupchat' && await o.model.mayBeModerated();
const retractable = !o.is_retracted && (o.model.mayBeRetracted() || may_be_moderated);
if (retractable) {
part.setValue(tpl_retract(o));
} else {
part.setValue('');
}
part.commit();
});

View File

@ -1,7 +0,0 @@
<div class="message chat-msg" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
<canvas class="avatar chat-msg__avatar" height="36" width="36"></canvas>
<div class="chat-msg__content">
<span class="chat-msg__text">{{{o.__('Uploading file:')}}} <strong>{{{o.filename}}}</strong>, {{{o.filesize}}}</span>
<progress value="{{{o.progress}}}"/>
</div>
</div>

View File

@ -0,0 +1,16 @@
import { __ } from '@converse/headless/i18n';
import { html } from "lit-html";
import { renderAvatar } from './../templates/directives/avatar';
const i18n_uploading = __('Uploading file:')
export default (o) => html`
<div class="message chat-msg" data-isodate="${o.time}" data-msgid="${o.msgid}">
${ renderAvatar(this) }
<div class="chat-msg__content">
<span class="chat-msg__text">${i18n_uploading} <strong>${o.filename}</strong>, ${o.filesize}</span>
<progress value="${o.progress}"/>
</div>
</div>
`;

View File

@ -1 +0,0 @@
<div class="message chat-info {[ if (o.type !== 'info') { ]} chat-{{{o.type}}} {[ } ]}" data-isodate="{{{o.isodate}}}">{{o.message}}</div>

View File

@ -1,13 +0,0 @@
<div class="message chat-info {{{o.extra_classes}}}" data-isodate="{{{o.isodate}}}" {[ if (o.data_name) { ]} data-{{{o.data_name}}}="{{{o.data_value}}}"{[ } ]}>
{[ if (o.render_message) {
// XXX: Should only ever be rendered if the message text has been sanitized already
]}
{{o.message}}
{[ } else { ]}
<div class="chat-info__message">{{{o.message}}}</div>
{[ if (o.reason) { ]}<q class="reason">{{{o.reason}}}</q>{[ } ]}
{[ } ]}
{[ if (o.retry) { ]}
<a class="retry">Retry</a>
{[ } ]}
</div>

View File

@ -1,52 +0,0 @@
<div class="message chat-msg {{{o.type}}} {{{o.extra_classes}}} {[ if (o.is_me_message) { ]} chat-msg--action {[ } ]}"
data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}" data-from="{{{o.from}}}" data-encrypted="{{{o.is_encrypted}}}">
{[ if (o.type !== 'headline' && !o.is_me_message) { ]}
<canvas class="avatar chat-msg__avatar" height="36" width="36"></canvas>
{[ } ]}
<div class="chat-msg__content chat-msg__content--{{{o.sender}}} {{{o.is_me_message ? 'chat-msg__content--action' : ''}}}">
{[ if (o.first_unread) { ]}
<div class="message date-separator"><hr class="separator"><span class="separator-text">{{{o.__('unread messages')}}}</span></div>
{[ } ]}
<span class="chat-msg__heading">{[ if (o.is_me_message) { ]}
<time timestamp="{{{o.isodate}}}" class="chat-msg__time">{{{o.pretty_time}}}</time>
{[ } ]}<span class="chat-msg__author">{[ if (o.is_me_message) { ]}**{[ }; ]}{{{o.username}}}</span>
{[ if (!o.is_me_message) { ]}{[o.hats.forEach(function (hat) { ]}<span class="badge badge-secondary">{{{hat.title}}}</span>
{[ }); ]}<time timestamp="{{{o.isodate}}}" class="chat-msg__time">{{{o.pretty_time}}}</time>{[ } ]}{[ if (o.is_encrypted) { ]}
<span class="fa fa-lock"></span>
{[ } ]}</span>
<div class="chat-msg__body chat-msg__body--{{{o.type}}} {{{o.received ? 'chat-msg__body--received' : '' }}} {{{o.is_delayed ? 'chat-msg__body--delayed' : '' }}}">
<div class="chat-msg__message">
{[ if (o.is_retracted) { ]}
<div>{{{o.retraction_text}}}</div>
{[ if (o.moderation_reason) { ]}<q class="chat-msg--retracted__reason">{{{o.moderation_reason}}}</q>{[ } ]}
{[ } else { ]}
{[ if (o.is_spoiler) { ]}
<div class="chat-msg__spoiler-hint">
<span class="spoiler-hint">{{{o.spoiler_hint}}}</span>
<a class="badge badge-info spoiler-toggle" data-toggle-state="closed" href="#"><i class="fa fa-eye"></i>{{{o.label_show}}}</a>
</div>
{[ } ]}
{[ if (o.subject) { ]}
<div class="chat-msg__subject">{{{ o.subject }}}</div>
{[ } ]}
<div class="chat-msg__text
{[ if (o.is_only_emojis) { ]} chat-msg__text--larger{[ } ]}
{[ if (o.is_spoiler) { ]} spoiler collapsed{[ } ]}"><!-- message gets added here via renderMessage --></div>
<div class="chat-msg__media"></div>
<div class="chat-msg__error">{{{o.error}}}</div>
{[ } ]}
</div>
{[ if (o.received && !o.is_me_message && !o.is_groupchat_message) { ]} <span class="fa fa-check chat-msg__receipt"></span> {[ } ]}
{[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
<div class="chat-msg__actions">
{[ if (o.editable) { ]}
<button class="chat-msg__action chat-msg__action-edit fa fa-pencil-alt" title="{{{o.__('Edit this message')}}}"></button>
{[ } ]}
{[ if (o.retractable) { ]}
<button class="chat-msg__action chat-msg__action-retract fa fa-trash-alt" title="{{{o.__('Retract this message')}}}"></button>
{[ } ]}
</div>
</div>
</div>
</div>

9
src/templates/new_day.js Normal file
View File

@ -0,0 +1,9 @@
import { html } from "lit-html";
export default (o) => html`
<div class="message date-separator" data-isodate="${o.time}">
<hr class="separator"/>
<time class="separator-text" datetime="${o.time}"><span>${o.datestring}</span></time>
</div>
`;

View File

@ -4,7 +4,6 @@
* @description This is the DOM/HTML utilities module.
*/
import URI from "urijs";
import { isFunction } from "lodash";
import log from '@converse/headless/log';
import sizzle from "sizzle";
import tpl_audio from "../templates/audio.js";
@ -20,8 +19,10 @@ import tpl_image from "../templates/image.js";
import tpl_select_option from "../templates/select_option.html";
import tpl_video from "../templates/video.js";
import u from "../headless/utils/core";
import { api } from "@converse/headless/converse-core";
import { html } from "lit-html";
import { isFunction } from "lodash";
const URL_REGEX = /\b(https?\:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
function getAutoCompleteProperty (name, options) {
@ -96,7 +97,7 @@ function renderAudioURL (_converse, uri) {
function renderImageURL (_converse, uri) {
if (!_converse.api.settings.get('show_images_inline')) {
return u.convertUriToHyperlink(uri);
return u.convertURIoHyperlink(uri);
}
const { __ } = _converse;
return tpl_image({
@ -179,60 +180,6 @@ function loadImage (url) {
}
async function renderImage (img_url, link_url, el, callback) {
if (u.isImageURL(img_url)) {
let img;
try {
img = await loadImage(img_url);
} catch (e) {
log.error(e);
return callback();
}
sizzle(`a[href="${link_url}"]`, el).forEach(a => {
a.innerHTML = "";
u.addClass('chat-image__link', a);
u.addClass('chat-image', img);
u.addClass('img-thumbnail', img);
a.insertAdjacentElement('afterBegin', img);
});
}
callback();
}
/**
* Returns a Promise which resolves once all images have been loaded.
* @method u#renderImageURLs
* @param { _converse }
* @param { HTMLElement }
* @returns { Promise }
*/
u.renderImageURLs = function (_converse, el) {
if (!_converse.api.settings.get('show_images_inline')) {
return Promise.resolve();
}
const list = el.textContent.match(URL_REGEX) || [];
return Promise.all(
list.map(url =>
new Promise(resolve => {
let image_url = getURI(url);
if (['imgur.com', 'pbs.twimg.com'].includes(image_url.hostname()) && !u.isImageURL(url)) {
const format = (image_url.hostname() === 'pbs.twimg.com') ? image_url.search(true).format : 'png';
image_url = image_url.removeSearch(/.*/).toString() + `.${format}`;
renderImage(image_url, url, el, resolve);
} else {
renderImage(url, url, el, resolve);
}
})
)
)
};
u.renderNewLines = function (text) {
return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
};
u.calculateElementHeight = function (el) {
/* Return the height of the passed in DOM element,
* based on the heights of its children.
@ -364,42 +311,43 @@ u.escapeHTML = function (string) {
.replace(/"/g, "&quot;");
};
u.addMentionsMarkup = function (text, references, chatbox) {
if (chatbox.get('message_type') !== 'groupchat') {
return text;
u.convertToImageTag = async function (url) {
const uri = getURI(url);
const img_url_without_ext = ['imgur.com', 'pbs.twimg.com'].includes(uri.hostname());
let src;
if (u.isImageURL(url) || img_url_without_ext) {
if (img_url_without_ext) {
const format = (uri.hostname() === 'pbs.twimg.com') ? uri.search(true).format : 'png';
src = uri.removeSearch(/.*/).toString() + `.${format}`;
} else {
src = url;
}
try {
await loadImage(src);
} catch (e) {
log.error(e);
return u.convertUrlToHyperlink(url);
}
return tpl_image({url, src});
}
const nick = chatbox.get('nick');
references
.sort((a, b) => b.begin - a.begin)
.forEach(ref => {
const prefix = text.slice(0, ref.begin);
const offset = ((prefix.match(/&lt;/g) || []).length + (prefix.match(/&gt;/g) || []).length) * 3;
const begin = parseInt(ref.begin, 10) + parseInt(offset, 10);
const end = parseInt(ref.end, 10) + parseInt(offset, 10);
const mention = text.slice(begin, end)
chatbox;
}
if (mention === nick) {
text = text.slice(0, begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(end);
} else {
text = text.slice(0, begin) + `<span class="mention">${mention}</span>` + text.slice(end);
}
});
return text;
};
u.convertUriToHyperlink = function (uri, urlAsTyped) {
let normalizedUrl = uri.normalize()._string;
const pretty_url = uri._parts.urn ? normalizedUrl : uri.readable();
const visibleUrl = u.escapeHTML(urlAsTyped || pretty_url);
if (!uri._parts.protocol && !normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
normalizedUrl = 'http://' + normalizedUrl;
u.convertURIoHyperlink = function (uri, urlAsTyped) {
let normalized_url = uri.normalize()._string;
const pretty_url = uri._parts.urn ? normalized_url : uri.readable();
const visible_url = urlAsTyped || pretty_url;
if (!uri._parts.protocol && !normalized_url.startsWith('http://') && !normalized_url.startsWith('https://')) {
normalized_url = 'http://' + normalized_url;
}
if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
return `<a target="_blank" rel="noopener" class="open-chatroom" href="${normalizedUrl}">${visibleUrl}</a>`;
return html`
<a target="_blank"
rel="noopener"
@click=${ev => api.rooms.open(ev.target.href)}
href="${normalized_url}">${visible_url}</a>`;
}
return `<a target="_blank" rel="noopener" href="${normalizedUrl}">${visibleUrl}</a>`;
return html`<a target="_blank" rel="noopener" href="${normalized_url}">${visible_url}</a>`;
};
function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
@ -417,27 +365,59 @@ function isUrlValid (urlString) {
}
u.convertUrlToHyperlink = function (url) {
const urlWithProtocol = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
const uri = getURI(url);
if (uri !== null && isUrlValid(urlWithProtocol) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
const hyperlink = this.convertUriToHyperlink(uri, url);
return hyperlink;
if (uri !== null && isUrlValid(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
return this.convertURIoHyperlink(uri, url);
}
return url;
};
u.addHyperlinks = function (text) {
const objs = [];
const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
try {
const parse_options = {
'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
};
return URI.withinString(text, url => u.convertUrlToHyperlink(url), parse_options);
URI.withinString(text, (url, start, end) => {
objs.push({url, start, end})
return url;
} , parse_options);
} catch (error) {
log.debug(error);
return text;
return [text];
}
const show_images = api.settings.get('show_images_inline');
let list = [text];
if (objs.length) {
objs.sort((a, b) => b.start - a.start)
.forEach(url_obj => {
const text = list.shift();
const url_text = text.slice(url_obj.start, url_obj.end);
list = [
text.slice(0, url_obj.start),
show_images && u.isImageURL(url_text) ?
u.convertToImageTag(url_text) :
u.convertUrlToHyperlink(url_text),
text.slice(url_obj.end),
...list
];
});
} else {
list = [text];
}
return list;
}
u.geoUriToHttp = function(text, geouri_replacement) {
const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
return text.replace(regex, geouri_replacement);
};
u.httpToGeoUri = function(text, _converse) {
const replacement = 'geo:$1,$2';
return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
};
u.slideInAllElements = function (elements, duration=300) {
return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));

View File

@ -20,6 +20,7 @@ module.exports = merge(common, {
new MiniCssExtractPlugin({filename: '../dist/converse.min.css'}),
new CopyWebpackPlugin([
{from: 'sounds'},
{from: 'node_modules/@fortawesome/fontawesome-free/sprites/solid.svg', to: '@fortawesome/fontawesome-free/sprites/solid.svg'},
{from: 'images/favicon.ico', to: 'images/favicon.ico'},
{from: 'images/custom_emojis', to: 'images/custom_emojis'},
{from: 'logo/conversejs-filled-192.png', to: 'images/logo'},