Commit 7651d584 authored by JC Brand's avatar JC Brand

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
parent a497e8df
......@@ -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",
......
......@@ -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
......
......@@ -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: " ";
}
......
......@@ -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;
......
......@@ -340,7 +340,7 @@ body.converse-fullscreen {
q {
quotes: "“" "”" "‘" "’";
&.reason {
display: block;
display: inline;
}
}
q:before {
......
......@@ -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 {
......
......@@ -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();
}));
});
......
......@@ -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);
......
......@@ -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");
......
......@@ -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));
}));
......
......@@ -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'})
......
This diff is collapsed.
......@@ -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'),
......
This diff is collapsed.
This diff is collapsed.
......@@ -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();
......
......@@ -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."
);
......
......@@ -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'], {
......
......@@ -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);
......
/* 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();
}));
});
This diff is collapsed.
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;
......
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);
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;
......
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);
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);
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);
This diff is collapsed.
This diff is collapsed.
......@@ -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
......
This diff is collapsed.
This diff is collapsed.
......@@ -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);
}
}
},
......
......@@ -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',
......
......@@ -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);
}
......
......@@ -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);
},
/**
......
......@@ -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;
......
......@@ -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),
......
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());
}
});
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>`;
......@@ -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>
......
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>
......
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>` : '' }
`;
}
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))}`);
}
});
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);
});
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();
});
<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>
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>
`;
<div class="message chat-info {[ if (o.type !== 'info') { ]} chat-{{{o.type}}} {[ } ]}" data-isodate="{{{o.isodate}}}">{{o.message}}</div>
<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>
<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>
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>
`;
......@@ -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)));
......
......@@ -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'},
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment