Skip to content

Commit e2289f4

Browse files
Feat: Working on XEP-0444
1 parent 798986b commit e2289f4

File tree

9 files changed

+1171
-4
lines changed

9 files changed

+1171
-4
lines changed

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import "./plugins/rosterview/index.js";
4242
import "./plugins/singleton/index.js";
4343
import "./plugins/dragresize/index.js"; // Allows chat boxes to be resized by dragging them
4444
import "./plugins/fullscreen/index.js";
45+
import "./plugins/reactions/index.js"; // XEP-0444 Reactions
4546
/* END: Removable components */
4647

4748
_converse.exports.CustomElement = CustomElement;

src/plugins/reactions/index.js

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* @module converse-reactions
3+
* @copyright The Converse.js contributors
4+
* @license Mozilla Public License (MPLv2)
5+
* @description
6+
* This plugin implements XEP-0444: Message Reactions
7+
* It allows users to react to messages with emojis (similar to Slack/Discord reactions)
8+
*
9+
* Features:
10+
* - Add emoji reactions to received messages
11+
* - Display reaction picker with popular emojis + full emoji selector
12+
* - Send reactions as XMPP stanzas per XEP-0444
13+
* - Receive and display reactions from other users
14+
* - Aggregate reactions by emoji with user counts
15+
* - Users can only have one reaction per message (changing reaction replaces previous)
16+
*/
17+
18+
import { converse, api, u } from '@converse/headless';
19+
import './reaction-picker.js';
20+
21+
import { __ } from 'i18n';
22+
23+
converse.plugins.add('reactions', {
24+
25+
/**
26+
* Initializes the reactions plugin
27+
* Sets up event listeners for:
28+
* - Adding reaction buttons to messages
29+
* - Receiving reaction stanzas from XMPP server
30+
* - Handling connection/reconnection events
31+
*/
32+
initialize () {
33+
/**
34+
* Add "Add Reaction" button to message action buttons
35+
* Only shown for received messages (not own messages)
36+
* @listens getMessageActionButtons
37+
*/
38+
api.listen.on('getMessageActionButtons', (el, buttons) => {
39+
const is_own_message = el.model.get('sender') === 'me';
40+
if (!is_own_message) {
41+
buttons.push({
42+
'i18n_text': __('Add Reaction'),
43+
'handler': (ev) => this.onReactionButtonClicked(el, ev),
44+
'button_class': 'chat-msg__action-reaction',
45+
'icon_class': 'fas fa-smile',
46+
'name': 'reaction',
47+
});
48+
}
49+
return buttons;
50+
});
51+
52+
/**
53+
* Register XMPP stanza handler for incoming reactions
54+
* Called on connect and reconnect events
55+
* Listens for <message> stanzas containing <reaction xmlns='urn:xmpp:reactions:0'/>
56+
*/
57+
const onConnect = () => {
58+
/**
59+
* Handler function for processing incoming message stanzas
60+
* @param {Element} stanza - The received XMPP message stanza
61+
* @returns {boolean} - Always returns true to keep handler active
62+
*/
63+
const handler = (stanza) => {
64+
// Check for reaction element per XEP-0444
65+
const reactions = stanza.getElementsByTagNameNS('urn:xmpp:reactions:0', 'reaction');
66+
67+
if (reactions.length > 0) {
68+
this.onReactionReceived(stanza, reactions[0]);
69+
}
70+
return true; // Keep handler alive for subsequent stanzas
71+
};
72+
73+
// Register for ALL message stanzas, then filter internally
74+
// This approach avoids missing reactions due to type variations
75+
const conn = api.connection.get();
76+
if (conn && conn.addHandler) {
77+
conn.addHandler(handler, null, 'message', null);
78+
}
79+
};
80+
81+
api.listen.on('connected', onConnect);
82+
api.listen.on('reconnected', onConnect);
83+
84+
// Also try to register immediately if already connected
85+
if (api.connection.connected()) {
86+
onConnect();
87+
}
88+
},
89+
90+
/**
91+
* Process a received reaction stanza
92+
* Updates the target message's reactions data structure
93+
*
94+
* @param {Element} stanza - The XMPP message stanza containing the reaction
95+
* @param {Element} reactionElement - The <reaction> element from the stanza
96+
*
97+
* Reaction format (XEP-0444):
98+
* <message from='user@domain' to='recipient@domain' type='chat'>
99+
* <reaction xmlns='urn:xmpp:reactions:0' id='target-message-id'>
100+
* <emoji>👍</emoji>
101+
* </reaction>
102+
* </message>
103+
*
104+
* Reactions are stored on messages as:
105+
* {
106+
* '👍': ['user1@domain', 'user2@domain'],
107+
* '❤️': ['user3@domain']
108+
* }
109+
*/
110+
async onReactionReceived (stanza, reactionElement) {
111+
const from_jid = stanza.getAttribute('from');
112+
const id = reactionElement.getAttribute('id'); // Target message ID
113+
114+
// Extract emoji from <emoji> child element
115+
const emojis = reactionElement.getElementsByTagNameNS('urn:xmpp:reactions:0', 'emoji');
116+
const emoji = emojis.length > 0 ? emojis[0].textContent : null;
117+
118+
if (!id || !emoji) return;
119+
120+
/**
121+
* Helper function to update a message with a new reaction
122+
* @param {Object} message - The message model to update
123+
*
124+
* Process:
125+
* 1. Clone reactions object to ensure Backbone detects changes
126+
* 2. Remove user's previous reactions (one reaction per message)
127+
* 3. Add the new reaction
128+
* 4. Save to message model (triggers view update)
129+
*/
130+
const updateMessage = (message) => {
131+
// IMPORTANT: Clone the reactions object to ensure Backbone detects the change
132+
const current_reactions = message.get('reactions') || {};
133+
const reactions = JSON.parse(JSON.stringify(current_reactions));
134+
135+
// Remove user's previous reactions (they can only have one reaction per message)
136+
for (const existingEmoji in reactions) {
137+
const index = reactions[existingEmoji].indexOf(from_jid);
138+
if (index !== -1) {
139+
reactions[existingEmoji].splice(index, 1);
140+
// Remove emoji key if no one else reacted with it
141+
if (reactions[existingEmoji].length === 0) {
142+
delete reactions[existingEmoji];
143+
}
144+
}
145+
}
146+
147+
// Add the new reaction
148+
if (!reactions[emoji]) {
149+
reactions[emoji] = [];
150+
}
151+
reactions[emoji].push(from_jid);
152+
153+
message.save({ 'reactions': reactions });
154+
};
155+
156+
// Strategy 1: Try to find chatbox by sender's bare JID
157+
const { Strophe } = converse.env;
158+
const bare_jid = Strophe.getBareJidFromJid(from_jid);
159+
let chatbox = api.chatboxes.get(bare_jid);
160+
161+
/**
162+
* Helper to find message by ID in a chatbox
163+
* @param {Object} box - The chatbox to search in
164+
* @param {string} msgId - The message ID to find
165+
* @returns {Object|null} - The message model or null
166+
*/
167+
const findMessage = (box, msgId) => {
168+
if (!box || !box.messages) {
169+
return null;
170+
}
171+
// Try direct lookup first
172+
let msg = box.messages.get(msgId);
173+
if (!msg) {
174+
// Fallback to findWhere for older messages
175+
msg = box.messages.findWhere({ 'msgid': msgId });
176+
}
177+
return msg;
178+
};
179+
180+
if (chatbox) {
181+
const message = findMessage(chatbox, id);
182+
if (message) {
183+
updateMessage(message);
184+
return;
185+
}
186+
}
187+
188+
// Strategy 2: Search all open chatboxes (for carbons/multi-device support)
189+
// This handles cases where reactions come from message carbons or other devices
190+
const allChatboxes = await api.chatboxes.get();
191+
for (const cb of allChatboxes) {
192+
const message = findMessage(cb, id);
193+
if (message) {
194+
updateMessage(message);
195+
return;
196+
}
197+
}
198+
},
199+
200+
/**
201+
* Handle click on "Add Reaction" button
202+
* Creates and displays the reaction picker UI
203+
*
204+
* @param {Element} el - The message element component
205+
* @param {Event} ev - The click event
206+
*/
207+
onReactionButtonClicked (el, ev) {
208+
ev?.preventDefault?.();
209+
ev?.stopPropagation?.();
210+
211+
const target = /** @type {HTMLElement} */(ev.target).closest('button');
212+
const existing_picker = document.querySelector('converse-reaction-picker');
213+
214+
// Toggle: if clicking same button, close picker instead of reopening
215+
if (existing_picker) {
216+
const isSameTarget = /** @type {any} */(existing_picker).target === target;
217+
existing_picker.remove();
218+
if (isSameTarget) {
219+
return;
220+
}
221+
}
222+
223+
// Create reaction picker component
224+
const picker = document.createElement('converse-reaction-picker');
225+
/** @type {any} */(picker).target = target;
226+
/** @type {any} */(picker).model = el.model;
227+
228+
// Position picker below the button
229+
const rect = target.getBoundingClientRect();
230+
picker.style.position = 'absolute';
231+
picker.style.zIndex = '10000'; // Ensure it's above other elements
232+
picker.style.left = `${rect.left}px`;
233+
picker.style.top = `${rect.bottom + 5}px`;
234+
235+
// Append to .conversejs container for proper CSS scoping
236+
// Fallback to converse-root or document.body if container not found
237+
const converseRoot = document.querySelector('.conversejs') || document.querySelector('converse-root');
238+
const container = converseRoot || document.body;
239+
container.appendChild(picker);
240+
241+
/**
242+
* Close picker when clicking outside
243+
* @param {Event} ev - The click event
244+
*/
245+
const onClickOutside = (ev) => {
246+
if (!picker.isConnected) {
247+
document.removeEventListener('click', onClickOutside);
248+
return;
249+
}
250+
const clickTarget = /** @type {Node} */(ev.target);
251+
if (!picker.contains(clickTarget) && !target.contains(clickTarget)) {
252+
picker.remove();
253+
document.removeEventListener('click', onClickOutside);
254+
}
255+
};
256+
// Use setTimeout to avoid immediate trigger if event bubbles
257+
setTimeout(() => document.addEventListener('click', onClickOutside), 0);
258+
259+
/**
260+
* Handle emoji selection from picker
261+
* @listens reactionSelected
262+
*/
263+
picker.addEventListener('reactionSelected', (/** @type {CustomEvent} */ e) => {
264+
const emoji = e.detail.emoji;
265+
this.sendReaction(/** @type {any} */(el).model, emoji);
266+
picker.remove();
267+
document.removeEventListener('click', onClickOutside);
268+
});
269+
},
270+
271+
/**
272+
* Send a reaction to a message
273+
* Implements XEP-0444: Message Reactions
274+
*
275+
* @param {Object} message - The message model being reacted to
276+
* @param {string} emoji - The emoji reaction (can be unicode or shortname like :joy:)
277+
*
278+
* Process:
279+
* 1. Convert emoji shortname to unicode if needed
280+
* 2. Build XEP-0444 compliant stanza
281+
* 3. Send via XMPP connection
282+
* 4. Optimistically update local state for immediate UI feedback
283+
*/
284+
sendReaction (message, emoji) {
285+
const { $msg } = converse.env;
286+
const chatbox = message.collection.chatbox;
287+
const msgId = message.get('msgid');
288+
const to_jid = chatbox.get('jid');
289+
// Default to 'chat' type unless explicitly a groupchat (MUC)
290+
const type = chatbox.get('type') === 'groupchat' ? 'groupchat' : 'chat';
291+
292+
if (!emoji) return;
293+
294+
// Convert emoji shortname (e.g. :joy:) to unicode (e.g. 😂)
295+
// Check if emoji is already unicode (from emoji picker) or needs conversion (from shortname buttons)
296+
let emojiUnicode = emoji;
297+
if (emoji.startsWith(':') && emoji.endsWith(':')) {
298+
const emojiArray = u.shortnamesToEmojis(emoji, { unicode_only: true });
299+
emojiUnicode = Array.isArray(emojiArray) ? emojiArray.join('') : emojiArray;
300+
}
301+
302+
// Build XEP-0444 reaction stanza
303+
const reaction = $msg({
304+
'to': to_jid,
305+
'type': type,
306+
'id': u.getUniqueId('reaction')
307+
}).c('reaction', {
308+
'xmlns': 'urn:xmpp:reactions:0',
309+
'id': msgId // ID of the message being reacted to
310+
}).c('emoji').t(emojiUnicode);
311+
312+
// Send stanza to XMPP server
313+
api.send(reaction);
314+
315+
// Optimistic local update for immediate UI feedback
316+
const my_jid = api.connection.get().jid;
317+
const currentReactions = message.get('reactions') || {};
318+
// Clone to ensure Backbone detects the change
319+
const reactions = JSON.parse(JSON.stringify(currentReactions));
320+
321+
// Remove user's previous reactions (one reaction per message per XEP-0444)
322+
for (const existingEmoji in reactions) {
323+
const index = reactions[existingEmoji].indexOf(my_jid);
324+
if (index !== -1) {
325+
reactions[existingEmoji].splice(index, 1);
326+
// Clean up emoji key if no users remain
327+
if (reactions[existingEmoji].length === 0) {
328+
delete reactions[existingEmoji];
329+
}
330+
}
331+
}
332+
333+
// Add the new reaction
334+
if (!reactions[emojiUnicode]) {
335+
reactions[emojiUnicode] = [];
336+
}
337+
reactions[emojiUnicode].push(my_jid);
338+
339+
// Save to model - triggers view re-render
340+
message.save({ 'reactions': reactions });
341+
}
342+
});

0 commit comments

Comments
 (0)