From cac7f224a98b553d65cf7999164759e6b97276d3 Mon Sep 17 00:00:00 2001 From: marcellintacite Date: Sun, 23 Nov 2025 09:43:06 +0300 Subject: [PATCH 1/5] Feat: Working on XEP-0444 --- src/index.js | 1 + src/plugins/reactions/index.js | 345 +++++++++++++++++ src/plugins/reactions/reaction-picker.js | 190 +++++++++ src/plugins/reactions/reaction-picker.scss | 179 +++++++++ src/plugins/reactions/tests/reactions.js | 431 +++++++++++++++++++++ src/shared/chat/emoji-picker.js | 4 +- src/shared/chat/styles/emoji.scss | 3 +- src/shared/chat/templates/message.js | 22 ++ src/shared/constants.js | 3 +- 9 files changed, 1174 insertions(+), 4 deletions(-) create mode 100644 src/plugins/reactions/index.js create mode 100644 src/plugins/reactions/reaction-picker.js create mode 100644 src/plugins/reactions/reaction-picker.scss create mode 100644 src/plugins/reactions/tests/reactions.js diff --git a/src/index.js b/src/index.js index 94971989d0..b6994eaf8b 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ import "./plugins/rosterview/index.js"; import "./plugins/singleton/index.js"; import "./plugins/dragresize/index.js"; // Allows chat boxes to be resized by dragging them import "./plugins/fullscreen/index.js"; +import "./plugins/reactions/index.js"; // XEP-0444 Reactions /* END: Removable components */ _converse.exports.CustomElement = CustomElement; diff --git a/src/plugins/reactions/index.js b/src/plugins/reactions/index.js new file mode 100644 index 0000000000..be747ede1c --- /dev/null +++ b/src/plugins/reactions/index.js @@ -0,0 +1,345 @@ +/** + * @module converse-reactions + * @copyright The Converse.js contributors + * @license Mozilla Public License (MPLv2) + * @description + * This plugin implements XEP-0444: Message Reactions + * It allows users to react to messages with emojis (similar to Slack/Discord reactions) + * + * Features: + * - Add emoji reactions to received messages + * - Display reaction picker with popular emojis + full emoji selector + * - Send reactions as XMPP stanzas per XEP-0444 + * - Receive and display reactions from other users + * - Aggregate reactions by emoji with user counts + * - Users can only have one reaction per message (changing reaction replaces previous) + */ + +import { converse, api, u } from '@converse/headless'; +import './reaction-picker.js'; + +import { __ } from 'i18n'; + +converse.plugins.add('reactions', { + + /** + * Initializes the reactions plugin + * Sets up event listeners for: + * - Adding reaction buttons to messages + * - Receiving reaction stanzas from XMPP server + * - Handling connection/reconnection events + */ + initialize () { + /** + * Add "Add Reaction" button to message action buttons + * Only shown for received messages (not own messages) + * @listens getMessageActionButtons + */ + api.listen.on('getMessageActionButtons', (el, buttons) => { + const is_own_message = el.model.get('sender') === 'me'; + if (!is_own_message) { + buttons.push({ + 'i18n_text': __('Add Reaction'), + 'handler': (ev) => this.onReactionButtonClicked(el, ev), + 'button_class': 'chat-msg__action-reaction', + 'icon_class': 'fas fa-smile', + 'name': 'reaction', + }); + } + return buttons; + }); + + /** + * Register XMPP stanza handler for incoming reactions + * Called on connect and reconnect events + * Listens for stanzas containing + */ + const onConnect = () => { + /** + * Handler function for processing incoming message stanzas + * @param {Element} stanza - The received XMPP message stanza + * @returns {boolean} - Always returns true to keep handler active + */ + const handler = (stanza) => { + // Check for reaction element per XEP-0444 + const reactions = stanza.getElementsByTagNameNS('urn:xmpp:reactions:0', 'reaction'); + + if (reactions.length > 0) { + this.onReactionReceived(stanza, reactions[0]); + } + return true; // Keep handler alive for subsequent stanzas + }; + + // Register for ALL message stanzas, then filter internally + // This approach avoids missing reactions due to type variations + const conn = api.connection.get(); + if (conn && conn.addHandler) { + conn.addHandler(handler, null, 'message', null); + } + }; + + api.listen.on('connected', onConnect); + api.listen.on('reconnected', onConnect); + + // Also try to register immediately if already connected + if (api.connection.connected()) { + onConnect(); + } + }, + + /** + * Process a received reaction stanza + * Updates the target message's reactions data structure + * + * @param {Element} stanza - The XMPP message stanza containing the reaction + * @param {Element} reactionElement - The element from the stanza + * + * Reaction format (XEP-0444): + * + * + * 👍 + * + * + * + * Reactions are stored on messages as: + * { + * '👍': ['user1@domain', 'user2@domain'], + * '❤️': ['user3@domain'] + * } + */ + async onReactionReceived (stanza, reactionElement) { + const from_jid = stanza.getAttribute('from'); + const id = reactionElement.getAttribute('id'); // Target message ID + + // Extract emoji from child element + const emojis = reactionElement.getElementsByTagNameNS('urn:xmpp:reactions:0', 'emoji'); + const emoji = emojis.length > 0 ? emojis[0].textContent : null; + + if (!id || !emoji) return; + + /** + * Helper function to update a message with a new reaction + * @param {Object} message - The message model to update + * + * Process: + * 1. Clone reactions object to ensure Backbone detects changes + * 2. Remove user's previous reactions (one reaction per message) + * 3. Add the new reaction + * 4. Save to message model (triggers view update) + */ + const updateMessage = (message) => { + // IMPORTANT: Clone the reactions object to ensure Backbone detects the change + const current_reactions = message.get('reactions') || {}; + const reactions = JSON.parse(JSON.stringify(current_reactions)); + + // Remove user's previous reactions (they can only have one reaction per message) + for (const existingEmoji in reactions) { + const index = reactions[existingEmoji].indexOf(from_jid); + if (index !== -1) { + reactions[existingEmoji].splice(index, 1); + // Remove emoji key if no one else reacted with it + if (reactions[existingEmoji].length === 0) { + delete reactions[existingEmoji]; + } + } + } + + // Add the new reaction + if (!reactions[emoji]) { + reactions[emoji] = []; + } + reactions[emoji].push(from_jid); + + message.save({ 'reactions': reactions }); + }; + + // Strategy 1: Try to find chatbox by sender's bare JID + const { Strophe } = converse.env; + const bare_jid = Strophe.getBareJidFromJid(from_jid); + let chatbox = api.chatboxes.get(bare_jid); + + /** + * Helper to find message by ID in a chatbox + * @param {Object} box - The chatbox to search in + * @param {string} msgId - The message ID to find + * @returns {Object|null} - The message model or null + */ + const findMessage = (box, msgId) => { + if (!box || !box.messages) { + return null; + } + // Try direct lookup first + let msg = box.messages.get(msgId); + if (!msg) { + // Fallback to findWhere for older messages + msg = box.messages.findWhere({ 'msgid': msgId }); + } + return msg; + }; + + if (chatbox) { + const message = findMessage(chatbox, id); + if (message) { + updateMessage(message); + return; + } + } + + // Strategy 2: Search all open chatboxes (for carbons/multi-device support) + // This handles cases where reactions come from message carbons or other devices + const allChatboxes = await api.chatboxes.get(); + for (const cb of allChatboxes) { + const message = findMessage(cb, id); + if (message) { + updateMessage(message); + return; + } + } + }, + + /** + * Handle click on "Add Reaction" button + * Creates and displays the reaction picker UI + * + * @param {Element} el - The message element component + * @param {Event} ev - The click event + */ + onReactionButtonClicked (el, ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + + const target = /** @type {HTMLElement} */(ev.target).closest('button'); + const existing_picker = document.querySelector('converse-reaction-picker'); + + // Toggle: if clicking same button, close picker instead of reopening + if (existing_picker) { + const isSameTarget = /** @type {any} */(existing_picker).target === target; + existing_picker.remove(); + if (isSameTarget) { + return; + } + } + + // Create reaction picker component + const pickerEl = document.createElement('converse-reaction-picker'); + const picker = /** @type {HTMLElement & { target: HTMLElement | null; model: any; }} */ (pickerEl); + // @ts-ignore - custom element exposes target property + picker.target = target; + // @ts-ignore - custom element exposes model property + picker.model = el.model; + + // Position picker below the button + const rect = target.getBoundingClientRect(); + picker.style.position = 'absolute'; + picker.style.zIndex = '10000'; // Ensure it's above other elements + picker.style.left = `${rect.left}px`; + picker.style.top = `${rect.bottom + 5}px`; + + // Append to .conversejs container for proper CSS scoping + // Fallback to converse-root or document.body if container not found + const converseRoot = document.querySelector('.conversejs') || document.querySelector('converse-root'); + const container = converseRoot || document.body; + container.appendChild(picker); + + /** + * Close picker when clicking outside + * @param {Event} ev - The click event + */ + const onClickOutside = (ev) => { + if (!picker.isConnected) { + document.removeEventListener('click', onClickOutside); + return; + } + const clickTarget = /** @type {Node} */(ev.target); + if (!picker.contains(clickTarget) && !target.contains(clickTarget)) { + picker.remove(); + document.removeEventListener('click', onClickOutside); + } + }; + // Use setTimeout to avoid immediate trigger if event bubbles + setTimeout(() => document.addEventListener('click', onClickOutside), 0); + + /** + * Handle emoji selection from picker + * @listens reactionSelected + */ + picker.addEventListener('reactionSelected', (/** @type {CustomEvent} */ e) => { + const emoji = e.detail.emoji; + this.sendReaction(/** @type {any} */(el).model, emoji); + picker.remove(); + document.removeEventListener('click', onClickOutside); + }); + }, + + /** + * Send a reaction to a message + * Implements XEP-0444: Message Reactions + * + * @param {Object} message - The message model being reacted to + * @param {string} emoji - The emoji reaction (can be unicode or shortname like :joy:) + * + * Process: + * 1. Convert emoji shortname to unicode if needed + * 2. Build XEP-0444 compliant stanza + * 3. Send via XMPP connection + * 4. Optimistically update local state for immediate UI feedback + */ + sendReaction (message, emoji) { + const { $msg } = converse.env; + const chatbox = message.collection.chatbox; + const msgId = message.get('msgid'); + const to_jid = chatbox.get('jid'); + // Default to 'chat' type unless explicitly a groupchat (MUC) + const type = chatbox.get('type') === 'groupchat' ? 'groupchat' : 'chat'; + + if (!emoji) return; + + // Convert emoji shortname (e.g. :joy:) to unicode (e.g. 😂) + // Check if emoji is already unicode (from emoji picker) or needs conversion (from shortname buttons) + let emojiUnicode = emoji; + if (emoji.startsWith(':') && emoji.endsWith(':')) { + const emojiArray = u.shortnamesToEmojis(emoji, { unicode_only: true }); + emojiUnicode = Array.isArray(emojiArray) ? emojiArray.join('') : emojiArray; + } + + // Build XEP-0444 reaction stanza + const reaction = $msg({ + 'to': to_jid, + 'type': type, + 'id': u.getUniqueId('reaction') + }).c('reaction', { + 'xmlns': 'urn:xmpp:reactions:0', + 'id': msgId // ID of the message being reacted to + }).c('emoji').t(emojiUnicode); + + // Send stanza to XMPP server + api.send(reaction); + + // Optimistic local update for immediate UI feedback + const my_jid = api.connection.get().jid; + const currentReactions = message.get('reactions') || {}; + // Clone to ensure Backbone detects the change + const reactions = JSON.parse(JSON.stringify(currentReactions)); + + // Remove user's previous reactions (one reaction per message per XEP-0444) + for (const existingEmoji in reactions) { + const index = reactions[existingEmoji].indexOf(my_jid); + if (index !== -1) { + reactions[existingEmoji].splice(index, 1); + // Clean up emoji key if no users remain + if (reactions[existingEmoji].length === 0) { + delete reactions[existingEmoji]; + } + } + } + + // Add the new reaction + if (!reactions[emojiUnicode]) { + reactions[emojiUnicode] = []; + } + reactions[emojiUnicode].push(my_jid); + + // Save to model - triggers view re-render + message.save({ 'reactions': reactions }); + } +}); diff --git a/src/plugins/reactions/reaction-picker.js b/src/plugins/reactions/reaction-picker.js new file mode 100644 index 0000000000..3fe8858654 --- /dev/null +++ b/src/plugins/reactions/reaction-picker.js @@ -0,0 +1,190 @@ +/** + * @module converse-reaction-picker + * @copyright The Converse.js contributors + * @license Mozilla Public License (MPLv2) + * @description + * LitElement custom component for the reaction picker UI + * Displays popular emojis for quick selection and a dropdown for full emoji picker + * + * Features: + * - Quick access to popular emojis (👍, ❤️, 😂, 😮) + * - Full emoji picker dropdown with search and categories + * - Lazy-loads emoji picker for better performance + * - Dispatches 'reactionSelected' event when emoji is chosen + */ + +import { CustomElement } from 'shared/components/element.js'; +import { html } from 'lit'; +import { api, u, EmojiPicker } from '@converse/headless'; +import { __ } from 'i18n'; +import 'shared/components/dropdown.js'; // Ensure dropdown styles/scripts are loaded +import 'shared/chat/emoji-picker.js'; // Ensure emoji picker component is loaded +import 'shared/chat/styles/emoji.scss'; // Import emoji picker styles +import './reaction-picker.scss'; + +/** + * Popular emojis shown in the quick picker + * These are the most commonly used reactions across messaging platforms + */ +const POPULAR_EMOJIS = [ + ':thumbsup:', // 👍 + ':heart:', // ❤️ + ':joy:', // 😂 + ':open_mouth:' // 😮 +]; + +/** + * ReactionPicker Component + * @extends CustomElement + * @fires reactionSelected - Dispatched when user selects an emoji + */ +export default class ReactionPicker extends CustomElement { + + /** + * Define reactive properties for the component + * @returns {Object} Property definitions + * + * Properties: + * - target: The button element that triggered the picker (for positioning) + * - model: The message model being reacted to + * - emoji_picker_state: State model for the full emoji picker + */ + static get properties () { + return { + 'target': { type: Object }, + 'model': { type: Object }, + 'emoji_picker_state': { type: Object } + }; + } + + /** + * Initialize component with default values + */ + constructor () { + super(); + this.target = null; + this.model = null; + this.emoji_picker_state = null; + this.picker_id = u.getUniqueId('reaction-picker'); + } + + /** + * Render the reaction picker UI + * @returns {Object} Lit HTML template + * + * UI Structure: + * - Popular emojis row (quick selection) + * - Plus button with dropdown (full emoji picker) + */ + render () { + const anchor_name = `--reaction-anchor-${this.picker_id}`; + const is_own_message = this.model?.get('sender') === 'me'; + + // Don't show reaction picker on own messages + if (is_own_message) { + return ''; + } + + return html` + + `; + } + + /** + * Initialize the full emoji picker (lazy-loaded) + * Only loads emoji data when user opens the dropdown + * This improves initial performance + * + * @async + * @returns {Promise} + */ + async initEmojiPicker () { + if (!this.emoji_picker_state) { + // Initialize emoji data from API + await api.emojis.initialize(); + + // Create emoji picker state model + const id = u.getUniqueId('emoji-picker'); + this.emoji_picker_state = new EmojiPicker({ id }); + + // Initialize local storage for picker preferences + u.initStorage(this.emoji_picker_state, id); + + // Fetch emoji data (categories, recent emojis, etc.) + await new Promise(resolve => this.emoji_picker_state.fetch({'success': resolve, 'error': resolve})); + + // Trigger re-render to show the picker + this.requestUpdate(); + } + } + + /** + * Handle emoji selection + * Dispatches custom event and closes the dropdown + * + * @param {string} emoji - The selected emoji (can be unicode or shortname) + * @fires reactionSelected + */ + onEmojiSelected (emoji) { + // Dispatch event for parent component to handle + this.dispatchEvent(new CustomEvent('reactionSelected', { + detail: { emoji }, + bubbles: true, // Allow event to bubble up + composed: true // Cross shadow DOM boundary + })); + + // Close Bootstrap dropdown programmatically + const dropdown = this.querySelector('.dropdown-menu'); + if (dropdown && dropdown.classList.contains('show')) { + const dropdownBtn = /** @type {HTMLElement} */ (this.querySelector('.dropdown-toggle')); + if (dropdownBtn) { + // Use Bootstrap 5 API to properly hide dropdown + const bootstrap = window.bootstrap; + if (bootstrap && bootstrap.Dropdown) { + const dropdownInstance = bootstrap.Dropdown.getInstance(dropdownBtn); + if (dropdownInstance) { + dropdownInstance.hide(); + } + } + } + } + } +} + +api.elements.define('converse-reaction-picker', ReactionPicker); diff --git a/src/plugins/reactions/reaction-picker.scss b/src/plugins/reactions/reaction-picker.scss new file mode 100644 index 0000000000..14d5129333 --- /dev/null +++ b/src/plugins/reactions/reaction-picker.scss @@ -0,0 +1,179 @@ +/** + * Reaction Picker Styles + * Styles for the emoji reaction picker UI and reaction bubbles + * + * Components: + * - .reaction-picker: Glassmorphic popup with popular emojis + * - .reaction-item: Individual emoji buttons + * - .dropdown-menu: Full emoji picker dropdown + * - .chat-msg__reaction: Slack-style reaction bubbles on messages + */ + +/* Pop-in animation for picker appearance */ +@keyframes popIn { + 0% { + opacity: 0; + transform: scale(0.5) translateY(10px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Main reaction picker container - glassmorphic design */ +.reaction-picker { + position: relative; /* anchor absolute dropdown so it doesn't affect layout */ + background: var(--reaction-picker-bg, rgba(255, 255, 255, 0.15)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); + padding: 4px 8px; + display: flex; + gap: 4px; + animation: popIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + transform-origin: bottom center; + margin-right: 1rem; + + &.popular { + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + /* Overflow removed to allow dropdown to extend beyond container */ + } + + /* Individual emoji button in picker */ + .reaction-item { + width: 30px; + height: 30px; + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + border-radius: 50%; + transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-color); + position: relative; + flex-shrink: 0; + + + &:hover { + background-color: rgba(255, 255, 255, 0.2); + transform: scale(1.05); + } + + &:active, &.active { + transform: scale(0.95); + background-color: rgba(255, 255, 255, 0.3); + } + + /* "More" button (plus icon) */ + &.more { + font-size: 0.7rem; + background: rgba(0, 0, 0, 0.05); + + &:hover { + background: rgba(0, 0, 0, 0.1); + transform: scale(1.05); + } + } + } + + /* Dropdown container for full emoji picker */ + .dropdown { + display: flex; + position: relative; + align-items: center; + flex-shrink: 0; + } + + /* Full emoji picker dropdown menu */ + .dropdown-menu { + display: none; + background: var(--background-color); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); + padding: 0.5rem; + overflow: visible; /* Allow skin-tone picker to extend horizontally */ + min-width: 18rem; + max-width: 22rem; + + /* Fallback positioning for browsers without CSS anchor positioning */ + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 1000; + + /* CSS Anchor Positioning (modern browsers) */ + /* Anchor name defined in inline style of the button */ + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline, bottom span-left; + margin-top: 8px; + + /* Bootstrap toggles "show" class to display */ + &.show { + display: block; + } + + li { + width: 100%; + } + } +} + +/* Container for reaction bubbles on messages */ +.chat-msg__reactions { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.5rem; + margin-left: 0; + padding-left: 0; + width: 100%; +} + +/* Individual reaction bubble (Slack-style) */ +.chat-msg__reaction { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + background: var(--reaction-bg, rgba(29, 155, 209, 0.1)); + border: 1px solid var(--reaction-border, rgba(29, 155, 209, 0.2)); + border-radius: 12px !important; /* Rounded pill shape */ + min-width: auto; + height: 22px; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + line-height: 1; + cursor: pointer; + + /* Style for user's own reactions (highlighted) */ + &.reacted-by-user { + background: var(--reaction-bg-active, rgba(29, 155, 209, 0.2)); + border-color: var(--reaction-border-active, rgba(29, 155, 209, 0.4)); + font-weight: 500; + } + + /* Emoji within the bubble */ + .emoji { + font-size: 0.875rem; + line-height: 1; + } + + /* Count of users who reacted with this emoji */ + .count { + display: inline; + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-color, #1d1c1d); + margin-left: 0.125rem; + } +} diff --git a/src/plugins/reactions/tests/reactions.js b/src/plugins/reactions/tests/reactions.js new file mode 100644 index 0000000000..90d27faa88 --- /dev/null +++ b/src/plugins/reactions/tests/reactions.js @@ -0,0 +1,431 @@ +/*global mock, converse */ + +/** + * Message Reactions Tests (XEP-0444) + * + * To run only specific tests: + * 1. Change it("test name") to fit("test name") for the test you want to focus on + * 2. Run: npm run test + * 3. Click the "Debug" button in the Karma browser window + * 4. Open browser DevTools (F12) to step through the test + * + * To run all tests, make sure all tests use it() not fit() + */ + +const { Strophe, sizzle, u } = converse.env; + +describe("Message Reactions (XEP-0444)", function () { + + beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); + + describe("Reaction Picker UI", function () { + + it("appears when hovering over a received message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + // Create a received message (not from 'me') + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'test-message-1', + body: 'This is a test message', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + expect(msg_el).not.toBe(null); + + // Check that reaction picker appears in message actions + const actions = msg_el.querySelector('.chat-msg__actions'); + expect(actions).not.toBe(null); + + const reaction_btn = actions.querySelector('.chat-msg__action-reaction'); + expect(reaction_btn).not.toBe(null); + }) + ); + + it("does not appear for own messages", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + // Send our own message + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = 'This is my message'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + key: "Enter", + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + + // Reaction button should not appear for own messages + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + expect(reaction_btn).toBe(null); + }) + ); + + it("displays popular emojis in the quick picker", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'test-message-2', + body: 'Test message for emoji picker', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + + // Click the reaction button + reaction_btn.click(); + + await u.waitUntil(() => document.querySelector('converse-reaction-picker')); + const picker = document.querySelector('converse-reaction-picker'); + expect(picker).not.toBe(null); + + // Check for popular emoji buttons + const emoji_buttons = picker.querySelectorAll('.reaction-item:not(.more)'); + expect(emoji_buttons.length).toBeGreaterThan(0); + }) + ); + }); + + describe("Sending Reactions", function () { + + it("sends a reaction stanza when clicking an emoji", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'msg-to-react', + body: 'React to this', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + + spyOn(api.connection.get(), 'send'); + + reaction_btn.click(); + await u.waitUntil(() => document.querySelector('converse-reaction-picker')); + + const picker = document.querySelector('converse-reaction-picker'); + const first_emoji_btn = picker.querySelector('.reaction-item:not(.more)'); + first_emoji_btn.click(); + + await u.waitUntil(() => api.connection.get().send.calls.count() > 0); + expect(api.connection.get().send).toHaveBeenCalled(); + + const sent_stanza = api.connection.get().send.calls.argsFor(0)[0]; + expect(Strophe.serialize(sent_stanza)).toContain('urn:xmpp:reactions:0'); + expect(Strophe.serialize(sent_stanza)).toContain('msg-to-react'); + }) + ); + + it("updates local message with the sent reaction", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'msg-with-reaction', + body: 'Message to get reaction', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + const msg_model = view.model.messages.findWhere({'msgid': 'msg-with-reaction'}); + expect(msg_model.get('reactions')).toBeFalsy(); + + const msg_el = view.querySelector('.chat-msg'); + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + reaction_btn.click(); + + await u.waitUntil(() => document.querySelector('converse-reaction-picker')); + const picker = document.querySelector('converse-reaction-picker'); + const first_emoji_btn = picker.querySelector('.reaction-item:not(.more)'); + first_emoji_btn.click(); + + // Wait for reaction to be added to message model + await u.waitUntil(() => { + const reactions = msg_model.get('reactions'); + return reactions && Object.keys(reactions).length > 0; + }); + + const reactions = msg_model.get('reactions'); + expect(Object.keys(reactions).length).toBeGreaterThan(0); + }) + ); + }); + + describe("Receiving Reactions", function () { + + it("displays received reactions on the message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + // Send a message first + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = 'Message to receive reaction'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + key: "Enter", + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.at(0); + const msgid = msg_model.get('msgid'); + + // Simulate receiving a reaction + const reaction_stanza = u.toStanza(` + + + 👍 + + + `); + + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction_stanza)); + + // Wait for reaction to appear + await u.waitUntil(() => { + const reactions = msg_model.get('reactions'); + return reactions && reactions['👍']; + }); + + const reactions = msg_model.get('reactions'); + expect(reactions['👍']).toContain(contact_jid); + }) + ); + + it("updates reaction count when multiple users react with the same emoji", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 2); + const contact1_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact2_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await mock.openChatBoxFor(_converse, contact1_jid); + const view = _converse.chatboxviews.get(contact1_jid); + + // Receive a message + const message = await mock.receiveMessage(_converse, { + from: contact1_jid, + to: _converse.bare_jid, + msgid: 'multi-reaction-msg', + body: 'Multiple reactions here', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.findWhere({'msgid': 'multi-reaction-msg'}); + + // First reaction from contact1 + const reaction1 = u.toStanza(` + + + ❤️ + + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction1)); + + await u.waitUntil(() => msg_model.get('reactions')?.['❤️']?.length === 1); + + // Second reaction from contact2 with same emoji + const reaction2 = u.toStanza(` + + + ❤️ + + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction2)); + + await u.waitUntil(() => msg_model.get('reactions')?.['❤️']?.length === 2); + + const reactions = msg_model.get('reactions'); + expect(reactions['❤️'].length).toBe(2); + expect(reactions['❤️']).toContain(contact1_jid); + expect(reactions['❤️']).toContain(contact2_jid); + }) + ); + + it("replaces a user's previous reaction when they react again", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'replace-reaction-msg', + body: 'Change reaction test', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.findWhere({'msgid': 'replace-reaction-msg'}); + + // First reaction + const reaction1 = u.toStanza(` + + + 👍 + + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction1)); + + await u.waitUntil(() => msg_model.get('reactions')?.['👍']); + expect(msg_model.get('reactions')['👍']).toContain(contact_jid); + + // Second reaction from same user (should replace first) + const reaction2 = u.toStanza(` + + + ❤️ + + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction2)); + + await u.waitUntil(() => msg_model.get('reactions')?.['❤️']); + + const reactions = msg_model.get('reactions'); + expect(reactions['👍']).toBeFalsy(); // Old reaction removed + expect(reactions['❤️']).toContain(contact_jid); // New reaction present + }) + ); + }); + + describe("Reaction Display", function () { + + it("shows reaction bubbles below the message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'bubble-test-msg', + body: 'Message with reaction bubbles', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.findWhere({'msgid': 'bubble-test-msg'}); + + // Add reaction + const reaction = u.toStanza(` + + + 🎉 + + + `); + _converse.api.connection.get()._dataRecv(mock.createRequest(reaction)); + + await u.waitUntil(() => view.querySelector('.chat-msg__reactions')); + + const reactions_container = view.querySelector('.chat-msg__reactions'); + expect(reactions_container).not.toBe(null); + + const reaction_bubble = reactions_container.querySelector('.chat-msg__reaction'); + expect(reaction_bubble).not.toBe(null); + expect(reaction_bubble.textContent).toContain('🎉'); + expect(reaction_bubble.textContent).toContain('1'); // Count + }) + ); + + it("displays reaction count correctly", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 2); + const contact1_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact2_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await mock.openChatBoxFor(_converse, contact1_jid); + const view = _converse.chatboxviews.get(contact1_jid); + + const message = await mock.receiveMessage(_converse, { + from: contact1_jid, + to: _converse.bare_jid, + msgid: 'count-test-msg', + body: 'Count test', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + // Add two reactions with same emoji + const reactions = [contact1_jid, contact2_jid].map(jid => u.toStanza(` + + + + + + `)); + + reactions.forEach(r => _converse.api.connection.get()._dataRecv(mock.createRequest(r))); + + await u.waitUntil(() => { + const bubble = view.querySelector('.chat-msg__reaction .count'); + return bubble && bubble.textContent === '2'; + }); + + const count_elem = view.querySelector('.chat-msg__reaction .count'); + expect(count_elem.textContent).toBe('2'); + }) + ); + }); +}); diff --git a/src/shared/chat/emoji-picker.js b/src/shared/chat/emoji-picker.js index e3909960b9..15accfa786 100644 --- a/src/shared/chat/emoji-picker.js +++ b/src/shared/chat/emoji-picker.js @@ -46,7 +46,7 @@ export default class EmojiPicker extends CustomElement { initialize() { super.initialize(); - this.dropdown = this.closest("converse-emoji-dropdown"); + this.dropdown = this.closest("converse-emoji-dropdown") || this.closest(".dropdown"); } firstUpdated(changed) { @@ -122,7 +122,7 @@ export default class EmojiPicker extends CustomElement { registerEvents() { this.onKeyDown = (ev) => this.#onKeyDown(ev); - this.dropdown.addEventListener("hide.bs.dropdown", () => this.onDropdownHide()); + this.dropdown?.addEventListener("hide.bs.dropdown", () => this.onDropdownHide()); this.addEventListener("keydown", this.onKeyDown); } diff --git a/src/shared/chat/styles/emoji.scss b/src/shared/chat/styles/emoji.scss index 0ff27cfda0..5e5911ea14 100644 --- a/src/shared/chat/styles/emoji.scss +++ b/src/shared/chat/styles/emoji.scss @@ -4,7 +4,8 @@ .conversejs { - .chatbox { + .chatbox, + converse-reaction-picker { img.emoji { height: 1.2em; width: 1.2em; diff --git a/src/shared/chat/templates/message.js b/src/shared/chat/templates/message.js index 0d393e99f0..5134ea65fb 100644 --- a/src/shared/chat/templates/message.js +++ b/src/shared/chat/templates/message.js @@ -9,6 +9,26 @@ import 'shared/chat/unfurl.js'; const { dayjs } = converse.env; + +const renderReactions = (model) => { + const reactions = model.get('reactions') || {}; + const emojis = Object.keys(reactions); + if (emojis.length === 0) return ''; + + return html` +
+ ${emojis.map(emoji => { + const count = reactions[emoji].length; + return html` + + `; + })} +
+ `; +}; + /** * @param {import('../message').default} el */ @@ -109,6 +129,8 @@ export default (el) => { ?is_retracted=${is_retracted} > + + ${renderReactions(el.model)} ${el.model.get('ogp_metadata')?.map((m) => { if (el.model.get('hide_url_previews') === true) { diff --git a/src/shared/constants.js b/src/shared/constants.js index 3ae16219f5..575ce948cb 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -27,7 +27,8 @@ export const VIEW_PLUGINS = [ 'converse-roomslist', 'converse-rootview', 'converse-rosterview', - 'converse-singleton' + 'converse-singleton', + 'reactions' ]; /** From 4499f7d1a95cb092c6db00c330b4615af2a6cecc Mon Sep 17 00:00:00 2001 From: marcellintacite Date: Mon, 1 Dec 2025 21:32:22 +0300 Subject: [PATCH 2/5] Clean code duplication and adding support for Discovering support & Restricted reactions --- src/headless/types/shared/_converse.d.ts | 1 + src/plugins/reactions/index.js | 241 ++++++++------ src/plugins/reactions/reaction-picker.js | 11 +- src/plugins/reactions/tests/reactions.js | 376 ++++++++++++++++++++++ src/shared/chat/emoji-picker-content.js | 9 + src/shared/chat/emoji-picker.js | 2 + src/shared/chat/templates/emoji-picker.js | 1 + 7 files changed, 540 insertions(+), 101 deletions(-) diff --git a/src/headless/types/shared/_converse.d.ts b/src/headless/types/shared/_converse.d.ts index 749cc2e62e..520f939ac3 100644 --- a/src/headless/types/shared/_converse.d.ts +++ b/src/headless/types/shared/_converse.d.ts @@ -23,6 +23,7 @@ declare const ConversePrivateGlobal_base: (new (...args: any[]) => { * @namespace _converse */ export class ConversePrivateGlobal extends ConversePrivateGlobal_base { + disco_entities: any; constructor(); initialize(): void; VERSION_NAME: string; diff --git a/src/plugins/reactions/index.js b/src/plugins/reactions/index.js index be747ede1c..bcb4dea839 100644 --- a/src/plugins/reactions/index.js +++ b/src/plugins/reactions/index.js @@ -11,11 +11,10 @@ * - Display reaction picker with popular emojis + full emoji selector * - Send reactions as XMPP stanzas per XEP-0444 * - Receive and display reactions from other users - * - Aggregate reactions by emoji with user counts - * - Users can only have one reaction per message (changing reaction replaces previous) + * - Aggregate reactions by emoji with user counts * - Users can have multiple reactions per message */ -import { converse, api, u } from '@converse/headless'; +import { converse, api, u, _converse } from '@converse/headless'; import './reaction-picker.js'; import { __ } from 'i18n'; @@ -30,14 +29,61 @@ converse.plugins.add('reactions', { * - Handling connection/reconnection events */ initialize () { + const { Strophe } = converse.env; + this.allowed_emojis = new Map(); // Store allowed emojis per JID + + /** + * Register the "urn:xmpp:reactions:0" feature + */ + api.listen.on('addClientFeatures', () => { + api.disco.own.features.add('urn:xmpp:reactions:0'); + }); + + /** + * Listen for disco info results to parse restricted emojis + */ + api.listen.on('stanza', (stanza) => { + if (stanza.nodeName === 'iq' && stanza.getAttribute('type') === 'result') { + const query = stanza.querySelector(`query[xmlns="${Strophe.NS.DISCO_INFO}"]`); + if (query) { + const from_jid = stanza.getAttribute('from'); + const bare_jid = Strophe.getBareJidFromJid(from_jid); + const feature = query.querySelector(`feature[var="urn:xmpp:reactions:0#restricted"]`); + if (feature) { + const allowed = Array.from(feature.querySelectorAll('allow')).map(el => el.textContent); + this.allowed_emojis.set(bare_jid, allowed); + this.allowed_emojis.set(from_jid, allowed); + } + } + } + }); + /** * Add "Add Reaction" button to message action buttons * Only shown for received messages (not own messages) + * Checks if the contact supports reactions * @listens getMessageActionButtons */ api.listen.on('getMessageActionButtons', (el, buttons) => { const is_own_message = el.model.get('sender') === 'me'; if (!is_own_message) { + const chatbox = el.model.collection.chatbox; + const jid = chatbox.get('jid'); + const type = chatbox.get('type'); + + // Check for support in 1:1 chats + if (type === 'chat') { + const entity = _converse.disco_entities?.get(jid); + // If we have disco info, check for the feature + if (entity && entity.features && entity.features.length > 0) { + const supportsReactions = entity.features.findWhere({'var': 'urn:xmpp:reactions:0'}); + if (!supportsReactions) { + return buttons; + } + } + // If unknown, we default to showing it (or we could trigger a disco check here) + } + buttons.push({ 'i18n_text': __('Add Reaction'), 'handler': (ev) => this.onReactionButtonClicked(el, ev), @@ -52,7 +98,7 @@ converse.plugins.add('reactions', { /** * Register XMPP stanza handler for incoming reactions * Called on connect and reconnect events - * Listens for stanzas containing + * Listens for stanzas containing */ const onConnect = () => { /** @@ -61,8 +107,8 @@ converse.plugins.add('reactions', { * @returns {boolean} - Always returns true to keep handler active */ const handler = (stanza) => { - // Check for reaction element per XEP-0444 - const reactions = stanza.getElementsByTagNameNS('urn:xmpp:reactions:0', 'reaction'); + // Check for reactions element per XEP-0444 + const reactions = stanza.getElementsByTagNameNS('urn:xmpp:reactions:0', 'reactions'); if (reactions.length > 0) { this.onReactionReceived(stanza, reactions[0]); @@ -87,71 +133,58 @@ converse.plugins.add('reactions', { } }, + /** + * Helper function to update a message with a new reaction + * @param {Object} message - The message model to update + * @param {string} from_jid - The JID of the user reacting + * @param {Array} emojis - The list of emojis (can be empty for removal) + */ + updateMessageReactions (message, from_jid, emojis) { + // IMPORTANT: Clone the reactions object to ensure Backbone detects the change + const current_reactions = message.get('reactions') || {}; + const reactions = JSON.parse(JSON.stringify(current_reactions)); + + // Remove user's previous reactions (clear slate for this user) + for (const existingEmoji in reactions) { + const index = reactions[existingEmoji].indexOf(from_jid); + if (index !== -1) { + reactions[existingEmoji].splice(index, 1); + // Remove emoji key if no one else reacted with it + if (reactions[existingEmoji].length === 0) { + delete reactions[existingEmoji]; + } + } + } + + // Add the new reactions + emojis.forEach(emoji => { + if (!reactions[emoji]) { + reactions[emoji] = []; + } + if (!reactions[emoji].includes(from_jid)) { + reactions[emoji].push(from_jid); + } + }); + + message.save({ 'reactions': reactions }); + }, + /** * Process a received reaction stanza * Updates the target message's reactions data structure * * @param {Element} stanza - The XMPP message stanza containing the reaction - * @param {Element} reactionElement - The element from the stanza - * - * Reaction format (XEP-0444): - * - * - * 👍 - * - * - * - * Reactions are stored on messages as: - * { - * '👍': ['user1@domain', 'user2@domain'], - * '❤️': ['user3@domain'] - * } + * @param {Element} reactionsElement - The element from the stanza */ - async onReactionReceived (stanza, reactionElement) { + async onReactionReceived (stanza, reactionsElement) { const from_jid = stanza.getAttribute('from'); - const id = reactionElement.getAttribute('id'); // Target message ID + const id = reactionsElement.getAttribute('id'); // Target message ID - // Extract emoji from child element - const emojis = reactionElement.getElementsByTagNameNS('urn:xmpp:reactions:0', 'emoji'); - const emoji = emojis.length > 0 ? emojis[0].textContent : null; - - if (!id || !emoji) return; + // Extract emojis from child elements + const reactionElements = reactionsElement.getElementsByTagName('reaction'); + const emojis = Array.from(reactionElements).map(el => el.textContent).filter(e => e); - /** - * Helper function to update a message with a new reaction - * @param {Object} message - The message model to update - * - * Process: - * 1. Clone reactions object to ensure Backbone detects changes - * 2. Remove user's previous reactions (one reaction per message) - * 3. Add the new reaction - * 4. Save to message model (triggers view update) - */ - const updateMessage = (message) => { - // IMPORTANT: Clone the reactions object to ensure Backbone detects the change - const current_reactions = message.get('reactions') || {}; - const reactions = JSON.parse(JSON.stringify(current_reactions)); - - // Remove user's previous reactions (they can only have one reaction per message) - for (const existingEmoji in reactions) { - const index = reactions[existingEmoji].indexOf(from_jid); - if (index !== -1) { - reactions[existingEmoji].splice(index, 1); - // Remove emoji key if no one else reacted with it - if (reactions[existingEmoji].length === 0) { - delete reactions[existingEmoji]; - } - } - } - - // Add the new reaction - if (!reactions[emoji]) { - reactions[emoji] = []; - } - reactions[emoji].push(from_jid); - - message.save({ 'reactions': reactions }); - }; + if (!id) return; // Strategy 1: Try to find chatbox by sender's bare JID const { Strophe } = converse.env; @@ -180,7 +213,7 @@ converse.plugins.add('reactions', { if (chatbox) { const message = findMessage(chatbox, id); if (message) { - updateMessage(message); + this.updateMessageReactions(message, from_jid, emojis); return; } } @@ -191,7 +224,7 @@ converse.plugins.add('reactions', { for (const cb of allChatboxes) { const message = findMessage(cb, id); if (message) { - updateMessage(message); + this.updateMessageReactions(message, from_jid, emojis); return; } } @@ -222,12 +255,20 @@ converse.plugins.add('reactions', { // Create reaction picker component const pickerEl = document.createElement('converse-reaction-picker'); - const picker = /** @type {HTMLElement & { target: HTMLElement | null; model: any; }} */ (pickerEl); + const picker = /** @type {HTMLElement & { target: HTMLElement | null; model: any; allowed_emojis: any; }} */ (pickerEl); // @ts-ignore - custom element exposes target property picker.target = target; // @ts-ignore - custom element exposes model property picker.model = el.model; + // @ts-ignore + const chatbox = el.model.collection.chatbox; + const jid = chatbox.get('jid'); + const { Strophe } = converse.env; + const bare_jid = Strophe.getBareJidFromJid(jid); + // @ts-ignore + picker.allowed_emojis = this.allowed_emojis.get(jid) || this.allowed_emojis.get(bare_jid); + // Position picker below the button const rect = target.getBoundingClientRect(); picker.style.position = 'absolute'; @@ -277,12 +318,6 @@ converse.plugins.add('reactions', { * * @param {Object} message - The message model being reacted to * @param {string} emoji - The emoji reaction (can be unicode or shortname like :joy:) - * - * Process: - * 1. Convert emoji shortname to unicode if needed - * 2. Build XEP-0444 compliant stanza - * 3. Send via XMPP connection - * 4. Optimistically update local state for immediate UI feedback */ sendReaction (message, emoji) { const { $msg } = converse.env; @@ -302,44 +337,52 @@ converse.plugins.add('reactions', { emojiUnicode = Array.isArray(emojiArray) ? emojiArray.join('') : emojiArray; } - // Build XEP-0444 reaction stanza - const reaction = $msg({ - 'to': to_jid, - 'type': type, - 'id': u.getUniqueId('reaction') - }).c('reaction', { - 'xmlns': 'urn:xmpp:reactions:0', - 'id': msgId // ID of the message being reacted to - }).c('emoji').t(emojiUnicode); - - // Send stanza to XMPP server - api.send(reaction); + // Filter out custom emojis (stickers) which don't have a unicode representation + if (emojiUnicode.startsWith(':') && emojiUnicode.endsWith(':')) { + return; + } - // Optimistic local update for immediate UI feedback const my_jid = api.connection.get().jid; const currentReactions = message.get('reactions') || {}; // Clone to ensure Backbone detects the change const reactions = JSON.parse(JSON.stringify(currentReactions)); - // Remove user's previous reactions (one reaction per message per XEP-0444) + // Determine current user's reactions + const myReactions = new Set(); for (const existingEmoji in reactions) { - const index = reactions[existingEmoji].indexOf(my_jid); - if (index !== -1) { - reactions[existingEmoji].splice(index, 1); - // Clean up emoji key if no users remain - if (reactions[existingEmoji].length === 0) { - delete reactions[existingEmoji]; - } + if (reactions[existingEmoji].includes(my_jid)) { + myReactions.add(existingEmoji); } } - // Add the new reaction - if (!reactions[emojiUnicode]) { - reactions[emojiUnicode] = []; + // Toggle the clicked emoji + if (myReactions.has(emojiUnicode)) { + myReactions.delete(emojiUnicode); + } else { + myReactions.add(emojiUnicode); + } + + // Build XEP-0444 reaction stanza with ALL current reactions + const reactionStanza = $msg({ + 'to': to_jid, + 'type': type, + 'id': u.getUniqueId('reaction') + }).c('reactions', { + 'xmlns': 'urn:xmpp:reactions:0', + 'id': msgId // ID of the message being reacted to + }); + + myReactions.forEach(r => { + reactionStanza.c('reaction').t(r).up(); + }); + + // Send stanza to XMPP server + api.send(reactionStanza); + + // Optimistic local update for immediate UI feedback + // Only for 1:1 chats where no server reflection occurs for the sender + if (type === 'chat') { + this.updateMessageReactions(message, my_jid, Array.from(myReactions)); } - reactions[emojiUnicode].push(my_jid); - - // Save to model - triggers view re-render - message.save({ 'reactions': reactions }); } }); diff --git a/src/plugins/reactions/reaction-picker.js b/src/plugins/reactions/reaction-picker.js index 3fe8858654..db235a7bb9 100644 --- a/src/plugins/reactions/reaction-picker.js +++ b/src/plugins/reactions/reaction-picker.js @@ -53,7 +53,8 @@ export default class ReactionPicker extends CustomElement { return { 'target': { type: Object }, 'model': { type: Object }, - 'emoji_picker_state': { type: Object } + 'emoji_picker_state': { type: Object }, + 'allowed_emojis': { type: Array } }; } @@ -66,6 +67,7 @@ export default class ReactionPicker extends CustomElement { this.model = null; this.emoji_picker_state = null; this.picker_id = u.getUniqueId('reaction-picker'); + this.allowed_emojis = null; } /** @@ -85,10 +87,14 @@ export default class ReactionPicker extends CustomElement { return ''; } + const popular_emojis = this.allowed_emojis ? + POPULAR_EMOJIS.filter(sn => this.allowed_emojis.includes(u.shortnamesToEmojis(sn))) : + POPULAR_EMOJIS; + return html`