Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support the latest retractions spec #3582

Merged
merged 11 commits into from
Feb 7, 2025
21 changes: 18 additions & 3 deletions .aiderignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
# Add files and directories to ignore by Aider
node_modules/
dist/
build/
*.log
*.tmp
3rdparty/libsignal-protocol.min.js
LICENSE
build/
certs/
demo/
develop-eggs/
dist/
docs/doctrees
docs/html/
images/
logo/
media/
node_modules/
share/
sounds/
src/headless/types
src/types/
tags
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
- Fix: renaming getEmojisByAtrribute to getEmojisByAttribute.

### Changes and features
- Upgrade to the latest versions of XEP-0424 and XEP-0425 (Message Retraction and Message Moderation).
Converse loses the ability to retract or moderate messages in the older format,
so you might need to upgrade your XMPP server's implementation of these as well.
- Embed the Spotify player for links to Spotify tracks. New config option [embed_3rd_party_media_players](https://conversejs.org/docs/html/configuration.html#embed-3rd-party-media-players).
- Add support for XEP-0191 Blocking Command
- Upgrade to Bootstrap 5
Expand Down
5 changes: 4 additions & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/chatview/tests/actions.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/deprecated-retractions.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
Expand All @@ -65,6 +66,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/chatview/tests/messages.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/oob.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/receipts.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/retractions.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/spoilers.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/styling.js", type: 'module' },
{ pattern: "src/plugins/chatview/tests/unreads.js", type: 'module' },
Expand All @@ -81,6 +83,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/csn.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/deprecated-retractions.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
Expand All @@ -99,10 +102,10 @@ module.exports = function(config) {
{ pattern: "src/plugins/muc-views/tests/muc-list-modal.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc-mentions.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc-messages.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/mute.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/muc-private-messages.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },
Expand Down
8 changes: 8 additions & 0 deletions src/headless/plugins/chat/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
return text.startsWith('/me ');
}

/**
* @returns {boolean}
*/
isRetracted () {
return this.get('retracted') || this.get('moderated') === 'retracted';
}

/**
* Returns a boolean indicating whether this message is considered a followup
* message from the previous one. Followup messages are shown grouped together
Expand All @@ -161,6 +168,7 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
}
const date = dayjs(this.get('time'));
return this.get('from') === prev_model.get('from') &&
!this.isRetracted() && !prev_model.isRetracted() &&
!this.isMeCommand() && !prev_model.isMeCommand() &&
!!this.get('is_encrypted') === !!prev_model.get('is_encrypted') &&
this.get('type') === prev_model.get('type') && this.get('type') !== 'info' &&
Expand Down
1 change: 0 additions & 1 deletion src/headless/plugins/chat/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ export async function parseMessage (stanza) {
'is_marker': !!marker,
'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
'marker_id': marker && marker.getAttribute('id'),
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'nick': contact?.attributes?.nickname,
'receipt_id': getReceiptId(stanza),
'received': new Date().toISOString(),
Expand Down
1 change: 0 additions & 1 deletion src/headless/plugins/disco/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,6 @@ export default {
return api.disco.features.has(feature, jid);
} catch (e) {
log.error(e);
debugger;
return false;
}
},
Expand Down
8 changes: 3 additions & 5 deletions src/headless/plugins/muc/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,18 @@ class MUCMessage extends Message {
/**
* Determines whether this messsage may be moderated,
* based on configuration settings and server support.
* @async
* @method _converse.ChatRoomMessages#mayBeModerated
* @returns {boolean}
* @returns {Promise<boolean>}
*/
mayBeModerated () {
async 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.chatbox.canModerateMessages()
this.get(`stanza_id ${this.get('from_muc')}`) && await this.chatbox.canModerateMessages()
);
}

Expand Down
77 changes: 39 additions & 38 deletions src/headless/plugins/muc/muc.js
Original file line number Diff line number Diff line change
Expand Up @@ -808,30 +808,27 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
*/
async retractOwnMessage(message) {
const __ = _converse.__;
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': 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 });
const retraction_id = getUniqueId();
const id = message.get('id');

const stanza = stx`
<message id="${retraction_id}"
to="${this.get('jid')}"
type="groupchat"
xmlns="jabber:client">
<retract id="${id}" xmlns="${Strophe.NS.RETRACT}"/>
<body>/me retracted a message</body>
<store xmlns="${Strophe.NS.HINTS}"/>
<fallback xmlns="${Strophe.NS.FALLBACK}" for="${Strophe.NS.RETRACT}" />
</message>`;

// Optimistic save
message.set({
'retracted': new Date().toISOString(),
'retracted_id': origin_id,
'retraction_id': stanza.tree().getAttribute('id'),
'editable': false
retracted: new Date().toISOString(),
retracted_id: id,
retraction_id: retraction_id,
editable: false
});
const result = await this.sendTimedMessage(stanza);

Expand All @@ -841,11 +838,11 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
log.error(result);
message.save({
editable,
'error_type': 'timeout',
'error': __('A timeout happened while while trying to retract your message.'),
'retracted': undefined,
'retracted_id': undefined,
'retraction_id': undefined
error_type: 'timeout',
error: __('A timeout happened while trying to retract your message.'),
retracted: undefined,
retracted_id: undefined,
retraction_id: undefined
});
}
}
Expand Down Expand Up @@ -890,16 +887,13 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
* @param {string} [reason] - The reason for retracting the message.
*/
sendRetractionIQ (message, reason) {
const iq = $iq({ 'to': this.get('jid'), 'type': 'set' })
.c('apply-to', {
'id': message.get(`stanza_id ${this.get('jid')}`),
'xmlns': Strophe.NS.FASTEN,
})
.c('moderate', { xmlns: Strophe.NS.MODERATE })
.c('retract', { xmlns: Strophe.NS.RETRACT })
.up()
.c('reason')
.t(reason || '');
const iq = stx`
<iq to="${this.get('jid')}" type="set" xmlns="jabber:client">
<moderate id="${message.get(`stanza_id ${this.get('jid')}`)}" xmlns="${Strophe.NS.MODERATE}">
<retract xmlns="${Strophe.NS.RETRACT}"/>
${reason ? stx`<reason>${reason}</reason>` : ''}
</moderate>
</iq>`;
return api.sendIQ(iq, null, false);
}

