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`
+
`;
+}
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 {