diff --git a/src/headless/plugins/muc/message.js b/src/headless/plugins/muc/message.js index fcfb08660..e203351b0 100644 --- a/src/headless/plugins/muc/message.js +++ b/src/headless/plugins/muc/message.js @@ -37,8 +37,14 @@ const ChatRoomMessageMixin = { * @returns { Boolean } */ mayBeModerated () { + if (typeof this.get('from_muc') === 'undefined') { + // If from_muc is not defined, then this message hasn't been + // reflected yet, which means we won't have a XEP-0359 stanza id. + return; + } return ( ['all', 'moderator'].includes(api.settings.get('allow_message_retraction')) && + this.get(`stanza_id ${this.get('from_muc')}`) && this.collection.chatbox.canModerateMessages() ); }, diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 2bdba8662..401d285fd 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -1825,6 +1825,8 @@ const ChatRoomMixin = { getUpdatedMessageAttributes (message, attrs) { const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs); + new_attrs['from_muc'] = attrs['from_muc']; + if (this.isOwnMessage(attrs)) { const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id')); Object.assign(new_attrs, pick(attrs, stanza_id_keys)); @@ -2114,12 +2116,7 @@ const ChatRoomMixin = { return false; } attrs.activities?.forEach(activity_attrs => { - const data = Object.assign({ - 'from_muc': attrs.from, - 'msgid': attrs.msgid, - 'received': attrs.received, - 'time': attrs.time, - }, activity_attrs); + const data = Object.assign(attrs,activity_attrs); this.createMessage(data) // Trigger so that notifications are shown api.trigger('message', { 'attrs': data, 'chatbox': this }); @@ -2137,7 +2134,7 @@ const ChatRoomMixin = { */ getDuplicateMessage (attrs) { if (attrs.activities?.length) { - return this.messages.findWhere({'type': 'info', 'msgid': attrs.msgid}); + return this.messages.findWhere({'type': 'mep', 'msgid': attrs.msgid}); } else { return _converse.ChatBox.prototype.getDuplicateMessage.call(this, attrs); } diff --git a/src/headless/plugins/muc/parsers.js b/src/headless/plugins/muc/parsers.js index eb1a95ccb..f94cfb069 100644 --- a/src/headless/plugins/muc/parsers.js +++ b/src/headless/plugins/muc/parsers.js @@ -45,7 +45,7 @@ export function getMEPActivities (stanza) { if (message) { const references = getReferences(stanza); const reason = el.querySelector('reason')?.textContent; - return { from, msgid, message, reason, references, 'type': 'info' }; + return { from, msgid, message, reason, references, 'type': 'mep' }; } return {}; }); @@ -175,6 +175,7 @@ export async function parseMUCMessage (stanza, chatbox, _converse) { * @property { String } to - The recipient JID * @property { String } type - The type of message */ + let attrs = Object.assign( { from, diff --git a/src/plugins/muc-views/templates/mep-message.js b/src/plugins/muc-views/templates/mep-message.js new file mode 100644 index 000000000..2d233e7fa --- /dev/null +++ b/src/plugins/muc-views/templates/mep-message.js @@ -0,0 +1,32 @@ +import { converse } from '@converse/headless/core'; +import { html } from 'lit'; + +const { dayjs } = converse.env; + +export default (el) => { + const isodate = dayjs(el.model.get('time')).toISOString(); + return html` +
+ +
+
+
+ ${ el.isRetracted() ? el.renderRetraction() : html` + + + ${ el.model.get('reason') ? html`${el.model.get('reason')}` : `` } + `} +
+ +
+
+
`; +} diff --git a/src/plugins/muc-views/tests/mep.js b/src/plugins/muc-views/tests/mep.js index 43c4ee362..2e8fb6766 100644 --- a/src/plugins/muc-views/tests/mep.js +++ b/src/plugins/muc-views/tests/mep.js @@ -1,6 +1,6 @@ /*global mock, converse */ -const { u } = converse.env; +const { u, Strophe } = converse.env; describe("A XEP-0316 MEP notification", function () { @@ -36,7 +36,7 @@ describe("A XEP-0316 MEP notification", function () { _converse.connection._dataRecv(mock.createRequest(message)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); - expect(view.querySelector('.chat-info__message').textContent.trim()).toBe(msg); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); expect(view.querySelector('.reason').textContent.trim()).toBe(reason); // Check that duplicates aren't created @@ -74,7 +74,7 @@ describe("A XEP-0316 MEP notification", function () { _converse.connection._dataRecv(mock.createRequest(message)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); - expect(view.querySelector('converse-chat-message:last-child .chat-info__message').textContent.trim()).toBe(msg); + expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-rich-text').textContent.trim()).toBe(msg); expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason); // Check that duplicates aren't created @@ -126,7 +126,86 @@ describe("A XEP-0316 MEP notification", function () { const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); - expect(view.querySelector('.chat-info__message').textContent.trim()).toBe(msg); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); expect(view.querySelector('.reason').textContent.trim()).toBe(reason); })); + + it("can be retracted by a moderator", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + const view = _converse.chatboxviews.get(muc_jid); + const msg = 'An anonymous user has saluted romeo'; + const reason = 'Thank you for helping me yesterday'; + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + + + + + + + + ${msg} + + ${reason} + + + + + + + ` + ))); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBe(1); + const action = view.querySelector('converse-message-actions converse-dropdown .chat-msg__action'); + expect(action.textContent.trim()).toBe('Retract'); + action.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``+ + ``); + + // The server responds with a retraction message + const retraction = u.toStanza(` + + + + + + + + `); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(''); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message div'); + expect(msg_el.textContent).toBe(`${nick} has removed this message`); + })); }); diff --git a/src/plugins/muc-views/tests/muc-messages.js b/src/plugins/muc-views/tests/muc-messages.js index ee20dc63f..f589102e1 100644 --- a/src/plugins/muc-views/tests/muc-messages.js +++ b/src/plugins/muc-views/tests/muc-messages.js @@ -1,6 +1,6 @@ /*global mock, converse */ -const { Promise, Strophe, $msg, $pres, sizzle } = converse.env; +const { Promise, $msg, $pres, sizzle } = converse.env; const u = converse.env.utils; const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; diff --git a/src/plugins/muc-views/tests/retractions.js b/src/plugins/muc-views/tests/retractions.js index c0976da31..b7ea218d5 100644 --- a/src/plugins/muc-views/tests/retractions.js +++ b/src/plugins/muc-views/tests/retractions.js @@ -753,6 +753,12 @@ describe("Message Retractions", function () { view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + // Check that you can only edit a message before it's been + // reflected. You can't retract because it hasn't + await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-edit')); + expect(view.querySelectorAll('.chat-msg__action').length).toBe(1); + const stanza_id = 'retraction-id-1'; const msg_obj = view.model.messages.at(0); const reflection_stanza = u.toStanza(` @@ -766,6 +772,7 @@ describe("Message Retractions", function () { by="lounge@montague.lit"/> `); + await view.model.handleMessageStanza(reflection_stanza); await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); expect(view.model.messages.length).toBe(1); diff --git a/src/shared/chat/message-actions.js b/src/shared/chat/message-actions.js index 7d9636df8..a838d424d 100644 --- a/src/shared/chat/message-actions.js +++ b/src/shared/chat/message-actions.js @@ -8,17 +8,15 @@ import { html } from 'lit'; import { isMediaURLDomainAllowed, isDomainWhitelisted } from '@converse/headless/utils/url.js'; import { until } from 'lit/directives/until.js'; +import './styles/message-actions.scss'; + const { Strophe, u } = converse.env; class MessageActions extends CustomElement { static get properties () { return { - correcting: { type: Boolean }, - editable: { type: Boolean }, is_retracted: { type: Boolean }, - message_type: { type: String }, - model: { type: Object }, - unfurls: { type: Number }, + model: { type: Object } }; } @@ -28,7 +26,7 @@ class MessageActions extends CustomElement { this.listenTo(settings, 'change:allowed_image_domains', () => this.requestUpdate()); this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate()); this.listenTo(settings, 'change:render_media', () => this.requestUpdate()); - this.listenTo(this.model, 'change:hide_url_previews', () => this.requestUpdate()); + this.listenTo(this.model, 'change', () => this.requestUpdate()); } render () { @@ -262,7 +260,7 @@ class MessageActions extends CustomElement { async getActionButtons () { const buttons = []; - if (this.editable) { + if (this.model.get('editable')) { /** * @typedef { Object } MessageActionAttributes * An object which represents a message action (as shown in the message dropdown); @@ -273,14 +271,16 @@ class MessageActions extends CustomElement { * @property { String } name */ buttons.push({ - 'i18n_text': this.correcting ? __('Cancel Editing') : __('Edit'), + 'i18n_text': this.model.get('correcting') ? __('Cancel Editing') : __('Edit'), 'handler': ev => this.onMessageEditButtonClicked(ev), 'button_class': 'chat-msg__action-edit', 'icon_class': 'fa fa-pencil-alt', 'name': 'edit', }); } - const may_be_moderated = this.model.get('type') === 'groupchat' && (await this.model.mayBeModerated()); + + const may_be_moderated = ['groupchat', 'mep'].includes(this.model.get('type')) && + (await this.model.mayBeModerated()); const retractable = !this.is_retracted && (this.model.mayBeRetracted() || may_be_moderated); if (retractable) { buttons.push({ diff --git a/src/shared/chat/message.js b/src/shared/chat/message.js index 2f7a6df77..ee39ed89c 100644 --- a/src/shared/chat/message.js +++ b/src/shared/chat/message.js @@ -8,16 +8,18 @@ import UserDetailsModal from 'modals/user-details.js'; import filesize from 'filesize'; import log from '@converse/headless/log'; import tpl_info_message from './templates/info-message.js'; +import tpl_mep_message from 'plugins/muc-views/templates/mep-message.js'; import tpl_message from './templates/message.js'; import tpl_message_text from './templates/message-text.js'; +import tpl_retraction from './templates/retraction.js'; import tpl_spinner from 'templates/spinner.js'; import { CustomElement } from 'shared/components/element.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; +import { getAppSettings } from '@converse/headless/shared/settings/utils.js'; import { getHats } from './utils.js'; import { html } from 'lit'; import { renderAvatar } from 'shared/directives/avatar'; -import { getAppSettings } from '@converse/headless/shared/settings/utils.js'; const { Strophe, dayjs } = converse.env; @@ -76,6 +78,8 @@ export default class Message extends CustomElement { return tpl_spinner(); } else if (this.model.get('file') && this.model.get('upload') !== _converse.SUCCESS) { return this.renderFileProgress(); + } else if (['mep'].includes(this.model.get('type'))) { + return this.renderMEPMessage(); } else if (['error', 'info'].includes(this.model.get('type'))) { return this.renderInfoMessage(); } else { @@ -90,6 +94,18 @@ export default class Message extends CustomElement { ); } + renderRetraction () { + return tpl_retraction(this); + } + + renderMessageText () { + return tpl_message_text(this); + } + + renderMEPMessage () { + return tpl_mep_message(this); + } + renderInfoMessage () { return tpl_info_message(this); } @@ -117,7 +133,9 @@ export default class Message extends CustomElement { } shouldShowAvatar () { - return api.settings.get('show_message_avatar') && !this.model.isMeCommand() && this.type !== 'headline'; + return api.settings.get('show_message_avatar') && + !this.model.isMeCommand() && + ['chat', 'groupchat'].includes(this.model.get('type')); } getAvatarData () { @@ -202,7 +220,7 @@ export default class Message extends CustomElement { } getRetractionText () { - if (this.model.get('type') === 'groupchat' && this.model.get('moderated_by')) { + if (['groupchat', 'mep'].includes(this.model.get('type')) && this.model.get('moderated_by')) { const retracted_by_mod = this.model.get('moderated_by'); const chatbox = this.model.collection.chatbox; if (!this.model.mod) { @@ -217,19 +235,6 @@ export default class Message extends CustomElement { } } - renderRetraction () { - const retraction_text = this.isRetracted() ? this.getRetractionText() : null; - return html` -
${retraction_text}
- ${ this.model.get('moderation_reason') ? - html`${this.model.get('moderation_reason')}` : '' } - `; - } - - renderMessageText () { - return tpl_message_text(this); - } - showUserModal (ev) { if (this.model.get('sender') === 'me') { api.modal.show(_converse.ProfileModal, {model: this.model}, ev); diff --git a/src/shared/chat/styles/message-actions.scss b/src/shared/chat/styles/message-actions.scss new file mode 100644 index 000000000..8fb77d9c5 --- /dev/null +++ b/src/shared/chat/styles/message-actions.scss @@ -0,0 +1,38 @@ +converse-message-actions { + margin-left: 0.5em; + + .chat-msg__actions { + .dropdown-menu { + min-width: 5rem; + } + i { + color: var(--text-color-lighten-15-percent); + font-size: 70%; + } + button { + border: none; + background: transparent; + color: var(--text-color-lighten-15-percent); + padding: 0 0.25em; + } + .btn--standalone { + opacity: 0; + margin-top: -0.2em; + } + .chat-msg__action { + width: 100%; + padding: 0.5em 1em; + text-align: left; + white-space: nowrap; + + converse-icon { + margin-right: 0.25em; + } + + &:hover { + color: var(--text-color); + background-color: var(--list-item-hover-color); + } + } + } +} diff --git a/src/shared/chat/styles/retraction.scss b/src/shared/chat/styles/retraction.scss new file mode 100644 index 000000000..e85d6766e --- /dev/null +++ b/src/shared/chat/styles/retraction.scss @@ -0,0 +1,10 @@ +converse-chat-message { + .message { + &.chat-msg--retracted { + .chat-msg__message { + color: var(--subdued-color); + } + } + + } +} diff --git a/src/shared/chat/templates/message.js b/src/shared/chat/templates/message.js index eb2d36f91..940797ee8 100644 --- a/src/shared/chat/templates/message.js +++ b/src/shared/chat/templates/message.js @@ -37,11 +37,7 @@ export default (el, o) => { + ?is_retracted=${o.is_retracted}> ${ el.model.get('ogp_metadata')?.map(m => { diff --git a/src/shared/chat/templates/retraction.js b/src/shared/chat/templates/retraction.js new file mode 100644 index 000000000..23b571cde --- /dev/null +++ b/src/shared/chat/templates/retraction.js @@ -0,0 +1,11 @@ +import { html } from 'lit'; + +import '../styles/retraction.scss'; + +export default (el) => { + const retraction_text = el.isRetracted() ? el.getRetractionText() : null; + return html` +
${retraction_text}
+ ${ el.model.get('moderation_reason') ? + html`${el.model.get('moderation_reason')}` : '' }`; +} diff --git a/src/shared/components/rich-text.js b/src/shared/components/rich-text.js index ab83bd858..a8b963763 100644 --- a/src/shared/components/rich-text.js +++ b/src/shared/components/rich-text.js @@ -2,6 +2,8 @@ import renderRichText from 'shared/directives/rich-text.js'; import { CustomElement } from 'shared/components/element.js'; import { api } from "@converse/headless/core"; +import './styles/rich-text.scss'; + /** * The RichText custom element allows you to parse transform text into rich DOM elements. * @example diff --git a/src/shared/components/styles/rich-text.scss b/src/shared/components/styles/rich-text.scss new file mode 100644 index 000000000..14e3d8af8 --- /dev/null +++ b/src/shared/components/styles/rich-text.scss @@ -0,0 +1,3 @@ +converse-rich-text { + display: block; +} diff --git a/src/shared/styles/messages.scss b/src/shared/styles/messages.scss index 88a80e3b0..f8d0229bc 100644 --- a/src/shared/styles/messages.scss +++ b/src/shared/styles/messages.scss @@ -62,12 +62,6 @@ } } - &.chat-msg--retracted { - .chat-msg__message { - color: var(--subdued-color); - } - } - &.chat-info { color: var(--chat-head-color); font-size: var(--message-font-size); @@ -149,20 +143,6 @@ } } - .chat-msg__content { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: stretch; - margin-left: 0.5rem; - width: calc(100% - var(--message-avatar-width)); - &:hover { - .btn--standalone { - opacity: 1; - } - } - } - .chat-msg__content--me { .chat-msg__body--groupchat { .chat-msg__text { @@ -180,12 +160,6 @@ margin-left: 0; } - .chat-msg__body { - display: flex; - flex-direction: row; - justify-content: space-between; - } - converse-chat-message-body { display: inline; } @@ -257,46 +231,6 @@ } } - converse-message-actions { - margin-left: 0.5em; - } - - .chat-msg__actions { - .dropdown-menu { - min-width: 5rem; - } - i { - color: var(--text-color-lighten-15-percent); - font-size: 70%; - } - button { - border: none; - background: transparent; - color: var(--text-color-lighten-15-percent); - padding: 0 0.25em; - } - .btn--standalone { - opacity: 0; - margin-top: -0.2em; - } - .chat-msg__action { - width: 100%; - padding: 0.5em 1em; - text-align: left; - white-space: nowrap; - - converse-icon { - margin-right: 0.25em; - } - - - &:hover { - color: var(--text-color); - background-color: var(--list-item-hover-color); - } - } - } - .chat-msg__avatar { margin-top: 0.5em; vertical-align: middle; @@ -356,6 +290,10 @@ } } + .chat-msg__content { + width: calc(100% - var(--message-avatar-width)); + } + &.chat-msg--followup { .chat-msg__heading, .chat-msg__avatar { @@ -367,12 +305,32 @@ } } + .chat-msg__receipt { margin-left: 0.5em; margin-right: 0.5em; color: var(--message-receipt-color); } } + + .chat-msg__content { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + margin-left: 0.5rem; + &:hover { + .btn--standalone { + opacity: 1; + } + } + } + + .chat-msg__body { + display: flex; + flex-direction: row; + justify-content: space-between; + } } .chatroom-body .message {