Expand Down Expand Up @@ -2084,7 +2078,14 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
* whether a message was moderated or not.
*/
async handleModeration (attrs) {
const MODERATION_ATTRIBUTES = ['editable', 'moderated', 'moderated_by', 'moderated_id', 'moderation_reason'];
const MODERATION_ATTRIBUTES = [
'editable',
'moderated',
'moderated_by',
'moderated_by_id',
'moderated_id',
'moderation_reason'
];
if (attrs.moderated === 'retracted') {
const query = {};
const key = `stanza_id ${this.get('jid')}`;
Expand All @@ -2102,7 +2103,7 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
const message = this.findDanglingModeration(attrs);
if (message) {
const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES);
const new_attrs = Object.assign({ 'dangling_moderation': false }, attrs, moderation_attrs);
const new_attrs = Object.assign({ dangling_moderation: false }, attrs, moderation_attrs);
delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
message.save(new_attrs);
return true;
Expand Down Expand Up @@ -2335,8 +2336,8 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
this.handleMUCPrivateMessage(attrs) ||
this.handleMetadataFastening(attrs) ||
this.handleMEPNotification(attrs) ||
(await this.handleRetraction(attrs)) ||
(await this.handleModeration(attrs)) ||
(await this.handleRetraction(attrs)) ||
(await this.handleSubjectChange(attrs))
) {
attrs.nick && this.removeNotification(attrs.nick, ['composing', 'paused']);
Expand Down
2 changes: 1 addition & 1 deletion src/headless/plugins/muc/occupants.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class MUCOccupants extends Collection {
* Lookup by occupant_id is done first, then jid, and then nick.
*
* @method _converse.MUCOccupants#findOccupant
* @param { OccupantData } data
* @param {OccupantData} data
*/
findOccupant (data) {
if (data.occupant_id) {
Expand Down
66 changes: 50 additions & 16 deletions src/headless/plugins/muc/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,41 +81,76 @@ function getJIDFromMUCUserData(stanza) {
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns {Object}
*/
function getModerationAttributes(stanza) {
function getDeprecatedModerationAttributes(stanza) {
const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
if (fastening) {
const applies_to_id = fastening.getAttribute('id');
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE0}"]`, fastening).pop();
if (moderated) {
const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT0}"]`, moderated).pop();
if (retracted) {
return {
'editable': false,
'moderated': 'retracted',
'moderated_by': moderated.getAttribute('by'),
'moderated_id': applies_to_id,
'moderation_reason': moderated.querySelector('reason')?.textContent,
editable: false,
moderated: 'retracted',
moderated_by: moderated.getAttribute('by'),
moderated_id: applies_to_id,
moderation_reason: moderated.querySelector('reason')?.textContent,
};
}
}
} else {
const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE0}"]`, stanza).pop();
if (tombstone) {
const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT0}"]`, tombstone).pop();
if (retracted) {
return {
'editable': false,
'is_tombstone': true,
'moderated_by': tombstone.getAttribute('by'),
'retracted': tombstone.getAttribute('stamp'),
'moderation_reason': tombstone.querySelector('reason')?.textContent,
editable: false,
is_tombstone: true,
moderated_by: tombstone.getAttribute('by'),
retracted: tombstone.getAttribute('stamp'),
moderation_reason: tombstone.querySelector('reason')?.textContent,
};
}
}
}
return {};
}

/**
* @param {Element} stanza - The message stanza
* message stanza, if it was contained, otherwise it's the message stanza itself.
* @returns {Object}
*/
function getModerationAttributes(stanza) {
const retract = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
if (retract) {
const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, retract).pop();
if (moderated) {
return {
editable: false,
moderated: 'retracted',
moderated_by: moderated.getAttribute('by'),
moderated_by_id: moderated.querySelector('occupant-id')?.getAttribute('id'),
moderated_id: retract.getAttribute('id'),
moderation_reason: retract.querySelector('reason')?.textContent,
};
}
} else {
const tombstone = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
if (tombstone) {
return {
editable: false,
is_tombstone: true,
moderated_by: tombstone.getAttribute('by'),
moderated_by_id: tombstone.querySelector('occupant-id')?.getAttribute('id'),
retracted: tombstone.getAttribute('stamp'),
moderation_reason: tombstone.querySelector('reason')?.textContent,
};
}
}
return getDeprecatedModerationAttributes(stanza);
}

/**
* @param {Element} stanza
* @param {'presence'|'message'} type
Expand Down Expand Up @@ -234,7 +269,6 @@ export async function parseMUCMessage(original_stanza, chatbox) {
'is_marker': !!marker,
'is_unstyled': !!sizzle(`message > unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
'marker_id': marker && marker.getAttribute('id'),
'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
'nick': Strophe.unescapeNode(Strophe.getResourceFromJid(from)),
'occupant_id': getOccupantID(stanza, chatbox),
'receipt_id': getReceiptId(stanza),
Expand Down
Loading
Loading