From 7651d58470518d6d8ee94829c254d44216978292 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Fri, 15 May 2020 14:33:31 +0200 Subject: [PATCH] Render chat messages as web components - Render chat content as a component - Create new component for rendering the message body - Get rid of `showMessage` method --- .eslintrc.json | 1 + karma.conf.js | 14 +- sass/_chatbox.scss | 40 ++- sass/_chatrooms.scss | 32 +- sass/_core.scss | 2 +- sass/_messages.scss | 3 +- spec/chatbox.js | 45 +-- spec/emojis.js | 27 +- spec/hats.js | 2 +- spec/http-file-upload.js | 22 +- spec/mam.js | 19 +- spec/messages.js | 247 +++++++-------- spec/mock.js | 10 +- spec/muc.js | 143 ++++----- spec/muc_messages.js | 50 +-- spec/notification.js | 4 +- spec/omemo.js | 11 +- spec/push.js | 4 + spec/retractions.js | 46 ++- spec/spoilers.js | 46 +-- spec/xss.js | 48 +-- src/components/adhoc-commands.js | 4 +- src/components/chat_content.js | 41 +++ src/components/dropdown.js | 6 +- src/components/help_messages.js | 43 +++ src/components/message-body.js | 29 ++ src/components/message-history.js | 124 ++++++++ src/components/message.js | 288 ++++++++++++++++++ src/converse-chatview.js | 401 ++++++++----------------- src/converse-headlines-view.js | 8 +- src/converse-message-view.js | 386 ------------------------ src/converse-muc-views.js | 151 +++------- src/converse-omemo.js | 7 - src/converse.js | 1 - src/headless/converse-chat.js | 87 ++++-- src/headless/converse-muc.js | 106 ++++--- src/headless/utils/core.js | 10 - src/headless/utils/stanza.js | 53 ++-- src/modals/message-versions.js | 11 + src/templates/avatar.js | 5 +- src/templates/chatbox.js | 6 +- src/templates/chatroom.js | 10 +- src/templates/chatroom_head.js | 6 +- src/templates/directives/avatar.js | 31 ++ src/templates/directives/body.js | 111 +++++++ src/templates/directives/retraction.js | 23 ++ src/templates/file_progress.html | 7 - src/templates/file_progress.js | 16 + src/templates/help_message.html | 1 - src/templates/info.html | 13 - src/templates/message.html | 52 ---- src/templates/new_day.js | 9 + src/utils/html.js | 172 +++++------ webpack.prod.js | 1 + 54 files changed, 1592 insertions(+), 1443 deletions(-) create mode 100644 src/components/chat_content.js create mode 100644 src/components/help_messages.js create mode 100644 src/components/message-body.js create mode 100644 src/components/message-history.js create mode 100644 src/components/message.js delete mode 100644 src/converse-message-view.js create mode 100644 src/modals/message-versions.js create mode 100644 src/templates/directives/avatar.js create mode 100644 src/templates/directives/body.js create mode 100644 src/templates/directives/retraction.js delete mode 100644 src/templates/file_progress.html create mode 100644 src/templates/file_progress.js delete mode 100644 src/templates/help_message.html delete mode 100644 src/templates/info.html delete mode 100644 src/templates/message.html create mode 100644 src/templates/new_day.js diff --git a/.eslintrc.json b/.eslintrc.json index 3f65b3e0a..ea8f2c230 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", diff --git a/karma.conf.js b/karma.conf.js index 6952d2561..7b92640eb 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -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 diff --git a/sass/_chatbox.scss b/sass/_chatbox.scss index 565b6eb60..c97303ae1 100644 --- a/sass/_chatbox.scss +++ b/sass/_chatbox.scss @@ -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: " "; } diff --git a/sass/_chatrooms.scss b/sass/_chatrooms.scss index 6bcafce28..9018e07dc 100644 --- a/sass/_chatrooms.scss +++ b/sass/_chatrooms.scss @@ -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; diff --git a/sass/_core.scss b/sass/_core.scss index 7b9175ba6..03d5647fc 100644 --- a/sass/_core.scss +++ b/sass/_core.scss @@ -340,7 +340,7 @@ body.converse-fullscreen { q { quotes: "“" "”" "‘" "’"; &.reason { - display: block; + display: inline; } } q:before { diff --git a/sass/_messages.scss b/sass/_messages.scss index 95e895c2f..af98a2358 100644 --- a/sass/_messages.scss +++ b/sass/_messages.scss @@ -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 { diff --git a/spec/chatbox.js b/spec/chatbox.js index d494ac607..d5f29c6d0 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -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('<p>This message contains <em>some</em> <b>markup</b></p>'); 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: www.opkode.com'); done(); })); @@ -886,8 +884,8 @@ describe("A Chat Message", function () { Hey\nHave you heard the news? `); _converse.connection._dataRecv(mock.createRequest(stanza)); - await new Promise(resolve => view.once('messageInserted', resolve)); - expect(view.content.querySelector('.chat-msg__text').innerHTML).toBe('Hey
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(` Hey\n\n\nHave you heard the news? `); _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

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(` Hey\nHave you heard\nthe news? `); _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
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\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( - ``); + expect(msg.innerHTML.replace(//g, '').trim()).toEqual( + ``+ + ``+ + ``); + message += "?param1=val1¶m2=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( - '') + expect(msg.innerHTML.replace(//g, '').trim()).toEqual( + ``+ + ``+ + ``); // 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 () { https://montague.lit/audio.mp3 `) _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( - ` `+ - `Download audio file "audio.mp3"`); + expect(media.innerHTML.replace(//g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( + ` `+ + `Download audio file "audio.mp3"`); // If the and contents is the same, don't duplicate. stanza = u.toStanza(` @@ -1833,14 +1840,14 @@ describe("A Chat Message", function () { https://montague.lit/audio.mp3 `); _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(''); // 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( - ` `+ + expect(media.innerHTML.replace(//g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( + ` `+ ``+ - `Download audio file "audio.mp3"`); + `Download audio file "audio.mp3"`); done(); })); @@ -1881,9 +1888,9 @@ describe("A Chat Message", function () { https://montague.lit/video.mp4 `); _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(''); // 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( ``); @@ -1908,7 +1915,7 @@ describe("A Chat Message", function () { https://montague.lit/funny.pdf `); _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 () { `); _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); diff --git a/spec/mock.js b/spec/mock.js index 41c038ff5..05976f2b5 100644 --- a/spec/mock.js +++ b/spec/mock.js @@ -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'), diff --git a/spec/muc.js b/spec/muc.js index 81068d955..07a6dc429 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -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 () { This is a 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 () { This is a 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 () { `))); 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 () { `); _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 () { `); _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( ` @@ -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( ` Disconnected: closed @@ -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); }); // 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(` - + `); _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(` - + Thou shalt not! `); _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(); })); diff --git a/spec/muc_messages.js b/spec/muc_messages.js index d8d418335..fcee7c489 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -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 z3r0 '+ 'tom '+ 'mr.robot, 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( '>hello z3r0 '+ 'tom '+ 'mr.robot, 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(` 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 z3r0 gibson mr.robot, how are you?' + ); + const msg = _converse.connection.send.calls.all()[0].args[0]; expect(msg.toLocaleString()) .toBe(` 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()) diff --git a/spec/notification.js b/spec/notification.js index 6733b61cf..7e1a18afd 100644 --- a/spec/notification.js +++ b/spec/notification.js @@ -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(); diff --git a/spec/omemo.js b/spec/omemo.js index 42db3da2f..d6ba74062 100644 --- a/spec/omemo.js +++ b/spec/omemo.js @@ -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() { `); _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." ); diff --git a/spec/push.js b/spec/push.js index 81e94a18b..8d53d30d7 100644 --- a/spec/push.js +++ b/spec/push.js @@ -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'], { diff --git a/spec/retractions.js b/spec/retractions.js index 35844072c..0ee89ae79 100644 --- a/spec/retractions.js +++ b/spec/retractions.js @@ -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 () { `); - 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 () { ``+ ``); + 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 () { `); _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 () { `); 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 () { `); 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); diff --git a/spec/spoilers.js b/spec/spoilers.js index eedbdaae7..ab7db1b82 100644 --- a/spec/spoilers.js +++ b/spec/spoilers.js @@ -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(); })); }); diff --git a/spec/xss.js b/spec/xss.js index cd5ee407e..8a454cb52 100644 --- a/spec/xss.js +++ b/spec/xss.js @@ -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("<img src=x onerror=alert('XSS');>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<img src=x onerror=alert('XSS');>"); expect(window.alert).not.toHaveBeenCalled(); message = "/g, '')).toEqual("<img src=x onerror=alert('XSS')//"); message = ""; 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("<img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<img src=x onerror=alert(String.fromCharCode(88,83,83));>"); message = ""; 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("<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>"); message = "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("<img src=x:alert(alt) onerror=eval(src) alt=xss>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<img src=x:alert(alt) onerror=eval(src) alt=xss>"); message = ">"; 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("><img src=x onerror=alert('XSS');>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("><img src=x onerror=alert('XSS');>"); message = ">"; 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("><img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("><img src=x onerror=alert(String.fromCharCode(88,83,83));>"); 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('<svg onload=alert(1)>'); + expect(msg.innerHTML.replace(//g, '')).toEqual('<svg onload=alert(1)>'); message = ""; 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("<svg/onload=alert('XSS')>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<svg/onload=alert('XSS')>"); message = "/g, '')).toEqual("<svg onload=alert(1)//"); message = ""; 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("<svg/onload=alert(String.fromCharCode(88,83,83))>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<svg/onload=alert(String.fromCharCode(88,83,83))>"); message = ""; 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("<svg id=alert(1) onload=eval(id)>"); + expect(msg.innerHTML.replace(//g, '')).toEqual("<svg id=alert(1) onload=eval(id)>"); message = '">'; 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('"><svg/onload=alert(String.fromCharCode(88,83,83))>'); + expect(msg.innerHTML.replace(//g, '')).toEqual('"><svg/onload=alert(String.fromCharCode(88,83,83))>'); message = '">/g, '')).toEqual('"><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('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever'); 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('http://www.opkode.com/"onmouseover="alert(1)"whatever'); + expect(msg.innerHTML.replace(//g, '')).toEqual('http://www.opkode.com/"onmouseover="alert(1)"whatever'); 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(''+message+''); + expect(msg.innerHTML.replace(//g, '')).toEqual(''+message+''); message = ""; 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( `<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>`); message = ''; @@ -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( '<http://www.opkode.com/"onmouseover="alert(1)"whatever>'); 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( `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`); 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(`${entered}`); + expect(msg.innerHTML.replace(//g, '')).toEqual(`${entered}`); } 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(`${entered}`); + expect(msg.textContent.trim()).toEqual(entered); + expect(msg.innerHTML.replace(//g, '').trim()).toEqual(`${entered}`); } await mock.sendMessage(view, bad_urls[0]); diff --git a/src/components/adhoc-commands.js b/src/components/adhoc-commands.js index 6efa6a158..0d0387586 100644 --- a/src/components/adhoc-commands.js +++ b/src/components/adhoc-commands.js @@ -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; diff --git a/src/components/chat_content.js b/src/components/chat_content.js new file mode 100644 index 000000000..a45323a8f --- /dev/null +++ b/src/components/chat_content.js @@ -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` + + +
${unsafeHTML(notifications)}
+ `; + } + + 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); diff --git a/src/components/dropdown.js b/src/components/dropdown.js index 0f77b7065..fa32846d2 100644 --- a/src/components/dropdown.js +++ b/src/components/dropdown.js @@ -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; diff --git a/src/components/help_messages.js b/src/components/help_messages.js new file mode 100644 index 000000000..5f4eb4d91 --- /dev/null +++ b/src/components/help_messages.js @@ -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``, + ...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`
${unsafeHTML(o.markup)}
`; + } +} + +customElements.define('converse-chat-help', ChatHelp); diff --git a/src/components/message-body.js b/src/components/message-body.js new file mode 100644 index 000000000..b875486ff --- /dev/null +++ b/src/components/message-body.js @@ -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` +
${renderBodyText(this)}
+ `; + } + +} + +customElements.define('converse-chat-message-body', MessageBody); diff --git a/src/components/message-history.js b/src/components/message-history.js new file mode 100644 index 000000000..14d674cab --- /dev/null +++ b/src/components/message-history.js @@ -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` + +`; + + +// 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`
${i18n_no_history}
`; + } + + 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); diff --git a/src/components/message.js b/src/components/message.js new file mode 100644 index 000000000..e4972fac1 --- /dev/null +++ b/src/components/message.js @@ -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` +
+ +
+ ${ this.model.getMessageText() } +
+ ${ this.reason ? html`${this.reason}` : `` } + ${ this.error_text ? html`${this.error_text}` : `` } + ${ this.allow_retry ? html`${i18n_retry}` : '' } +
+ `; + } + + renderFileProgress () { + const filename = this.model.file.name; + const size = filesize(this.model.file.size); + return html` +
+ ${ renderAvatar(this) } +
+ ${i18n_uploading} ${filename}, ${size} + +
+
`; + } + + renderChatMessage () { + const is_groupchat_message = (this.message_type === 'groupchat'); + return html` +
+ + ${ renderAvatar(this) } +
+ ${this.first_unread ? html`

{{{this.__('unread messages')}}}
` : '' } + + ${ (this.is_me_message) ? html` + + ${this.hats.map(hat => html`${hat}`)} + ` : '' } + ${ this.is_me_message ? '**' : ''}${this.username} + ${ !this.is_me_message ? this.renderAvatarByline() : '' } + ${ this.is_encrypted ? html`` : '' } + +
+
+ ${ this.is_retracted ? this.renderRetraction() : this.renderMessageText() } +
+ ${ (this.received && !this.is_me_message && !is_groupchat_message) ? html`` : '' } + ${ (this.edited) ? html`` : '' } +
+ ${ this.editable ? + html`` : '' } + ${ renderRetractionLink(this) } +
+
+
+
`; + } + + 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` +
${retraction_text}
+ ${ this.moderation_reason ? html`${this.moderation_reason}` : '' } + `; + } + + renderMessageText () { + const tpl_spoiler_hint = html` + + `; + return html` + ${ this.is_spoiler ? tpl_spoiler_hint : '' } + ${ this.subject ? html`
${this.subject}
` : '' } + + ${ this.oob_url ? html`
${u.getOOBURLMarkup(_converse, this.oob_url)}
` : '' } +
${ this.error_text || this.error }
+ `; + } + + renderAvatarByline () { + return html` + ${ this.hats.map(h => html`${h.title}`) } + + `; + } + + 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); diff --git a/src/converse-chatview.js b/src/converse-chatview.js index 79a45bcbb..523245854 100644 --- a/src/converse-chatview.js +++ b/src/converse-chatview.js @@ -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 [ + `/clear: ${__('Remove messages')}`, + `/close: ${__('Close this chat')}`, + `/me: ${__('Write in the third person')}`, + `/help: ${__('Show this menu')}` + ]; + }, + + renderHelpMessages () { + render( + html``, + + this.help_container + ); + }, + + renderChatContent (msgs_by_ref=false) { + if (!this.tpl_chat_content) { + this.tpl_chat_content = (o) => { + return html` + + ` + }; + } + 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 = [ - `/clear: ${__('Remove messages')}`, - `/close: ${__('Close this chat')}`, - `/me: ${__('Write in the third person')}`, - `/help: ${__('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') { diff --git a/src/converse-headlines-view.js b/src/converse-headlines-view.js index 7e2a72e52..f5cd848ab 100644 --- a/src/converse-headlines-view.js +++ b/src/converse-headlines-view.js @@ -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 diff --git a/src/converse-message-view.js b/src/converse-message-view.js deleted file mode 100644 index c94930255..000000000 --- a/src/converse-message-view.js +++ /dev/null @@ -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 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 >). - * - * The URI lib correctly trims a trailing >, but not a trailing > - */ - 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 , 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(" "); - } - }); - } -}); diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index 16a0d42a4..24809762f 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -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 [ + `/admin: ${__("Change user's affiliation to admin")}`, + `/ban: ${__('Ban user by changing their affiliation to outcast')}`, + `/clear: ${__('Clear the chat area')}`, + `/close: ${__('Close this groupchat')}`, + `/deop: ${__('Change user role to participant')}`, + `/destroy: ${__('Remove this groupchat')}`, + `/help: ${__('Show this menu')}`, + `/kick: ${__('Kick user from groupchat')}`, + `/me: ${__('Write in 3rd person')}`, + `/member: ${__('Grant membership to a user')}`, + `/modtools: ${__('Opens up the moderator tools GUI')}`, + `/mute: ${__("Remove user's ability to post messages")}`, + `/nick: ${__('Change your nickname')}`, + `/op: ${__('Grant moderator role to user')}`, + `/owner: ${__('Grant ownership of this groupchat')}`, + `/register: ${__("Register your nickname")}`, + `/revoke: ${__("Revoke the user's current affiliation")}`, + `/subject: ${__('Set groupchat subject')}`, + `/topic: ${__('Set groupchat subject (alias for /subject)')}`, + `/voice: ${__('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([`${__("You can run the following commands")}`]); - this.showHelpMessages([ - `/admin: ${__("Change user's affiliation to admin")}`, - `/ban: ${__('Ban user by changing their affiliation to outcast')}`, - `/clear: ${__('Clear the chat area')}`, - `/close: ${__('Close this groupchat')}`, - `/deop: ${__('Change user role to participant')}`, - `/destroy: ${__('Remove this groupchat')}`, - `/help: ${__('Show this menu')}`, - `/kick: ${__('Kick user from groupchat')}`, - `/me: ${__('Write in 3rd person')}`, - `/member: ${__('Grant membership to a user')}`, - `/modtools: ${__('Opens up the moderator tools GUI')}`, - `/mute: ${__("Remove user's ability to post messages")}`, - `/nick: ${__('Change your nickname')}`, - `/op: ${__('Grant moderator role to user')}`, - `/owner: ${__('Grant ownership of this groupchat')}`, - `/register: ${__("Register your nickname")}`, - `/revoke: ${__("Revoke the user's current affiliation")}`, - `/subject: ${__('Set groupchat subject')}`, - `/topic: ${__('Set groupchat subject (alias for /subject)')}`, - `/voice: ${__('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(); diff --git a/src/converse-omemo.js b/src/converse-omemo.js index 60fa28511..a25f8cbf1 100644 --- a/src/converse-omemo.js +++ b/src/converse-omemo.js @@ -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); - } } }, diff --git a/src/converse.js b/src/converse.js index a1da342d5..fdb9c3774 100644 --- a/src/converse.js +++ b/src/converse.js @@ -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', diff --git a/src/headless/converse-chat.js b/src/headless/converse-chat.js index de7e89845..69e58ec50 100644 --- a/src/headless/converse-chat.js +++ b/src/headless/converse-chat.js @@ -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 `` 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 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 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); } diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index 6db251e65..220234caa 100644 --- a/src/headless/converse-muc.js +++ b/src/headless/converse-muc.js @@ -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} */ - 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); }, /** diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index e56ca1897..6a2f9ff0e 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -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; diff --git a/src/headless/utils/stanza.js b/src/headless/utils/stanza.js index ac6586cb2..91fc10736 100644 --- a/src/headless/utils/stanza.js +++ b/src/headless/utils/stanza.js @@ -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 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 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), diff --git a/src/modals/message-versions.js b/src/modals/message-versions.js new file mode 100644 index 000000000..a8fe02842 --- /dev/null +++ b/src/modals/message-versions.js @@ -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()); + } +}); diff --git a/src/templates/avatar.js b/src/templates/avatar.js index dd6a082d5..8910b3ddf 100644 --- a/src/templates/avatar.js +++ b/src/templates/avatar.js @@ -1,5 +1,6 @@ import { html } from "lit-html"; export default (o) => html` - ${o.alt_text}`; + + + `; diff --git a/src/templates/chatbox.js b/src/templates/chatbox.js index cac14afd5..1953aec10 100644 --- a/src/templates/chatbox.js +++ b/src/templates/chatbox.js @@ -4,9 +4,9 @@ export default (o) => html`
-
-
-
+
+
+
diff --git a/src/templates/chatroom.js b/src/templates/chatroom.js index c9d6f39fd..0cb55339b 100644 --- a/src/templates/chatroom.js +++ b/src/templates/chatroom.js @@ -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`
@@ -10,10 +6,8 @@ export default (o) => html`
-
- ${ o.muc_show_logs_before_join ? html`
${ i18n_no_history }
` : '' } -
-
+
+
diff --git a/src/templates/chatroom_head.js b/src/templates/chatroom_head.js index 83e59ddce..abcf4969d 100644 --- a/src/templates/chatroom_head.js +++ b/src/templates/chatroom_head.js @@ -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`
@@ -28,6 +26,6 @@ export default (o) => { ${ o.dropdown_btns.length ? html`` : '' }
- ${ show_subject ? html`

${unsafeHTML(subject)}

` : '' } + ${ show_subject ? html`

${subject}

` : '' } `; } diff --git a/src/templates/directives/avatar.js b/src/templates/directives/avatar.js new file mode 100644 index 000000000..cc67da8db --- /dev/null +++ b/src/templates/directives/avatar.js @@ -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))}`); + } +}); diff --git a/src/templates/directives/body.js b/src/templates/directives/body.js new file mode 100644 index 000000000..aeac19ef9 --- /dev/null +++ b/src/templates/directives/body.js @@ -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`${o.mention}`; +const tpl_mention = (o) => html`${o.mention}`; + + +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); +}); diff --git a/src/templates/directives/retraction.js b/src/templates/directives/retraction.js new file mode 100644 index 000000000..a5dc7e06d --- /dev/null +++ b/src/templates/directives/retraction.js @@ -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` + +`; + + +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(); +}); diff --git a/src/templates/file_progress.html b/src/templates/file_progress.html deleted file mode 100644 index f409e41aa..000000000 --- a/src/templates/file_progress.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
- {{{o.__('Uploading file:')}}} {{{o.filename}}}, {{{o.filesize}}} - -
-
diff --git a/src/templates/file_progress.js b/src/templates/file_progress.js new file mode 100644 index 000000000..210942dbe --- /dev/null +++ b/src/templates/file_progress.js @@ -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` +
+ ${ renderAvatar(this) } +
+ ${i18n_uploading} ${o.filename}, ${o.filesize} + +
+
+`; diff --git a/src/templates/help_message.html b/src/templates/help_message.html deleted file mode 100644 index 5424340d4..000000000 --- a/src/templates/help_message.html +++ /dev/null @@ -1 +0,0 @@ -
{{o.message}}
diff --git a/src/templates/info.html b/src/templates/info.html deleted file mode 100644 index 6c77a9981..000000000 --- a/src/templates/info.html +++ /dev/null @@ -1,13 +0,0 @@ -
-{[ if (o.render_message) { - // XXX: Should only ever be rendered if the message text has been sanitized already -]} - {{o.message}} -{[ } else { ]} -
{{{o.message}}}
- {[ if (o.reason) { ]}{{{o.reason}}}{[ } ]} -{[ } ]} -{[ if (o.retry) { ]} - Retry -{[ } ]} -
diff --git a/src/templates/message.html b/src/templates/message.html deleted file mode 100644 index 8de4b84e4..000000000 --- a/src/templates/message.html +++ /dev/null @@ -1,52 +0,0 @@ -
- {[ if (o.type !== 'headline' && !o.is_me_message) { ]} - - {[ } ]} -
- {[ if (o.first_unread) { ]} -

{{{o.__('unread messages')}}}
- {[ } ]} - {[ if (o.is_me_message) { ]} - - {[ } ]}{[ if (o.is_me_message) { ]}**{[ }; ]}{{{o.username}}} - {[ if (!o.is_me_message) { ]}{[o.hats.forEach(function (hat) { ]}{{{hat.title}}} - {[ }); ]}{[ } ]}{[ if (o.is_encrypted) { ]} - - {[ } ]} -
-
- {[ if (o.is_retracted) { ]} -
{{{o.retraction_text}}}
- {[ if (o.moderation_reason) { ]}{{{o.moderation_reason}}}{[ } ]} - {[ } else { ]} - {[ if (o.is_spoiler) { ]} -
- {{{o.spoiler_hint}}} - {{{o.label_show}}} -
- {[ } ]} - - {[ if (o.subject) { ]} -
{{{ o.subject }}}
- {[ } ]} -
-
-
{{{o.error}}}
- {[ } ]} -
- {[ if (o.received && !o.is_me_message && !o.is_groupchat_message) { ]} {[ } ]} - {[ if (o.edited) { ]} {[ } ]} -
- {[ if (o.editable) { ]} - - {[ } ]} - {[ if (o.retractable) { ]} - - {[ } ]} -
-
-
-
diff --git a/src/templates/new_day.js b/src/templates/new_day.js new file mode 100644 index 000000000..e5f7b4f93 --- /dev/null +++ b/src/templates/new_day.js @@ -0,0 +1,9 @@ +import { html } from "lit-html"; + + +export default (o) => html` +
+
+ +
+`; diff --git a/src/utils/html.js b/src/utils/html.js index dbf9042ae..23bf59db1 100644 --- a/src/utils/html.js +++ b/src/utils/html.js @@ -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, '

').replace(/\n/g, '
'); -}; - 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, """); }; - -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(/</g) || []).length + (prefix.match(/>/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) + `${mention}` + text.slice(end); - } else { - text = text.slice(0, begin) + `${mention}` + 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 `${visibleUrl}`; + return html` + api.rooms.open(ev.target.href)} + href="${normalized_url}">${visible_url}`; } - return `${visibleUrl}`; + return html`${visible_url}`; }; 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))); diff --git a/webpack.prod.js b/webpack.prod.js index 721fbd390..8c9a86cce 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -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'},