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