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..a8d970458e --- /dev/null +++ b/src/plugins/reactions/index.js @@ -0,0 +1,385 @@ +/** + * @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 have multiple reactions per message + */ + +import { converse, api, u, _converse } 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 () { + 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 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.api.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), + '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 reactions element per XEP-0444 + const reactions = stanza.getElementsByTagNameNS('urn:xmpp:reactions:0', 'reactions'); + + 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(); + } + }, + + /** + * 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} reactionsElement - The element from the stanza + */ + async onReactionReceived (stanza, reactionsElement) { + const from_jid = stanza.getAttribute('from'); + const id = reactionsElement.getAttribute('id'); // Target message ID + + // Extract emojis from child elements + const reactionElements = reactionsElement.getElementsByTagName('reaction'); + const emojis = Array.from(reactionElements).map(el => el.textContent).filter(e => e); + + if (!id) return; + + // 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) { + this.updateMessageReactions(message, from_jid, emojis); + 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) { + this.updateMessageReactions(message, from_jid, emojis); + 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; 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'; + 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:) + */ + 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; + } + + // Filter out custom emojis (stickers) which don't have a unicode representation + if (emojiUnicode.startsWith(':') && emojiUnicode.endsWith(':')) { + return; + } + + 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)); + + // Determine current user's reactions + const myReactions = new Set(); + for (const existingEmoji in reactions) { + if (reactions[existingEmoji].includes(my_jid)) { + myReactions.add(existingEmoji); + } + } + + // 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)); + } + } +}); diff --git a/src/plugins/reactions/reaction-picker.js b/src/plugins/reactions/reaction-picker.js new file mode 100644 index 0000000000..bab130dff8 --- /dev/null +++ b/src/plugins/reactions/reaction-picker.js @@ -0,0 +1,193 @@ +/** + * @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, converse } 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 }, + 'allowed_emojis': { type: Array } + }; + } + + /** + * 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'); + this.allowed_emojis = null; + } + + /** + * 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 popular_emojis = this.allowed_emojis ? + POPULAR_EMOJIS.filter(sn => this.allowed_emojis.includes(u.shortnamesToEmojis(sn))) : + POPULAR_EMOJIS; + + 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/custom_emoji_filtering.js b/src/plugins/reactions/tests/custom_emoji_filtering.js new file mode 100644 index 0000000000..859737088a --- /dev/null +++ b/src/plugins/reactions/tests/custom_emoji_filtering.js @@ -0,0 +1,64 @@ +/* global converse */ +const { u } = converse.env; + +describe("Reaction Picker Custom Emoji Filtering", function () { + let _converse; + + beforeAll(async function () { + await converse.plugins.get('converse-emoji'); + /* + * We mock the emojies API setup for this test. + * We inject a custom emoji and standard emojis. + */ + const json = { + "custom": { + ":custom_emoji:": { "sn": ":custom_emoji:", "url": "http://example.com/image.png", "c": "custom" } + }, + "smileys": { + ":smile:": { "sn": ":smile:", "cp": "1f604", "c": "smileys" } + } + }; + converse.emojis.json = json; + converse.emojis.by_sn = Object.keys(json).reduce((result, cat) => Object.assign(result, json[cat]), {}); + converse.emojis.list = Object.values(converse.emojis.by_sn); + converse.emojis.shortnames = converse.emojis.list.map((m) => m.sn); + }); + + beforeEach(function () { + _converse = converse.env._converse; + _converse.api.settings.set('emoji_categories', { + "smileys": "Smileys", + "custom": "Custom" + }); + }); + + it("filters out custom emojis when unicode_only is true", async function () { + const picker = document.createElement('converse-emoji-picker-content'); + picker.allowed_emojis = []; + picker.unicode_only = true; + picker.query = ""; + + // We need to mock the model as it's used in shouldBeHidden for skintone checks + picker.model = new _converse.Model({}); + + // Test Custom Emoji (no cp) + expect(picker.shouldBeHidden(":custom_emoji:")).toBe(true); + + // Test Standard Emoji (has cp) + expect(picker.shouldBeHidden(":smile:")).toBe(false); + }); + + it("shows custom emojis when unicode_only is false", async function () { + const picker = document.createElement('converse-emoji-picker-content'); + picker.allowed_emojis = []; + picker.unicode_only = false; + picker.query = ""; + picker.model = new _converse.Model({}); + + // Test Custom Emoji (no cp) + expect(picker.shouldBeHidden(":custom_emoji:")).toBe(false); + + // Test Standard Emoji (has cp) + expect(picker.shouldBeHidden(":smile:")).toBe(false); + }); +}); diff --git a/src/plugins/reactions/tests/reactions.js b/src/plugins/reactions/tests/reactions.js new file mode 100644 index 0000000000..b95888c025 --- /dev/null +++ b/src/plugins/reactions/tests/reactions.js @@ -0,0 +1,807 @@ +/*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'); + }) + ); + }); + + describe("XEP-0444 Discovery Support", function () { + + it("advertises urn:xmpp:reactions:0 in disco#info", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + + // Check that the feature is registered + const features = await api.disco.own.features.get(); + expect(features.includes('urn:xmpp:reactions:0')).toBe(true); + }) + ); + + it("hides reaction button if contact does not support reactions", + 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); + + // Mock disco info response without reactions support + const bare_jid = Strophe.getBareJidFromJid(contact_jid); + const entity = await api.disco.entities.get(bare_jid, true); + + // Simulate disco query response without reactions feature + const disco_stanza = u.toStanza(` + + + + + + + `); + + _converse.api.connection.get()._dataRecv(mock.createRequest(disco_stanza)); + await u.waitUntil(() => entity.features.length > 0); + + // Receive a message + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'test-no-support', + body: 'Test message', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + + // Reaction button should be hidden + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + expect(reaction_btn).toBe(null); + }) + ); + + it("shows reaction button if contact supports reactions", + 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); + + // Mock disco info response WITH reactions support + const bare_jid = Strophe.getBareJidFromJid(contact_jid); + const entity = await api.disco.entities.get(bare_jid, true); + + const disco_stanza = u.toStanza(` + + + + + + + `); + + _converse.api.connection.get()._dataRecv(mock.createRequest(disco_stanza)); + await u.waitUntil(() => entity.features.findWhere({'var': 'urn:xmpp:reactions:0'})); + + // Receive a message + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'test-with-support', + body: 'Test message', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = view.querySelector('.chat-msg'); + + // Reaction button should be visible + const reaction_btn = msg_el.querySelector('.chat-msg__action-reaction'); + expect(reaction_btn).not.toBe(null); + }) + ); + }); + + describe("Restricted Reactions", function () { + + it("parses restricted emoji set from disco#info", + 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'; + + // Simulate disco info with restricted reactions + const disco_stanza = u.toStanza(` + + + + + 👍 + ❤️ + 😂 + + + + `); + + _converse.api.connection.get()._dataRecv(mock.createRequest(disco_stanza)); + + // Wait for the stanza to be processed + await u.waitUntil(() => { + const plugin = _converse.pluggable.plugins['converse-reactions']; + const bare_jid = Strophe.getBareJidFromJid(contact_jid); + return plugin.allowed_emojis.has(bare_jid); + }, 1000); + + const plugin = _converse.pluggable.plugins['converse-reactions']; + const bare_jid = Strophe.getBareJidFromJid(contact_jid); + const allowed = plugin.allowed_emojis.get(bare_jid); + + expect(allowed).toBeDefined(); + expect(allowed.length).toBe(3); + expect(allowed).toContain('👍'); + expect(allowed).toContain('❤️'); + expect(allowed).toContain('😂'); + }) + ); + }); + + describe("Hybrid Update Strategy", function () { + + it("applies optimistic updates for 1:1 chats", + 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); + + // Receive a message to react to + const message = await mock.receiveMessage(_converse, { + from: contact_jid, + to: _converse.bare_jid, + msgid: 'optimistic-test', + body: 'React to this', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.findWhere({'msgid': 'optimistic-test'}); + + // Get the message element and click reaction button + 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(); + + // Check that the reaction appears immediately (optimistic update) + await u.waitUntil(() => { + const reactions = msg_model.get('reactions'); + return reactions && Object.keys(reactions).length > 0; + }, 500); // Short timeout - should be immediate + + const reactions = msg_model.get('reactions'); + expect(reactions).toBeDefined(); + expect(Object.keys(reactions).length).toBeGreaterThan(0); + + // Verify it's our own JID in the reaction + const emoji = Object.keys(reactions)[0]; + expect(reactions[emoji]).toContain(_converse.bare_jid); + }) + ); + + it("does NOT apply optimistic updates for MUCs", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 1); + + // Create a MUC + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + // Receive a message in the MUC + const message = await view.model.handleMessageStanza(u.toStanza(` + + MUC message to react to + + + `)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.at(0); + const original_msgid = msg_model.get('msgid'); + + // Spy on the model save to detect optimistic updates + const original_reactions = msg_model.get('reactions'); + spyOn(msg_model, 'save').and.callThrough(); + + // Trigger a reaction + const plugin = _converse.pluggable.plugins['converse-reactions']; + plugin.sendReaction(msg_model, '👍'); + + // Wait a bit to ensure no optimistic update occurred + await new Promise(resolve => setTimeout(resolve, 200)); + + // For MUC, the save should NOT have been called with reactions + // (optimistic update should be skipped) + const save_calls = msg_model.save.calls.all(); + const reaction_updates = save_calls.filter(call => + call.args[0] && call.args[0].reactions + ); + + // Should be 0 since we don't do optimistic updates for MUCs + expect(reaction_updates.length).toBe(0); + }) + ); + + it("toggles reactions correctly (add and remove)", + 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: 'toggle-test', + body: 'Toggle reaction test', + type: 'chat' + }); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_model = view.model.messages.findWhere({'msgid': 'toggle-test'}); + + const plugin = _converse.pluggable.plugins['converse-reactions']; + + // Add a reaction + plugin.sendReaction(msg_model, '❤️'); + await u.waitUntil(() => msg_model.get('reactions')?.['❤️']); + + expect(msg_model.get('reactions')['❤️']).toContain(_converse.bare_jid); + + // Toggle it off (remove) + plugin.sendReaction(msg_model, '❤️'); + await u.waitUntil(() => !msg_model.get('reactions')?.['❤️']); + + const reactions = msg_model.get('reactions'); + expect(reactions['❤️']).toBeFalsy(); // Should be removed + }) + ); + }); + + describe("updateMessageReactions Helper", function () { + + it("correctly updates message reactions", + 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: 'helper-test', + body: 'Helper test', + type: 'chat' + }); + + const msg_model = view.model.messages.findWhere({'msgid': 'helper-test'}); + const plugin = _converse.pluggable.plugins['converse-reactions']; + + // Add reaction using helper + plugin.updateMessageReactions(msg_model, contact_jid, ['👍', '❤️']); + + await u.waitUntil(() => msg_model.get('reactions')); + const reactions = msg_model.get('reactions'); + + expect(reactions['👍']).toContain(contact_jid); + expect(reactions['❤️']).toContain(contact_jid); + }) + ); + + it("replaces previous reactions when called 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-helper-test', + body: 'Replace test', + type: 'chat' + }); + + const msg_model = view.model.messages.findWhere({'msgid': 'replace-helper-test'}); + const plugin = _converse.pluggable.plugins['converse-reactions']; + + // Add initial reactions + plugin.updateMessageReactions(msg_model, contact_jid, ['👍']); + await u.waitUntil(() => msg_model.get('reactions')?.['👍']); + + // Replace with new reactions + plugin.updateMessageReactions(msg_model, contact_jid, ['❤️', '😂']); + await u.waitUntil(() => msg_model.get('reactions')?.['❤️']); + + const reactions = msg_model.get('reactions'); + expect(reactions['👍']).toBeFalsy(); // Old reaction removed + expect(reactions['❤️']).toContain(contact_jid); + expect(reactions['😂']).toContain(contact_jid); + }) + ); + + it("removes all reactions when given empty array", + 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: 'remove-all-test', + body: 'Remove all test', + type: 'chat' + }); + + const msg_model = view.model.messages.findWhere({'msgid': 'remove-all-test'}); + const plugin = _converse.pluggable.plugins['converse-reactions']; + + // Add reactions + plugin.updateMessageReactions(msg_model, contact_jid, ['👍', '❤️']); + await u.waitUntil(() => msg_model.get('reactions')?.['👍']); + + // Remove all by passing empty array + plugin.updateMessageReactions(msg_model, contact_jid, []); + await u.waitUntil(() => { + const reactions = msg_model.get('reactions'); + return !reactions || Object.keys(reactions).length === 0; + }); + + const reactions = msg_model.get('reactions'); + expect(reactions['👍']).toBeFalsy(); + expect(reactions['❤️']).toBeFalsy(); + }) + ); + }); +}); diff --git a/src/shared/chat/emoji-picker-content.js b/src/shared/chat/emoji-picker-content.js index 0f54740993..6d9390f647 100644 --- a/src/shared/chat/emoji-picker-content.js +++ b/src/shared/chat/emoji-picker-content.js @@ -17,6 +17,8 @@ export default class EmojiPickerContent extends CustomElement { 'current_skintone': { type: String }, 'model': { type: Object }, 'query': { type: String }, + 'allowed_emojis': { type: Array }, + 'filter': { type: Function }, }; } @@ -26,6 +28,8 @@ export default class EmojiPickerContent extends CustomElement { this.current_skintone = null; this.query = null; this.search_results = null; + this.allowed_emojis = null; + this.filter = null; } render () { @@ -96,8 +100,19 @@ export default class EmojiPickerContent extends CustomElement { */ shouldBeHidden (shortname) { // Helper method for the template which decides whether an - // emoji should be hidden, based on which skin tone is - // currently being applied. + // emoji should be hidden, based on allowed emojis, custom filters, + // the current skin tone and search queries. + if (this.allowed_emojis && this.allowed_emojis.length > 0) { + const unicode = u.shortnamesToEmojis(shortname); + if (!this.allowed_emojis.includes(unicode)) { + return true; + } + } + + if (this.filter && !this.filter(shortname)) { + return true; + } + if (shortname.includes('_tone')) { if (!this.current_skintone || !shortname.includes(this.current_skintone)) { return true; diff --git a/src/shared/chat/emoji-picker.js b/src/shared/chat/emoji-picker.js index e3909960b9..74f4cc8f3f 100644 --- a/src/shared/chat/emoji-picker.js +++ b/src/shared/chat/emoji-picker.js @@ -27,6 +27,8 @@ export default class EmojiPicker extends CustomElement { state: { type: Object }, // This is an optimization to lazily render the emoji picker render_emojis: { type: Boolean }, + allowed_emojis: { type: Array }, + filter: { type: Function }, }; } @@ -37,16 +39,20 @@ export default class EmojiPicker extends CustomElement { this.query = ""; this.render_emojis = null; this._search_results = []; + this.filter = null; + this.current_category = ""; + this.current_skintone = ""; this.debouncedFilter = debounce( /** @param {HTMLInputElement} input */ (input) => this.state.set({ "query": input.value }), 250 ); + this.allowed_emojis = undefined; } initialize() { super.initialize(); - this.dropdown = this.closest("converse-emoji-dropdown"); + this.dropdown = this.closest("converse-emoji-dropdown") || this.closest(".dropdown"); } firstUpdated(changed) { @@ -75,7 +81,8 @@ export default class EmojiPicker extends CustomElement { query: this.query, search_results: this.search_results, render_emojis: this.render_emojis, - sn2Emoji: /** @param {string} sn */ (sn) => u.shortnamesToEmojis(this.getTonedShortname(sn)), + filter: this.filter, + sn2Emoji: /** @param {string} sn */ (sn) => u.shortnamesToEmojis(sn), }); } @@ -122,7 +129,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/emoji-picker.js b/src/shared/chat/templates/emoji-picker.js index 741b8bf31e..954956d1cc 100644 --- a/src/shared/chat/templates/emoji-picker.js +++ b/src/shared/chat/templates/emoji-picker.js @@ -98,6 +98,8 @@ export function tplEmojiPicker (el, o) { html`` : ''} 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' ]; /** diff --git a/src/types/plugins/reactions/reaction-picker.d.ts b/src/types/plugins/reactions/reaction-picker.d.ts new file mode 100644 index 0000000000..2c1e4cd8d4 --- /dev/null +++ b/src/types/plugins/reactions/reaction-picker.d.ts @@ -0,0 +1,54 @@ +import { CustomElement } from '../../../shared/components/element.js'; +import { EmojiPicker } from '@converse/headless'; + +/** + * 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(): any; + target: any; + model: any; + emoji_picker_state: EmojiPicker; + picker_id: string; + allowed_emojis: any; + /** + * 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(): any; + /** + * Initialize the full emoji picker (lazy-loaded) + * Only loads emoji data when user opens the dropdown + * This improves initial performance + * + * @async + * @returns {Promise} + */ + initEmojiPicker(): Promise; + /** + * 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: string): void; +} + + +//# sourceMappingURL=reaction-picker.d.ts.map \ No newline at end of file diff --git a/src/types/shared/chat/emoji-picker-content.d.ts b/src/types/shared/chat/emoji-picker-content.d.ts index 05255959f2..832001934e 100644 --- a/src/types/shared/chat/emoji-picker-content.d.ts +++ b/src/types/shared/chat/emoji-picker-content.d.ts @@ -12,11 +12,19 @@ export default class EmojiPickerContent extends CustomElement { query: { type: StringConstructor; }; + allowed_emojis: { + type: ArrayConstructor; + }; + filter: { + type: FunctionConstructor; + }; }; model: any; current_skintone: any; query: any; search_results: any; + allowed_emojis: any; + filter: any; render(): import("lit-html").TemplateResult<1>; firstUpdated(): void; initIntersectionObserver(): void; diff --git a/src/types/shared/chat/emoji-picker.d.ts b/src/types/shared/chat/emoji-picker.d.ts index 380c79f8a7..6e1729a51d 100644 --- a/src/types/shared/chat/emoji-picker.d.ts +++ b/src/types/shared/chat/emoji-picker.d.ts @@ -21,13 +21,23 @@ export default class EmojiPicker extends CustomElement { render_emojis: { type: BooleanConstructor; }; + allowed_emojis: { + type: ArrayConstructor; + }; + filter: { + type: FunctionConstructor; + }; }; state: any; model: any; query: string; render_emojis: any; _search_results: any[]; + filter: any; + current_category: string; + current_skintone: string; debouncedFilter: import("lodash").DebouncedFunc<(input: HTMLInputElement) => any>; + allowed_emojis: any; initialize(): void; dropdown: Element; firstUpdated(changed: any): void; @@ -36,8 +46,6 @@ export default class EmojiPicker extends CustomElement { render(): import("lit-html").TemplateResult<1>; updated(changed: any): void; onModelChanged(changed: any): void; - current_category: any; - current_skintone: any; setScrollPosition(): void; preserve_scroll: boolean; updateSearchResults(changed: any): any[];