Add the ability to retract XEP-0316 MEP messages

This commit is contained in:
JC Brand 2021-10-20 16:06:11 +02:00
parent 3cfdf4c946
commit d2a33bc210
16 changed files with 254 additions and 109 deletions

View File

@ -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()
);
},

View File

@ -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);
}

View File

@ -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,

View File

@ -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`
<div class="message chat-info message--mep ${ el.getExtraMessageClasses() }"
data-isodate="${isodate}"
data-type="${el.data_name}"
data-value="${el.data_value}">
<div class="chat-msg__content">
<div class="chat-msg__body chat-msg__body--${el.model.get('type')} ${el.model.get('is_delayed') ? 'chat-msg__body--delayed' : '' }">
<div class="chat-info__message">
${ el.isRetracted() ? el.renderRetraction() : html`
<converse-rich-text
.mentions=${el.model.get('references')}
render_styling
text=${el.model.getMessageText()}>
</converse-rich-text>
${ el.model.get('reason') ? html`<q class="reason">${el.model.get('reason')}</q>` : `` }
`}
</div>
<converse-message-actions
?is_retracted=${el.isRetracted()}
.model=${el.model}></converse-message-actions>
</div>
</div>
</div>`;
}

View File

@ -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(`
<message from='${muc_jid}'
to='${_converse.jid}'
type='headline'
id='zns61f38'>
<event xmlns='http://jabber.org/protocol/pubsub#event'>
<items node='urn:ietf:params:xml:ns:conference-info'>
<item id='ehs51f40'>
<conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
<activity xmlns='http://jabber.org/protocol/activity'>
<other/>
<text id="activity-text" xml:lang="en">${msg}</text>
<reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
<reason id="activity-reason">${reason}</reason>
</activity>
</conference-info>
</item>
</items>
</event>
<stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
</message>`
)));
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(
`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
`<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+
`<moderate xmlns="urn:xmpp:message-moderate:0">`+
`<retract xmlns="urn:xmpp:message-retract:0"/>`+
`<reason></reason>`+
`</moderate>`+
`</apply-to>`+
`</iq>`);
// The server responds with a retraction message
const retraction = u.toStanza(`
<message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/${nick}">
<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
<moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
<retract xmlns='urn:xmpp:message-retract:0' />
<reason></reason>
</moderated>
</apply-to>
</message>`);
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`);
}));
});

View File

@ -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;

View File

@ -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"/>
<origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
</message>`);
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);

View File

@ -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({

View File

@ -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`
<div>${retraction_text}</div>
${ this.model.get('moderation_reason') ?
html`<q class="chat-msg--retracted__reason">${this.model.get('moderation_reason')}</q>` : '' }
`;
}
renderMessageText () {
return tpl_message_text(this);
}
showUserModal (ev) {
if (this.model.get('sender') === 'me') {
api.modal.show(_converse.ProfileModal, {model: this.model}, ev);

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,10 @@
converse-chat-message {
.message {
&.chat-msg--retracted {
.chat-msg__message {
color: var(--subdued-color);
}
}
}
}

View File

@ -37,11 +37,7 @@ export default (el, o) => {
</div>
<converse-message-actions
.model=${el.model}
?correcting=${o.correcting}
?editable=${o.editable}
?is_retracted=${o.is_retracted}
unfurls="${el.model.get('ogp_metadata')?.length}"
message_type="${o.message_type}"></converse-message-actions>
?is_retracted=${o.is_retracted}></converse-message-actions>
</div>
${ el.model.get('ogp_metadata')?.map(m => {

View File

@ -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`
<div class="retraction">${retraction_text}</div>
${ el.model.get('moderation_reason') ?
html`<q class="chat-msg--retracted__reason">${el.model.get('moderation_reason')}</q>` : '' }`;
}

View File

@ -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 <converse-rich-text text="*_hello_ world!*"></converse-rich-text>

View File

@ -0,0 +1,3 @@
converse-rich-text {
display: block;
}

View File

@ -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 {