diff --git a/Makefile b/Makefile index 3fe0dbdb32..50aff5c1f3 100644 --- a/Makefile +++ b/Makefile @@ -233,17 +233,7 @@ eslint: node_modules check: eslint | src/headless/dist/converse-headless.js dist/converse.js dist/converse.css npm run types make check-git-clean - cd src/log && npm test - cd src/headless && npm run test -- --single-run - npm run test -- --single-run - -.PHONY: test-headless -test-headless: - cd src/headless && npm run test -- $(ARGS) - -.PHONY: test -test: - npm run test -- $(ARGS) + npm run test:all ######################################################################## ## Documentation diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 4cb98d6e67..25b88c5d7f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -1697,13 +1697,13 @@ You can set the URL where the sound files are hosted with the `sounds_path`_ opt Requires the `src/converse-notification.js` plugin. -popular_reactions ------------------ +popular_emojis +-------------- * Default: ``[':thumbsup:', ':heart:', ':joy:', ':open_mouth:']`` An array of emoji shortnames that are displayed as quick-access reaction buttons -in the message reaction picker (see `XEP-0444: Message Reactions `_). +in the emoji and message reaction pickers (see `XEP-0444: Message Reactions `_). These emojis are shown as the first row of buttons in the reaction picker, allowing users to quickly react to messages without opening the full emoji selector. diff --git a/karma.conf.js b/karma.conf.js index cac3c2498a..e4753e5bf8 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -44,6 +44,9 @@ module.exports = function(config) { { pattern: "src/shared/tests/mock.js", type: 'module' }, { pattern: "src/headless/tests/mock.js", type: 'module' }, + // Run earlier, otherwise it fails (times out) with the message avatar not updating for some reason + { pattern: "src/plugins/chatview/tests/message-avatar.js", type: 'module' }, + // Ideally this should go into the headless test runner { pattern: "src/headless/plugins/vcard/tests/update.js", type: 'module' }, @@ -124,7 +127,7 @@ module.exports = function(config) { // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: ['Chrome'], + browsers: ['ChromeHeadless'], // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits diff --git a/package-lock.json b/package-lock.json index 184dcfe83c..c13e7923f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9002,11 +9002,29 @@ "version": "0.0.1", "license": "MPL-2.0", "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.5.4" }, "engines": { "node": ">=16.0.0" } + }, + "src/log/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "src/log/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 5192b1092b..6f723c2316 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,11 @@ "nodeps": "rspack build --config rspack/rspack.nodeps.js", "serve": "http-server -c-1", "serve-tls": "http-server -S -C certs/chat.example.org.crt -K certs/chat.example.org.key", - "test": "karma start karma.conf", - "test:all": "npm run test:headless -- --single-run && npm run test -- --single-run", - "test:headless": "cd src/headless && karma start karma.conf.js", + "test": "karma start karma.conf --single-run", + "test:browser": "karma start karma.conf --browsers Chrome", + "test:all": "npm run test:headless && npm run test", + "test:headless": "cd src/headless && karma start karma.conf.js --single-run", + "test:headless:browser": "cd src/headless && karma start karma.conf.js --browsers Chrome", "types": "tsc -p ./src/log/tsconfig.json && tsc -p ./src/headless/tsconfig.json && tsc", "types:check": "tsc --noEmit", "watch": "concurrently \"npm run watch:headless\" \"npm run watch:main\"", diff --git a/src/headless/karma.conf.js b/src/headless/karma.conf.js index 064e475eef..dd6ac30cb5 100644 --- a/src/headless/karma.conf.js +++ b/src/headless/karma.conf.js @@ -14,20 +14,21 @@ module.exports = function (config) { { pattern: 'dist/*.js.map', included: false }, 'dist/converse-headless.js', { pattern: 'tests/*.js', type: 'module' }, - { pattern: 'shared/settings/tests/settings.js', type: 'module' }, { pattern: 'plugins/blocklist/tests/*.js', type: 'module' }, - { pattern: 'plugins/caps/tests/*.js', type: 'module' }, { pattern: 'plugins/bookmarks/tests/*.js', type: 'module' }, + { pattern: 'plugins/caps/tests/*.js', type: 'module' }, { pattern: 'plugins/chat/tests/*.js', type: 'module' }, { pattern: 'plugins/disco/tests/*.js', type: 'module' }, + { pattern: 'plugins/emoji/tests/*.js', type: 'module' }, { pattern: 'plugins/mam/tests/*.js', type: 'module' }, { pattern: 'plugins/muc/tests/*.js', type: 'module' }, { pattern: 'plugins/ping/tests/*.js', type: 'module' }, { pattern: 'plugins/pubsub/tests/*.js', type: 'module' }, + { pattern: 'plugins/reactions/tests/*.js', type: 'module' }, { pattern: 'plugins/roster/tests/*.js', type: 'module' }, { pattern: 'plugins/smacks/tests/*.js', type: 'module' }, { pattern: 'plugins/status/tests/*.js', type: 'module' }, - { pattern: 'plugins/reactions/tests/*.js', type: 'module' }, + { pattern: 'shared/settings/tests/settings.js', type: 'module' }, ], proxies: { '/dist/': '/base/dist/', @@ -43,7 +44,7 @@ module.exports = function (config) { colors: true, logLevel: config.LOG_INFO, autoWatch: true, - browsers: ['Chrome'], + browsers: ['ChromeHeadless'], singleRun: false, concurrency: Infinity, }); diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index b53a244647..3630761756 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -233,7 +233,7 @@ class ChatBox extends ModelWithVCard(ModelWithMessages(ModelWithContact(ColorAwa const is_spoiler = !!this.get('composing_spoiler'); const origin_id = u.getUniqueId(); const text = attrs?.body; - const body = text ? u.shortnamesToUnicode(text) : undefined; + const body = text ? u.emojis.shortnamesToUnicode(text) : undefined; // Get reply attributes from chatbox model if replying to a message const reply_to_id = this.get('reply_to_id'); diff --git a/src/headless/plugins/emoji/constants.js b/src/headless/plugins/emoji/constants.js new file mode 100644 index 0000000000..9f54b76e95 --- /dev/null +++ b/src/headless/plugins/emoji/constants.js @@ -0,0 +1,5 @@ + +// Regex to detect shortname keys (e.g. ':thumbsup:') +export const SHORTNAME_RE = /^:[a-zA-Z0-9_+*-]+:$/; +// How long to debounce/wait before publishing popular emojis +export const PUBLISH_DEBOUNCE_MILLIS = 30_000; diff --git a/src/headless/plugins/emoji/emoji.json b/src/headless/plugins/emoji/emoji.json index d37d0ba3cc..570aeffdca 100644 --- a/src/headless/plugins/emoji/emoji.json +++ b/src/headless/plugins/emoji/emoji.json @@ -2233,7 +2233,7 @@ ":grey_exclamation:":{"sn":":grey_exclamation:","cp":"2755","sns":[],"c":"symbols"}, ":grey_question:":{"sn":":grey_question:","cp":"2754","sns":[],"c":"symbols"}, ":hash:":{"sn":":hash:","cp":"23-fe0f-20e3","sns":[],"c":"symbols"}, -":heart:":{"sn":":heart:","cp":"2764","sns":[],"c":"symbols"}, +":heart:":{"sn":":heart:","cp":"2764-fe0f","sns":[],"c":"symbols"}, ":heart_decoration:":{"sn":":heart_decoration:","cp":"1f49f","sns":[],"c":"symbols"}, ":heart_exclamation:":{"sn":":heart_exclamation:","cp":"2763","sns":[":heavy_heart_exclamation_mark_ornament:"],"c":"symbols"}, ":heartbeat:":{"sn":":heartbeat:","cp":"1f493","sns":[],"c":"symbols"}, diff --git a/src/headless/plugins/emoji/handlers.js b/src/headless/plugins/emoji/handlers.js new file mode 100644 index 0000000000..72356c51d8 --- /dev/null +++ b/src/headless/plugins/emoji/handlers.js @@ -0,0 +1,59 @@ +import api from '../../shared/api/index.js'; +import converse from '../../shared/api/public.js'; +import _converse from '../../shared/_converse.js'; +import { getCodePointReferences, getShortnameReferences, isOnlyEmojis } from './utils.js'; + +const { Strophe } = converse.env; + +export function registerPEPPushHandler() { + const bare_jid = _converse.session.get('bare_jid'); + api.connection.get().addHandler( + /** @param {Element} stanza */ + (stanza) => { + const { popular_emojis } = _converse.state; + popular_emojis?.applyPopularEmojisFromStanza(stanza); + return true; + }, + Strophe.NS.REACTIONS_POPULAR, + 'message', + 'headline', + null, + bare_jid, + ); +} + +/** + * Given a message, extract emojis from it and use them to update the list of + * popular emojis. + * @param {import('../../plugins/reactions/utils').BaseMessage} message + */ +export async function updatePopularEmojis(message) { + const body = message.get('body'); + if (!body) return; + + await api.emojis.initialize(); + + const { popular_emojis } = _converse.state; + if (!popular_emojis) return; + + const shortname_refs = new Set(getShortnameReferences(body).map(({ shortname }) => shortname)); + const cp_refs = new Set(getCodePointReferences(body).map(({ emoji }) => emoji)); + const emojis = [...shortname_refs, ...cp_refs]; + + if (emojis.length) { + popular_emojis.recordUsage(emojis); + } +} + +/** + * @param {import('../../shared/types').MessageAttributes} attrs + * @param {String} text + * @returns {Promise} + */ +export async function parseMessage(attrs, text) { + await api.emojis.initialize(); + return { + ...attrs, + is_only_emojis: text ? isOnlyEmojis(text) : false, + }; +} diff --git a/src/headless/plugins/emoji/plugin.js b/src/headless/plugins/emoji/plugin.js index 357670df91..08f9ca8e35 100644 --- a/src/headless/plugins/emoji/plugin.js +++ b/src/headless/plugins/emoji/plugin.js @@ -9,7 +9,12 @@ import api from '../../shared/api/index.js'; import converse from '../../shared/api/public.js'; import EmojiPicker from './picker.js'; import emojis from './api.js'; -import { isOnlyEmojis } from './utils.js'; +import { parseMessage, registerPEPPushHandler, updatePopularEmojis } from './handlers.js'; +import PopularEmojis from './popular-emojis.js'; + +const { Strophe } = converse.env; + +Strophe.addNamespace('REACTIONS_POPULAR', 'urn:xmpp:reactions:popular:0'); converse.emojis = { initialized: false, @@ -17,43 +22,46 @@ converse.emojis = { }; converse.plugins.add('converse-emoji', { - initialize () { + initialize() { /* The initialize function gets called as soon as the plugin is * loaded by converse.js's plugin machinery. */ const { ___ } = _converse; api.settings.extend({ - 'emoji_image_path': 'https://twemoji.maxcdn.com/v/12.1.6/', - 'emoji_categories': { - 'smileys': ':grinning:', - 'people': ':thumbsup:', - 'activity': ':soccer:', - 'travel': ':motorcycle:', - 'objects': ':bomb:', - 'nature': ':rainbow:', - 'food': ':hotdog:', - 'symbols': ':musical_note:', - 'flags': ':flag_ac:', - 'custom': null, + emoji_image_path: 'https://twemoji.maxcdn.com/v/12.1.6/', + emoji_categories: { + popular: ':star:', + smileys: ':grinning:', + people: ':thumbsup:', + activity: ':soccer:', + travel: ':motorcycle:', + objects: ':bomb:', + nature: ':rainbow:', + food: ':hotdog:', + symbols: ':musical_note:', + flags: ':flag_ac:', + custom: null, }, + popular_emojis: [':thumbsup:', ':heart:', ':laughing:', ':joy:', ':tada:'], // We use the triple-underscore method which doesn't actually // translate but does signify to gettext that these strings should // go into the POT file. The translation then happens in the // template. We do this so that users can pass in their own // strings via converse.initialize, which is before __ is // available. - 'emoji_category_labels': { - 'smileys': ___('Smileys and emotions'), - 'people': ___('People'), - 'activity': ___('Activities'), - 'travel': ___('Travel'), - 'objects': ___('Objects'), - 'nature': ___('Animals and nature'), - 'food': ___('Food and drink'), - 'symbols': ___('Symbols'), - 'flags': ___('Flags'), - 'custom': ___('Stickers'), + emoji_category_labels: { + popular: ___('Frequently used'), + smileys: ___('Smileys and emotions'), + people: ___('People'), + activity: ___('Activities'), + travel: ___('Travel'), + objects: ___('Objects'), + nature: ___('Animals and nature'), + food: ___('Food and drink'), + symbols: ___('Symbols'), + flags: ___('Flags'), + custom: ___('Stickers'), }, }); @@ -62,24 +70,22 @@ converse.plugins.add('converse-emoji', { Object.assign(_converse.exports, exports); Object.assign(api, emojis); - api.listen.on('getOutgoingMessageAttributes', async (_chat, attrs) => { - await api.emojis.initialize(); - const { original_text: text } = attrs; - return { - ...attrs, - is_only_emojis: text ? isOnlyEmojis(text) : false, - }; + api.listen.on('connected', () => { + registerPEPPushHandler(); + if (_converse.state.popular_emojis) return; + + const popular_emojis = new PopularEmojis(); + Object.assign(_converse.state, { popular_emojis }); }); - async function parseMessage (_stanza, attrs) { - await api.emojis.initialize(); - return { - ...attrs, - is_only_emojis: attrs.body ? isOnlyEmojis(attrs.body) : false - } - } + api.listen.on('clearSession', () => { + _converse.state.popular_emojis?.debouncedPublish.flush(); + delete _converse.state.popular_emojis; + }); - api.listen.on('parseMUCMessage', parseMessage); - api.listen.on('parseMessage', parseMessage); + api.listen.on('getOutgoingMessageAttributes', async (_chat, attrs) => parseMessage(attrs, attrs.original_text)); + api.listen.on('parseMUCMessage', (_chat, attrs) => parseMessage(attrs, attrs.body)); + api.listen.on('parseMessage', (_chat, attrs) => parseMessage(attrs, attrs.body)); + api.listen.on('sendMessage', async ({ message }) => updatePopularEmojis(message)); }, }); diff --git a/src/headless/plugins/emoji/popular-emojis.js b/src/headless/plugins/emoji/popular-emojis.js new file mode 100644 index 0000000000..67d2347c90 --- /dev/null +++ b/src/headless/plugins/emoji/popular-emojis.js @@ -0,0 +1,219 @@ +import log from '@converse/log'; +import { getOpenPromise } from '@converse/openpromise'; +import { Model } from '@converse/skeletor'; +import api from '../../shared/api/index.js'; +import converse from '../../shared/api/public.js'; +import _converse from '../../shared/_converse.js'; +import { debounce } from '../../utils/promise.js'; +import { emojiToCodepointKey } from './utils.js'; +import { PUBLISH_DEBOUNCE_MILLIS, SHORTNAME_RE } from './constants.js'; + +const { Strophe, sizzle, stx, u } = converse.env; + +class PopularEmojis extends Model { + defaults() { + return { + // Format: { 'unicode_emoji': 'ISO8601-timestamp', ... } + timestamps: {}, + }; + } + + initialize() { + super.initialize(); + const { session } = _converse; + const storage_key = `converse.popular_emojis_frequencies.${session.get('bare_jid')}`; + const fetched_flag_key = `${storage_key}-fetched`; + + this.fetched_flag = fetched_flag_key; + this.debouncedPublish = debounce(() => this.publish(), PUBLISH_DEBOUNCE_MILLIS); + u.initStorage(this, storage_key); + this.fetchPopularEmojis(); + } + + async fetchPopularEmojis() { + if (_converse.state.session.get(this.fetched_flag)) { + const deferred = getOpenPromise(); + this.fetch({ + success: () => deferred.resolve(), + error: () => deferred.resolve(), + }); + return deferred; + } else { + await this.fetchPopularEmojisFromServer(); + } + } + + /** + * Fetch the user's stored popular emojis from their PEP node and apply them. + * If no item is stored, the default `popular_emojis` setting is left unchanged. + */ + async fetchPopularEmojisFromServer() { + const bare_jid = _converse.state.session.get('bare_jid'); + let iq; + try { + iq = await api.sendIQ( + stx` + + + + `, + ); + this.applyPopularEmojisFromStanza(iq); + } catch (e) { + // item-not-found is expected when the user has never saved a custom list + if (e?.querySelector?.('item-not-found')) return; + log.warn('fetchPopularEmojisFromServer: could not fetch popular emojis from PubSub'); + log.error(e); + return; + } + } + + /** + * Parse a list of unicode emoji from a popular-reactions PubSub item and + * merge the received timestamps with local ones, keeping the most recent + * timestamp per emoji (last-write-wins per slot). + * + * @param {Element} stanza - An IQ result or headline message containing the pubsub item + */ + applyPopularEmojisFromStanza(stanza) { + const item = sizzle(`items[node="${Strophe.NS.REACTIONS_POPULAR}"] item`, stanza).pop(); + if (!item) return; + + const popular_el = item.getElementsByTagNameNS(Strophe.NS.REACTIONS_POPULAR, 'popular-reactions')[0]; + if (!popular_el) return; + + const reactions = Array.from(popular_el.querySelectorAll('reaction')) + .map((el) => ({ emoji: el.textContent?.trim(), stamp: el.getAttribute('stamp') })) + .filter(({ emoji, stamp }) => emoji && stamp); + + if (!reactions.length) return; + + const local_timestamps = { ...(this.get('timestamps') || {}) }; + + for (const { emoji, stamp } of reactions) { + const remote_ms = new Date(stamp).getTime(); + if (isNaN(remote_ms)) continue; + + // Normalise to UTC ISO string so stored values are always comparable + // as strings, regardless of the timezone used by the sending device. + const normalised_stamp = new Date(remote_ms).toISOString(); + + const local_stamp = local_timestamps[emoji]; + const local_ms = local_stamp ? new Date(local_stamp).getTime() : 0; + + // Keep whichever timestamp is more recent (last-write-wins per emoji) + if (remote_ms > local_ms) { + local_timestamps[emoji] = normalised_stamp; + } + } + + this.save({ timestamps: local_timestamps }); + } + + /** + * Record that an emoji was just used, setting its timestamp to now. + * Accepts unicode emoji or shortnames. Shortnames are converted to unicode + * before storage to prevent duplicates. Unicode is stored as-is (preserving + * variation selectors like U+FE0F). + * + * @param {string[]} emojis - An array of unicode emojis and/or shortnames + */ + recordUsage(emojis) { + const timestamps = { ...(this.get('timestamps') || {}) }; + const by_sn = converse.emojis.by_sn || {}; + + emojis.forEach((emoji) => { + let unicode; + if (SHORTNAME_RE.test(emoji)) { + const emoji_data = by_sn[emoji]; + if (emoji_data?.cp) { + unicode = u.emojis.convert(emoji_data.cp); + } else { + return; + } + } else { + unicode = emoji; + } + timestamps[unicode] = new Date().toISOString(); + }); + this.save({ timestamps }); + this.debouncedPublish(); + } + + /** + * Get emojis sorted by most recently used first. + * @param {number} [maxLength=5] - Maximum number of emojis to return + * @returns {string[]} - Array of unicode emoji keys sorted by timestamp descending + */ + getSortedEmojis(maxLength = 5) { + const timestamps = this.get('timestamps') || {}; + return Object.entries(timestamps) + .sort((a, b) => new Date(b[1]).getTime() - new Date(a[1]).getTime() || a[0].localeCompare(b[0])) + .map((entry) => entry[0]) + .slice(0, maxLength); + } + + /** + * @returns {Promise} Map from unicode emoji to data + */ + async getPopularEmojis() { + await api.emojis.initialize(); + + const result = /** @type{import('./types').EmojiDataByUnicode} */ ({}); + + const default_setting = api.settings.get('popular_emojis') ?? []; + const max = default_setting.length || 5; + const sorted = this.getSortedEmojis(max); + const by_cp = u.emojis.getEmojisByAttribute('cp'); + + for (const key of sorted) { + const emoji_data = by_cp[emojiToCodepointKey(key)]; + if (!emoji_data) continue; + + result[key] = emoji_data; + } + + if (Object.keys(result).length < max) { + const by_sn = converse.emojis.by_sn || {}; + for (const sn of default_setting) { + if (Object.keys(result).length >= max) break; + const data = by_sn[sn]; + const unicode = data?.cp ? u.emojis.convert(data.cp) : null; + if (unicode && !result[unicode]) { + const emoji_data = by_cp[emojiToCodepointKey(unicode)]; + if (emoji_data) result[unicode] = emoji_data; + } + } + } + + return result; + } + + async publish() { + await api.emojis.initialize(); + + const default_setting = api.settings.get('popular_emojis') ?? []; + const max = default_setting.length || 5; + const sorted = this.getSortedEmojis(max); + const timestamps = this.get('timestamps') || {}; + + const item = stx` + + + ${sorted.map((e) => stx`${e}`)} + + `; + + try { + await api.pubsub.publish(null, Strophe.NS.REACTIONS_POPULAR, item, { + persist_items: 'true', + access_model: 'whitelist', + }); + } catch (e) { + log.warn('PopularEmojis#publish: failed to update popular emojis'); + log.error(e); + } + } +} + +export default PopularEmojis; diff --git a/src/headless/plugins/emoji/tests/popular-emojis.js b/src/headless/plugins/emoji/tests/popular-emojis.js new file mode 100644 index 0000000000..b5c8b9de7d --- /dev/null +++ b/src/headless/plugins/emoji/tests/popular-emojis.js @@ -0,0 +1,360 @@ +/* global converse */ +import mock from '../../../tests/mock.js'; + +const { Strophe, sizzle, stx, u } = converse.env; + +describe('Popular Emojis', function () { + beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); + + describe('PopularEmojis Model', function () { + it( + 'records usage timestamps correctly', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + expect(popular_emojis).toBeDefined(); + expect(Object.keys(popular_emojis.get('timestamps'))).toEqual([]); + + const before = new Date().toISOString(); + // Unicode input is stored as-is (preserving variation selectors) + popular_emojis.recordUsage(['👍', '❤️', '🎉']); + const after = new Date().toISOString(); + + const timestamps = popular_emojis.get('timestamps'); + expect(timestamps['👍']).toBeDefined(); + expect(timestamps['❤️']).toBeDefined(); + expect(timestamps['🎉']).toBeDefined(); + + // All timestamps should be within the test window + for (const emoji of ['👍', '❤️', '🎉']) { + expect(timestamps[emoji] >= before).toBeTrue(); + expect(timestamps[emoji] <= after).toBeTrue(); + } + }), + ); + + it( + 'records shortname usage as unicode', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + + // Shortname input is converted to unicode before storage + popular_emojis.recordUsage([':thumbsup:', ':heart:', ':tada:']); + + const timestamps = popular_emojis.get('timestamps'); + expect(timestamps['👍']).toBeDefined(); + expect(timestamps['❤️']).toBeDefined(); + expect(timestamps['🎉']).toBeDefined(); + expect(timestamps[':thumbsup:']).toBeUndefined(); + expect(timestamps[':heart:']).toBeUndefined(); + expect(timestamps[':tada:']).toBeUndefined(); + }), + ); + + it( + 'returns emojis sorted by most recently used first', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + + // Set timestamps directly to control ordering (unicode keys) + popular_emojis.save({ + timestamps: { + '😂': '2026-03-29T10:00:00.000Z', + '❤️': '2026-03-29T12:00:00.000Z', + '👍': '2026-03-29T11:00:00.000Z', + '😮': '2026-03-29T09:00:00.000Z', + }, + }); + + // Most recent first + const sorted = popular_emojis.getSortedEmojis(); + expect(sorted).toEqual(['❤️', '👍', '😂', '😮']); + + // Respects maxLength + const limited = popular_emojis.getSortedEmojis(2); + expect(limited).toEqual(['❤️', '👍']); + }), + ); + + it( + 'overwrites the previous timestamp when an emoji is used again', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.save({ + timestamps: { + '❤️': '2026-03-29T10:00:00.000Z', + '👍': '2026-03-29T12:00:00.000Z', + }, + }); + + // Using the unicode form directly — should update the '❤️' key + popular_emojis.recordUsage(['❤️']); + + const sorted = popular_emojis.getSortedEmojis(); + expect(sorted[0]).toBe('❤️'); + }), + ); + }); + + describe('PubSub Storage and Retrieval', function () { + it( + 'fetches popular reactions from PEP node on connect', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 0); + const own_jid = _converse.session.get('jid'); + const bare_jid = Strophe.getBareJidFromJid(own_jid); + const sent_stanzas = api.connection.get().sent_stanzas; + const sent_stanza = await u.waitUntil(() => + sent_stanzas + .filter((iq) => sizzle(`pubsub items[node="${Strophe.NS.REACTIONS_POPULAR}"]`, iq).length) + .pop(), + ); + expect(sent_stanza).toEqualStanza(stx` + + + + + + `); + + const returned_stanza = stx` + + + + + + 👍 + ❤️ + 🎉 + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(returned_stanza)); + + const { popular_emojis } = _converse.state; + await u.waitUntil(() => Object.keys(popular_emojis.get('timestamps')).length); + const timestamps = popular_emojis.get('timestamps'); + // Stanza unicode is stored directly as the key + expect(timestamps['👍']).toBe('2026-03-29T12:00:00.000Z'); + expect(timestamps['❤️']).toBe('2026-03-29T11:00:00.000Z'); + expect(timestamps['🎉']).toBe('2026-03-29T10:00:00.000Z'); + + // Verify sorted order: most recent first + const sorted = popular_emojis.getSortedEmojis(); + expect(sorted).toEqual(['👍', '❤️', '🎉']); + }), + ); + }); + + describe('Cross-device Synchronization', function () { + it( + 'merges incoming PEP timestamps with local ones, keeping the most recent per emoji', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const { popular_emojis } = _converse.state; + + // Simulate local usage with unicode keys + popular_emojis.save({ + timestamps: { + '👍': '2026-03-29T13:00:00.000Z', // local is newer + '🎉': '2026-03-29T09:00:00.000Z', // local is older + }, + }); + + const bare_jid = _converse.session.get('bare_jid'); + + // Incoming PEP event from another device: + // ❤️ is new (not local), 👍 is older than local, 🎉 is newer than local + const stanza = stx` + + + + + + 🎉 + 👍 + ❤️ + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => popular_emojis.get('timestamps')['❤️']); + + const timestamps = popular_emojis.get('timestamps'); + + // 👍 — local (13:00) is newer than remote (11:00), keep local + expect(timestamps['👍']).toBe('2026-03-29T13:00:00.000Z'); + + // 🎉 — remote (14:00) is newer than local (09:00), use remote + expect(timestamps['🎉']).toBe('2026-03-29T14:00:00.000Z'); + + // ❤️ — only on remote, should be added + expect(timestamps['❤️']).toBe('2026-03-29T12:00:00.000Z'); + + // Sorted order: 🎉 (14:00), 👍 (13:00 local), ❤️ (12:00) + const sorted = popular_emojis.getSortedEmojis(); + expect(sorted).toEqual(['🎉', '👍', '❤️']); + }), + ); + + it( + 'does not overwrite a newer local timestamp with an older remote one', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const { popular_emojis } = _converse.state; + + // Local has a very recent usage + popular_emojis.save({ + timestamps: { + '👍': '2026-03-29T15:00:00.000Z', + }, + }); + + const bare_jid = _converse.session.get('bare_jid'); + + // Remote has an older timestamp for the same emoji + const stanza = stx` + + + + + + 👍 + + + + + + `; + _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); + + // Give it a moment to process + await u.waitUntil(() => true); + + const timestamps = popular_emojis.get('timestamps'); + // Local timestamp must be preserved + expect(timestamps['👍']).toBe('2026-03-29T15:00:00.000Z'); + }), + ); + it( + 'preserves skin-tone modifiers in unicode keys', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.recordUsage(['👍🏽']); + + // The skin-tone modifier must be preserved in the stored key + const timestamps = popular_emojis.get('timestamps'); + expect(timestamps['👍🏽']).toBeDefined(); + expect(timestamps['👍']).toBeUndefined(); + + // getPopularEmojis must return the skin-tone emoji as a distinct entry + const result = await popular_emojis.getPopularEmojis(); + expect(result['👍🏽']).toBeDefined(); + expect(result['👍🏽'].sn).toBe(':thumbsup_tone3:'); + }), + ); + }); + + describe('getPopularEmojis', function () { + it( + 'returns emoji data keyed by the stored unicode, preserving variation selectors', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.save({ + timestamps: { + '👍': '2026-03-29T12:00:00.000Z', + '❤️': '2026-03-29T11:00:00.000Z', + }, + }); + + const result = await popular_emojis.getPopularEmojis(); + // Keyed by the stored unicode key, preserving variation selectors + expect(result['👍']).toBeDefined(); + expect(result['👍'].sn).toBe(':thumbsup:'); + expect(result['❤️']).toBeDefined(); + expect(result['❤️'].sn).toBe(':heart:'); + // Padded with default popular_emojis (length 5) — the two stored + // emojis overlap with :thumbsup: and :heart: defaults, leaving room + // for the 3 remaining defaults. + expect(Object.keys(result).length).toBe(5); + }), + ); + + it( + 'respects the popular_emojis setting length', + mock.initConverse( + ['chatBoxesFetched'], + { 'popular_emojis': [':thumbsup:', ':heart:'] }, + async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.save({ + timestamps: { + '😂': '2026-03-29T14:00:00.000Z', + '❤️': '2026-03-29T13:00:00.000Z', + '👍': '2026-03-29T12:00:00.000Z', + '🎉': '2026-03-29T11:00:00.000Z', + }, + }); + + const result = await popular_emojis.getPopularEmojis(); + // Setting length is 2, so only the 2 most recent should be returned + expect(Object.keys(result)).toEqual(['😂', '❤️']); + }, + ), + ); + + it( + 'filters out emoji not found in emoji data', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.save({ + timestamps: { + '👍': '2026-03-29T12:00:00.000Z', + '🫠': '2026-03-29T11:00:00.000Z', + }, + }); + + const result = await popular_emojis.getPopularEmojis(); + expect(result['👍']).toBeDefined(); + expect(result['🫠']).toBeUndefined(); + }), + ); + }); +}); diff --git a/src/headless/plugins/emoji/types.ts b/src/headless/plugins/emoji/types.ts new file mode 100644 index 0000000000..61b2541f89 --- /dev/null +++ b/src/headless/plugins/emoji/types.ts @@ -0,0 +1,17 @@ +export type EmojiData = { + sn: string; + cp: string; + sns: string; + c: string; + url?: string; // for custom emojis +}; + +export type EmojiDataByUnicode = Record; + +export type EmojiReference = { + cp: string; + begin: number; + end: number; + shortname: string; + emoji: string | null +}; diff --git a/src/headless/plugins/emoji/utils.js b/src/headless/plugins/emoji/utils.js index 485b3fa3b0..d9f0416bb5 100644 --- a/src/headless/plugins/emoji/utils.js +++ b/src/headless/plugins/emoji/utils.js @@ -1,27 +1,130 @@ import { ASCII_REPLACE_REGEX, CODEPOINTS_REGEX } from './regexes.js'; import { unescapeHTML } from '../../utils/html.js'; import converse from '../../shared/api/public.js'; +import _converse from '../../shared/_converse.js'; +import { SHORTNAME_RE } from './constants.js'; const { u } = converse.env; // Closured cache const emojis_by_attribute = {}; - const ASCII_LIST = { - '*\\0/*':'1f646', '*\\O/*':'1f646', '-___-':'1f611', ':\'-)':'1f602', '\':-)':'1f605', '\':-D':'1f605', '>:-)':'1f606', '\':-(':'1f613', - '>:-(':'1f620', ':\'-(':'1f622', 'O:-)':'1f607', '0:-3':'1f607', '0:-)':'1f607', '0;^)':'1f607', 'O;-)':'1f607', '0;-)':'1f607', 'O:-3':'1f607', - '-__-':'1f611', ':-Þ':'1f61b', ':)':'1f606', '>;)':'1f606', '>=)':'1f606', ';-)':'1f609', '*-)':'1f609', ';-]':'1f609', ';^)':'1f609', '\':(':'1f613', '\'=(':'1f613', - ':-*':'1f618', ':^*':'1f618', '>:P':'1f61c', 'X-P':'1f61c', '>:[':'1f61e', ':-(':'1f61e', ':-[':'1f61e', '>:(':'1f620', ':\'(':'1f622', - ';-(':'1f622', '>.<':'1f623', '#-)':'1f635', '%-)':'1f635', 'X-)':'1f635', '\\0/':'1f646', '\\O/':'1f646', '0:3':'1f607', '0:)':'1f607', - 'O:)':'1f607', 'O=)':'1f607', 'O:3':'1f607', 'B-)':'1f60e', '8-)':'1f60e', 'B-D':'1f60e', '8-D':'1f60e', '-_-':'1f611', '>:\\':'1f615', - '>:/':'1f615', ':-/':'1f615', ':-.':'1f615', ':-P':'1f61b', ':Þ':'1f61b', ':-b':'1f61b', ':-O':'1f62e', 'O_O':'1f62e', '>:O':'1f62e', - ':-X':'1f636', ':-#':'1f636', ':-)':'1f642', '(y)':'1f44d', '<3':'2764', ':D':'1f603', '=D':'1f603', ';)':'1f609', '*)':'1f609', - ';]':'1f609', ';D':'1f609', ':*':'1f618', '=*':'1f618', ':(':'1f61e', ':[':'1f61e', '=(':'1f61e', ':@':'1f620', ';(':'1f622', 'D:':'1f628', - ':$':'1f633', '=$':'1f633', '#)':'1f635', '%)':'1f635', 'X)':'1f635', 'B)':'1f60e', '8)':'1f60e', ':/':'1f615', ':\\':'1f615', '=/':'1f615', - '=\\':'1f615', ':L':'1f615', '=L':'1f615', ':P':'1f61b', '=P':'1f61b', ':b':'1f61b', ':O':'1f62e', ':X':'1f636', ':#':'1f636', '=X':'1f636', - '=#':'1f636', ':)':'1f642', '=]':'1f642', '=)':'1f642', ':]':'1f642' + '*\\0/*': '1f646', + '*\\O/*': '1f646', + '-___-': '1f611', + ":'-)": '1f602', + "':-)": '1f605', + "':-D": '1f605', + '>:-)': '1f606', + "':-(": '1f613', + '>:-(': '1f620', + ":'-(": '1f622', + 'O:-)': '1f607', + '0:-3': '1f607', + '0:-)': '1f607', + '0;^)': '1f607', + 'O;-)': '1f607', + '0;-)': '1f607', + 'O:-3': '1f607', + '-__-': '1f611', + ':-Þ': '1f61b', + ':)': '1f606', + '>;)': '1f606', + '>=)': '1f606', + ';-)': '1f609', + '*-)': '1f609', + ';-]': '1f609', + ';^)': '1f609', + "':(": '1f613', + "'=(": '1f613', + ':-*': '1f618', + ':^*': '1f618', + '>:P': '1f61c', + 'X-P': '1f61c', + '>:[': '1f61e', + ':-(': '1f61e', + ':-[': '1f61e', + '>:(': '1f620', + ":'(": '1f622', + ';-(': '1f622', + '>.<': '1f623', + '#-)': '1f635', + '%-)': '1f635', + 'X-)': '1f635', + '\\0/': '1f646', + '\\O/': '1f646', + '0:3': '1f607', + '0:)': '1f607', + 'O:)': '1f607', + 'O=)': '1f607', + 'O:3': '1f607', + 'B-)': '1f60e', + '8-)': '1f60e', + 'B-D': '1f60e', + '8-D': '1f60e', + '-_-': '1f611', + '>:\\': '1f615', + '>:/': '1f615', + ':-/': '1f615', + ':-.': '1f615', + ':-P': '1f61b', + ':Þ': '1f61b', + ':-b': '1f61b', + ':-O': '1f62e', + 'O_O': '1f62e', + '>:O': '1f62e', + ':-X': '1f636', + ':-#': '1f636', + ':-)': '1f642', + '(y)': '1f44d', + '<3': '2764', + ':D': '1f603', + '=D': '1f603', + ';)': '1f609', + '*)': '1f609', + ';]': '1f609', + ';D': '1f609', + ':*': '1f618', + '=*': '1f618', + ':(': '1f61e', + ':[': '1f61e', + '=(': '1f61e', + ':@': '1f620', + ';(': '1f622', + 'D:': '1f628', + ':$': '1f633', + '=$': '1f633', + '#)': '1f635', + '%)': '1f635', + 'X)': '1f635', + 'B)': '1f60e', + '8)': '1f60e', + ':/': '1f615', + ':\\': '1f615', + '=/': '1f615', + '=\\': '1f615', + ':L': '1f615', + '=L': '1f615', + ':P': '1f61b', + '=P': '1f61b', + ':b': '1f61b', + ':O': '1f62e', + ':X': '1f636', + ':#': '1f636', + '=X': '1f636', + '=#': '1f636', + ':)': '1f642', + '=]': '1f642', + '=)': '1f642', + ':]': '1f642', }; function toCodePoint(unicode_surrogates) { @@ -42,6 +145,36 @@ function toCodePoint(unicode_surrogates) { return r.join('-'); } +/** + * If it already looks like a shortname, use it directly. + * Otherwise try to resolve it via codepoint. + * + * @param {string} emoji - A unicode emoji or shortname + */ +export function emojiToShortname(emoji) { + if (emoji.match(SHORTNAME_RE)) { + return emoji; + } else { + const by_cp = getEmojisByAttribute('cp'); + const cp = u.emojis.emojiToCodepointKey(emoji); + return by_cp[cp]?.sn ?? emoji; + } +} + +/** + * Convert a unicode emoji string to its codepoint key as used in the emoji data + * (e.g. '❤️' → '2764-fe0f', '👍' → '1f44d'). + * @param {string} emoji + * @returns {string} + */ +export function emojiToCodepointKey(emoji) { + return [...emoji].map((c) => c.codePointAt(0).toString(16)).join('-'); +} + +/** + * @param {number|string} codepoint + * @returns {string} + */ function fromCodePoint(codepoint) { let code = typeof codepoint === 'string' ? parseInt(codepoint, 16) : codepoint; if (code < 0x10000) { @@ -93,12 +226,13 @@ export function convertASCII2Emoji(str) { /** * @param {string} text + * @returns {import('./types').EmojiReference[]} */ export function getShortnameReferences(text) { if (!converse.emojis.initialized) { throw new Error( 'getShortnameReferences called before emojis are initialized. ' + - 'To avoid this problem, first await the converse.emojis.initialized_promise' + 'To avoid this problem, first await the converse.emojis.initialized_promise', ); } const references = [...text.matchAll(converse.emojis.shortnames_regex)].filter((ref) => ref[0].length > 0); @@ -106,10 +240,10 @@ export function getShortnameReferences(text) { const cp = converse.emojis.by_sn[ref[0].toLowerCase()]?.cp; return { cp, - 'begin': ref.index, - 'end': ref.index + ref[0].length, - 'shortname': ref[0], - 'emoji': cp ? convert(cp) : null, + begin: ref.index, + end: ref.index + ref[0].length, + shortname: ref[0], + emoji: cp ? convert(cp) : null, }; }); } @@ -130,26 +264,31 @@ function parseStringForEmojis(str, callback) { /** * @param {string} text + * @returns {import('./types').EmojiReference[]} */ export function getCodePointReferences(text) { const references = []; parseStringForEmojis(text, (icon_id, emoji, offset) => { references.push({ - 'begin': offset, - 'cp': icon_id, - 'emoji': emoji, - 'end': offset + emoji.length, - 'shortname': getEmojisByAttribute('cp')[icon_id]?.sn || '', + begin: offset, + cp: icon_id, + emoji: emoji, + end: offset + emoji.length, + shortname: getEmojisByAttribute('cp')[icon_id]?.sn || '', }); }); return references; } -function addEmojisMarkup (text) { +/** + * @param {string} text + * @returns {string[]} + */ +function addEmojisMarkup(text) { let list = [text]; [...getShortnameReferences(text), ...getCodePointReferences(text)] .sort((a, b) => b.begin - a.begin) - .forEach(ref => { + .forEach((ref) => { const text = list.shift(); const emoji = ref.emoji || ref.shortname; list = [text.slice(0, ref.begin) + emoji + text.slice(ref.end), ...list]; @@ -160,12 +299,10 @@ function addEmojisMarkup (text) { /** * Replaces all shortnames in the passed in string with their * unicode (emoji) representation. - * @namespace u - * @method u.shortnamesToUnicode * @param {String} str - String containing the shortname(s) * @returns {String} */ -function shortnamesToUnicode (str) { +function shortnamesToUnicode(str) { return addEmojisMarkup(convertASCII2Emoji(str)).pop(); } @@ -182,15 +319,13 @@ export function isOnlyEmojis(text) { return false; } const emojis = words.filter((text) => { - const refs = getCodePointReferences(u.shortnamesToUnicode(text)); + const refs = getCodePointReferences(u.emojis.shortnamesToUnicode(text)); return refs.length === 1 && (text.toLowerCase() === refs[0]['shortname'] || text === refs[0]['emoji']); }); return emojis.length === words.length; } /** - * @namespace u - * @method u.getEmojisByAttribute * @param { 'category'|'cp'|'sn' } attr * The attribute according to which the returned map should be keyed. * @returns { Object } @@ -203,27 +338,23 @@ function getEmojisByAttribute(attr) { if (attr === 'category') { return converse.emojis.json; } - const all_variants = converse.emojis.list - .map(e => e[attr]) - .filter((c, i, arr) => arr.indexOf(c) == i); + const all_variants = converse.emojis.list.map((e) => e[attr]).filter((c, i, arr) => arr.indexOf(c) == i); emojis_by_attribute[attr] = {}; - all_variants.forEach(v => (emojis_by_attribute[attr][v] = converse.emojis.list.find(i => i[attr] === v))); + all_variants.forEach((v) => (emojis_by_attribute[attr][v] = converse.emojis.list.find((i) => i[attr] === v))); return emojis_by_attribute[attr]; } -const exports = { - convertASCII2Emoji, - getCodePointReferences, - getEmojisByAttribute, - getShortnameReferences, - isOnlyEmojis, - shortnamesToUnicode, -}; - Object.assign(u, { - ...exports, // DEPRECATED emojis: { - ...exports, + convert, + convertASCII2Emoji, + emojiToCodepointKey, + emojiToShortname, + getCodePointReferences, + getEmojisByAttribute, + getShortnameReferences, + isOnlyEmojis, + shortnamesToUnicode, }, }); diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 99d3753606..aa2e2adf5a 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -1145,7 +1145,7 @@ class MUC extends ModelWithVCard(ModelWithMessages(ColorAwareModel(ChatBoxBase)) [text, references] = this.parseTextForReferences(attrs.body); } const origin_id = getUniqueId(); - const body = text ? u.shortnamesToUnicode(text) : undefined; + const body = text ? u.emojis.shortnamesToUnicode(text) : undefined; // Get reply attributes from chatbox model if replying to a message const reply_to_id = this.get('reply_to_id'); diff --git a/src/headless/plugins/muc/occupant.js b/src/headless/plugins/muc/occupant.js index 234a190c9a..fa5509b2ba 100644 --- a/src/headless/plugins/muc/occupant.js +++ b/src/headless/plugins/muc/occupant.js @@ -191,7 +191,7 @@ class MUCOccupant extends ModelWithVCard(ModelWithMessages(ColorAwareModel(Model async getOutgoingMessageAttributes(attrs) { const origin_id = u.getUniqueId(); const text = attrs?.body; - const body = text ? u.shortnamesToUnicode(text) : undefined; + const body = text ? u.emojis.shortnamesToUnicode(text) : undefined; const muc = this.collection.chatroom; const own_occupant = muc.getOwnOccupant(); attrs = Object.assign( diff --git a/src/headless/plugins/reactions/plugin.js b/src/headless/plugins/reactions/plugin.js index 57d3756e0e..8bda2aa3b9 100644 --- a/src/headless/plugins/reactions/plugin.js +++ b/src/headless/plugins/reactions/plugin.js @@ -10,39 +10,24 @@ import api from '../../shared/api/index.js'; import _converse from '../../shared/_converse.js'; import { parseReactionsMessage } from './parsers.js'; import { - clearSession, getDuplicateMessageQueries, getErrorAttributesForMessage, getUpdatedMessageAttributes, onAfterMessageCreated, onBeforeMessageCreated, - registerPEPPushHandler, } from './utils.js'; -import PopularReactions from './popular-model.js'; const { Strophe } = converse.env; Strophe.addNamespace('REACTIONS', 'urn:xmpp:reactions:0'); -Strophe.addNamespace('REACTIONS_POPULAR', 'urn:xmpp:reactions:popular:0'); converse.plugins.add('converse-reactions', { - dependencies: ['converse-chat', 'converse-muc', 'converse-pubsub'], + dependencies: ['converse-chat', 'converse-muc', 'converse-pubsub', 'converse-emoji'], initialize() { api.listen.on('parseMessage', parseReactionsMessage); api.listen.on('parseMUCMessage', parseReactionsMessage); - api.listen.on('connected', () => { - registerPEPPushHandler(); - if (_converse.state.popular_reactions) { - return; - } - const popular_reactions = new PopularReactions(); - Object.assign(_converse.state, { popular_reactions }); - }); - - api.listen.on('clearSession', clearSession); - api.listen.on('getDuplicateMessageQueries', getDuplicateMessageQueries); api.listen.on('getUpdatedMessageAttributes', getUpdatedMessageAttributes); api.listen.on('getErrorAttributesForMessage', getErrorAttributesForMessage); diff --git a/src/headless/plugins/reactions/popular-model.js b/src/headless/plugins/reactions/popular-model.js deleted file mode 100644 index d94508d091..0000000000 --- a/src/headless/plugins/reactions/popular-model.js +++ /dev/null @@ -1,140 +0,0 @@ -import log from '@converse/log'; -import { getOpenPromise } from '@converse/openpromise'; -import { Model } from '@converse/skeletor'; -import api from '../../shared/api/index.js'; -import converse from '../../shared/api/public.js'; -import _converse from '../../shared/_converse.js'; -import { emojiToCodepointKey, getStorageKeys } from './utils.js'; - -const { Strophe, sizzle, stx, u } = converse.env; - -/** - * Model for storing popular reactions with timestamps. - * Tracks when each emoji was last used for a reaction. - */ -class PopularReactions extends Model { - defaults() { - return { - // Format: { 'emoji_shortname': 'ISO8601-timestamp', ... } - timestamps: {}, - }; - } - - async initialize() { - super.initialize(); - const { storage_key, fetched_flag_key } = getStorageKeys(); - this.fetched_flag = fetched_flag_key; - u.initStorage(this, storage_key); - await this.fetchPopularReactions(); - } - - async fetchPopularReactions() { - if (_converse.state.session.get(this.fetched_flag)) { - const deferred = getOpenPromise(); - this.fetch({ - success: () => deferred.resolve(), - error: () => deferred.resolve(), - }); - return deferred; - } else { - await this.fetchPopularReactionsFromServer(); - } - } - - /** - * Fetch the user's stored popular reactions from their PEP node and apply them. - * If no item is stored, the default `popular_reactions` setting is left unchanged. - */ - async fetchPopularReactionsFromServer() { - const bare_jid = _converse.state.session.get('bare_jid'); - let iq; - try { - iq = await api.sendIQ( - stx` - - - - `, - ); - await this.applyPopularReactionsFromStanza(iq); - } catch (e) { - // item-not-found is expected when the user has never saved a custom list - if (e?.querySelector?.('item-not-found')) return; - log.warn('fetchPopularReactionsFromServer: could not fetch popular reactions from PubSub'); - log.error(e); - return; - } - } - - /** - * Parse a list of unicode emoji from a popular-reactions PubSub item and - * merge the received timestamps with local ones, keeping the most recent - * timestamp per emoji (last-write-wins per slot). - * - * @param {Element} stanza - An IQ result or headline message containing the pubsub item - */ - async applyPopularReactionsFromStanza(stanza) { - const item = sizzle(`items[node="${Strophe.NS.REACTIONS_POPULAR}"] item`, stanza).pop(); - if (!item) return; - - const popular_el = item.getElementsByTagNameNS(Strophe.NS.REACTIONS_POPULAR, 'popular-reactions')[0]; - if (!popular_el) return; - - const reactions = Array.from(popular_el.querySelectorAll('reaction')) - .map((el) => ({ emoji: el.textContent?.trim(), stamp: el.getAttribute('stamp') })) - .filter(({ emoji, stamp }) => emoji && stamp); - - if (!reactions.length) return; - - await api.emojis.initialize(); - - const by_cp = u.getEmojisByAttribute('cp'); - const local_timestamps = { ...(this.get('timestamps') || {}) }; - - for (const { emoji, stamp } of reactions) { - const cp = emojiToCodepointKey(emoji); - const shortname = by_cp[cp]?.sn ?? emoji; - const remote_ms = new Date(stamp).getTime(); - if (isNaN(remote_ms)) continue; - - // Normalise to UTC ISO string so stored values are always comparable - // as strings, regardless of the timezone used by the sending device. - const normalised_stamp = new Date(remote_ms).toISOString(); - - const local_stamp = local_timestamps[shortname]; - const local_ms = local_stamp ? new Date(local_stamp).getTime() : 0; - - // Keep whichever timestamp is more recent (last-write-wins per emoji) - if (remote_ms > local_ms) { - local_timestamps[shortname] = normalised_stamp; - } - } - - this.save({ timestamps: local_timestamps }); - } - - /** - * Record that an emoji was just used, setting its timestamp to now. - * @param {string} shortname - The emoji shortname (e.g., ':thumbsup:') - */ - recordUsage(shortname) { - const timestamps = { ...(this.get('timestamps') || {}) }; - timestamps[shortname] = new Date().toISOString(); - this.save({ timestamps }); - } - - /** - * Get emojis sorted by most recently used first. - * @param {number} [maxLength=5] - Maximum number of emojis to return - * @returns {string[]} - Array of shortnames sorted by timestamp descending - */ - getSortedEmojis(maxLength = 5) { - const timestamps = this.get('timestamps') || {}; - return Object.entries(timestamps) - .sort((a, b) => new Date(b[1]).getTime() - new Date(a[1]).getTime() || a[0].localeCompare(b[0])) - .map((entry) => entry[0]) - .slice(0, maxLength); - } -} - -export default PopularReactions; diff --git a/src/headless/plugins/reactions/tests/popular-reactions.js b/src/headless/plugins/reactions/tests/popular-reactions.js deleted file mode 100644 index ca4c5e9082..0000000000 --- a/src/headless/plugins/reactions/tests/popular-reactions.js +++ /dev/null @@ -1,249 +0,0 @@ -/* global converse */ -import mock from '../../../tests/mock.js'; - -const { Strophe, sizzle, stx, u } = converse.env; - -describe('Popular Reactions Timestamp Tracking', function () { - beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); - - describe('PopularReactions Model', function () { - it( - 'records usage timestamps correctly', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - await mock.waitForRoster(_converse, 'current', 0); - - const popular_reactions = _converse.state.popular_reactions; - expect(popular_reactions).toBeDefined(); - expect(Object.keys(popular_reactions.get('timestamps'))).toEqual([]); - - const before = new Date().toISOString(); - popular_reactions.recordUsage(':thumbsup:'); - popular_reactions.recordUsage(':heart:'); - popular_reactions.recordUsage(':tada:'); - const after = new Date().toISOString(); - - const timestamps = popular_reactions.get('timestamps'); - expect(timestamps[':thumbsup:']).toBeDefined(); - expect(timestamps[':heart:']).toBeDefined(); - expect(timestamps[':tada:']).toBeDefined(); - - // All timestamps should be within the test window - for (const sn of [':thumbsup:', ':heart:', ':tada:']) { - expect(timestamps[sn] >= before).toBeTrue(); - expect(timestamps[sn] <= after).toBeTrue(); - } - }), - ); - - it( - 'returns emojis sorted by most recently used first', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - await mock.waitForRoster(_converse, 'current', 0); - - const popular_reactions = _converse.state.popular_reactions; - - // Set timestamps directly to control ordering - popular_reactions.save({ - timestamps: { - ':joy:': '2026-03-29T10:00:00.000Z', - ':heart:': '2026-03-29T12:00:00.000Z', - ':thumbsup:': '2026-03-29T11:00:00.000Z', - ':open_mouth:': '2026-03-29T09:00:00.000Z', - }, - }); - - // Most recent first - const sorted = popular_reactions.getSortedEmojis(); - expect(sorted).toEqual([':heart:', ':thumbsup:', ':joy:', ':open_mouth:']); - - // Respects maxLength - const limited = popular_reactions.getSortedEmojis(2); - expect(limited).toEqual([':heart:', ':thumbsup:']); - }), - ); - - it( - 'overwrites the previous timestamp when an emoji is used again', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - await mock.waitForRoster(_converse, 'current', 0); - - const popular_reactions = _converse.state.popular_reactions; - popular_reactions.save({ - timestamps: { - ':heart:': '2026-03-29T10:00:00.000Z', - ':thumbsup:': '2026-03-29T12:00:00.000Z', - }, - }); - - // :heart: was older, but after using it again it should be most recent - popular_reactions.recordUsage(':heart:'); - - const sorted = popular_reactions.getSortedEmojis(); - expect(sorted[0]).toBe(':heart:'); - }), - ); - }); - - describe('PubSub Storage and Retrieval', function () { - it( - 'fetches popular reactions from PEP node on connect', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - const { api } = _converse; - await mock.waitForRoster(_converse, 'current', 0); - const own_jid = _converse.session.get('jid'); - const bare_jid = Strophe.getBareJidFromJid(own_jid); - const sent_stanzas = api.connection.get().sent_stanzas; - const sent_stanza = await u.waitUntil(() => - sent_stanzas - .filter((iq) => sizzle(`pubsub items[node="${Strophe.NS.REACTIONS_POPULAR}"]`, iq).length) - .pop(), - ); - expect(sent_stanza).toEqualStanza(stx` - - - - - - `); - - const returned_stanza = stx` - - - - - - 👍 - ❤️ - 🎉 - - - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(returned_stanza)); - - const { popular_reactions } = _converse.state; - await u.waitUntil(() => Object.keys(popular_reactions.get('timestamps')).length); - const timestamps = popular_reactions.get('timestamps'); - expect(timestamps[':thumbsup:']).toBe('2026-03-29T12:00:00.000Z'); - expect(timestamps[':heart:']).toBe('2026-03-29T11:00:00.000Z'); - expect(timestamps[':tada:']).toBe('2026-03-29T10:00:00.000Z'); - - // Verify sorted order: most recent first - const sorted = popular_reactions.getSortedEmojis(); - expect(sorted).toEqual([':thumbsup:', ':heart:', ':tada:']); - }), - ); - }); - - describe('Cross-device Synchronization', function () { - it( - 'merges incoming PEP timestamps with local ones, keeping the most recent per emoji', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - await mock.waitForRoster(_converse, 'current', 0); - - const { popular_reactions } = _converse.state; - - // Simulate local usage: :thumbsup: used very recently, :tada: used earlier - popular_reactions.save({ - timestamps: { - ':thumbsup:': '2026-03-29T13:00:00.000Z', // local is newer - ':tada:': '2026-03-29T09:00:00.000Z', // local is older - }, - }); - - const bare_jid = _converse.session.get('bare_jid'); - - // Incoming PEP event from another device: - // :heart: is new (not local), :thumbsup: is older than local, :tada: is newer than local - const stanza = stx` - - - - - - 🎉 - 👍 - ❤️ - - - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); - - await u.waitUntil(() => popular_reactions.get('timestamps')[':heart:']); - - const timestamps = popular_reactions.get('timestamps'); - - // :thumbsup: — local (13:00) is newer than remote (11:00), keep local - expect(timestamps[':thumbsup:']).toBe('2026-03-29T13:00:00.000Z'); - - // :tada: — remote (14:00) is newer than local (09:00), use remote - expect(timestamps[':tada:']).toBe('2026-03-29T14:00:00.000Z'); - - // :heart: — only on remote, should be added - expect(timestamps[':heart:']).toBe('2026-03-29T12:00:00.000Z'); - - // Sorted order: :tada: (14:00), :heart: (12:00), :thumbsup: (13:00 local → second) - const sorted = popular_reactions.getSortedEmojis(); - expect(sorted).toEqual([':tada:', ':thumbsup:', ':heart:']); - }), - ); - - it( - 'does not overwrite a newer local timestamp with an older remote one', - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { - await mock.waitForRoster(_converse, 'current', 0); - - const { popular_reactions } = _converse.state; - - // Local has a very recent usage - popular_reactions.save({ - timestamps: { - ':thumbsup:': '2026-03-29T15:00:00.000Z', - }, - }); - - const bare_jid = _converse.session.get('bare_jid'); - - // Remote has an older timestamp for the same emoji - const stanza = stx` - - - - - - 👍 - - - - - - `; - _converse.api.connection.get()._dataRecv(mock.createRequest(stanza)); - - // Give it a moment to process - await u.waitUntil(() => true); - - const timestamps = popular_reactions.get('timestamps'); - // Local timestamp must be preserved - expect(timestamps[':thumbsup:']).toBe('2026-03-29T15:00:00.000Z'); - }), - ); - }); -}); diff --git a/src/headless/plugins/reactions/utils.js b/src/headless/plugins/reactions/utils.js index 8c14fc4d57..f685a269a5 100644 --- a/src/headless/plugins/reactions/utils.js +++ b/src/headless/plugins/reactions/utils.js @@ -1,9 +1,8 @@ -import log from '@converse/log'; import converse from '../../shared/api/public.js'; import api from '../../shared/api/index.js'; import _converse from '../../shared/_converse.js'; -const { Strophe, stx, u } = converse.env; +const { Strophe, u } = converse.env; /** * @typedef {import('../../shared/types').MessageAttributes} MessageAttributes @@ -49,40 +48,6 @@ export function getDuplicateMessageQueries(chatbox, queries, attrs) { return [...queries, ...extra]; } -export function registerPEPPushHandler() { - const bare_jid = _converse.session.get('bare_jid'); - api.connection.get().addHandler( - /** @param {Element} stanza */ - (stanza) => { - const { popular_reactions } = _converse.state; - popular_reactions?.applyPopularReactionsFromStanza(stanza); - return true; - }, - Strophe.NS.REACTIONS_POPULAR, - 'message', - 'headline', - null, - bare_jid, - ); -} - -/** - * @returns {import('shared/types').StorageKeys} - */ -export function getStorageKeys() { - const { session } = _converse; - const storage_key = `converse.popular_reactions_frequencies.${session.get('bare_jid')}`; - const fetched_flag_key = `${storage_key}-fetched`; - return { storage_key, fetched_flag_key }; -} - -/** - * Clear the popular reactions session data. - */ -export function clearSession() { - delete _converse.state.popular_reactions; -} - /** * This hook handler merges the incoming single-reactor reactions * with all existing reactions from other reactors, so that no @@ -240,52 +205,10 @@ export function getOwnReactionJID(chatbox) { return Strophe.getBareJidFromJid(api.connection.get().jid); } -/** - * Convert a unicode emoji string to its codepoint key as used in the emoji data - * (e.g. '❤️' → '2764', '👍' → '1f44d'). - * Variation selectors (U+FE0F, U+FE0E) are stripped since the emoji data keys - * do not include them. - * @param {string} emoji - * @returns {string} - */ -export function emojiToCodepointKey(emoji) { - return [...emoji] - .filter((c) => c.codePointAt(0) !== 0xfe0f && c.codePointAt(0) !== 0xfe0e) - .map((c) => c.codePointAt(0).toString(16)) - .join('-'); -} - -/** - * Publish the given list of emoji+timestamp pairs as the user's popular reactions - * to their private PEP node (XEP-0223). Timestamps follow the XEP-0082 datetime - * profile (ISO 8601 UTC), as used by XEP-0203 delayed delivery. - * - * @param {Array<{emoji: string, stamp: string}>} reactions - Emoji/timestamp pairs to store, sorted most-recent first - */ -export async function publishPopularReactions(reactions) { - const item = stx` - - - ${reactions.map(({ emoji, stamp }) => stx`${emoji}`)} - - `; - - try { - await api.pubsub.publish(null, Strophe.NS.REACTIONS_POPULAR, item, { - 'persist_items': 'true', - 'access_model': 'whitelist', - }); - } catch (e) { - log.warn('publishPopularReactions: failed to update popular reactions'); - log.error(e); - } -} Object.assign(u, { reactions: { ...u.reactions, - emojiToCodepointKey, getOwnReactionJID, - publishPopularReactions, }, }); diff --git a/src/headless/plugins/smacks/tests/smacks.js b/src/headless/plugins/smacks/tests/smacks.js index c201c99333..00bb52eb4f 100644 --- a/src/headless/plugins/smacks/tests/smacks.js +++ b/src/headless/plugins/smacks/tests/smacks.js @@ -1,7 +1,7 @@ /* global converse */ import mock from '../../../tests/mock.js'; -const { stx, Strophe, sizzle, u } = converse.env; +const { stx, Strophe, Stanza, sizzle, u } = converse.env; describe('XEP-0198 Stream Management', function () { beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); @@ -15,7 +15,7 @@ describe('XEP-0198 Stream Management', function () { enable_smacks: true, show_controlbox_by_default: true, smacks_max_unacked_stanzas: 2, - blacklisted_plugins: ['converse-blocklist', 'converse-reactions'], + blacklisted_plugins: ['converse-blocklist'], }, async function (_converse) { await _converse.api.user.login('romeo@montague.lit/orchard', 'secret'); @@ -37,31 +37,38 @@ describe('XEP-0198 Stream Management', function () { const disco_iq = IQ_stanzas[0]; expect(disco_iq).toEqualStanza(stx` - - `); + + + + + `); expect(IQ_stanzas[1]).toEqualStanza(stx` - - `); + + `); await mock.waitForRoster(_converse, 'current', 1); expect(IQ_stanzas[2]).toEqualStanza(stx` - - `); + + `); expect(IQ_stanzas[3]).toEqualStanza(stx` - - `); + + `); + + expect(IQ_stanzas[4]).toEqualStanza(stx` + + `); await u.waitUntil(() => sent_stanzas.filter((s) => s.nodeName === 'presence').length); - expect(sent_stanzas.filter((s) => s.nodeName === 'r').length).toBe(2); - expect(_converse.session.get('unacked_stanzas').length).toBe(5); + expect(sent_stanzas.filter((s) => s.nodeName === 'r').length).toBe(3); + expect(_converse.session.get('unacked_stanzas').length).toBe(6); // test handling of acks let ack = stx``; _converse.api.connection.get()._dataRecv(mock.createRequest(ack)); - expect(_converse.session.get('unacked_stanzas').length).toBe(3); + expect(_converse.session.get('unacked_stanzas').length).toBe(4); // test handling of ack requests let r = stx``; @@ -72,32 +79,34 @@ describe('XEP-0198 Stream Management', function () { expect(Strophe.serialize(ack)).toBe(''); const disco_result = stx` - - - - - - - `; + + + + + + + `; _converse.api.connection.get()._dataRecv(mock.createRequest(disco_result)); ack = stx``; _converse.api.connection.get()._dataRecv(mock.createRequest(ack)); - expect(_converse.session.get('unacked_stanzas').length).toBe(3); - - expect(_converse.session.get('unacked_stanzas')[0]).toBe(Strophe.serialize(IQ_stanzas[2])); - expect(_converse.session.get('unacked_stanzas')[1]).toBe(Strophe.serialize(IQ_stanzas[3])); - expect(_converse.session.get('unacked_stanzas')[2]).toBe( - `0` + - `` + - ``, + expect(_converse.session.get('unacked_stanzas').length).toBe(4); + + const unacked_stanzas = _converse.session.get('unacked_stanzas').map(Stanza.fromString); + expect(unacked_stanzas[0]).toEqualStanza(IQ_stanzas[2]); + expect(unacked_stanzas[1]).toEqualStanza(IQ_stanzas[3]); + expect(unacked_stanzas[2]).toEqualStanza(IQ_stanzas[4]); + expect(unacked_stanzas[3]).toEqualStanza( + stx`0 + + ` ); r = stx``; _converse.api.connection.get()._dataRecv(mock.createRequest(r)); ack = await u.waitUntil(() => - sent_stanzas.filter((s) => s.nodeName === 'a' && s.getAttribute('h') === '3').pop(), + sent_stanzas.filter((s) => s.nodeName === 'a' && s.getAttribute('h') === '3').pop() ); expect(Strophe.serialize(ack)).toBe(''); @@ -109,7 +118,7 @@ describe('XEP-0198 Stream Management', function () { await _converse.api.connection.reconnect(); stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.tagName === 'resume').pop(), 1000); expect(Strophe.serialize(stanza)).toEqual( - '', + '' ); result = stx``; @@ -120,24 +129,26 @@ describe('XEP-0198 Stream Management', function () { expect(_converse.session.get('smacks_enabled')).toBe(true); await new Promise((resolve) => _converse.api.listen.once('reconnected', resolve)); - await u.waitUntil(() => IQ_stanzas.length === 2); + await u.waitUntil(() => IQ_stanzas.length === 3); // Test that unacked stanzas get resent out let iq = IQ_stanzas.pop(); expect(iq).toEqualStanza(stx` - - - `); + + + `); iq = IQ_stanzas.pop(); expect(iq).toEqualStanza(stx` - - - `); + + + `); - expect(IQ_stanzas.filter((iq) => sizzle('query[xmlns="jabber:iq:roster"]', iq).pop()).length).toBe(0); - }, - ), + iq = IQ_stanzas.pop(); + expect(iq).toEqualStanza(stx` + `); + } + ) ); it( @@ -178,8 +189,8 @@ describe('XEP-0198 Stream Management', function () { // send_initial_presence at that point, before onConnected() fires. // This is fully synchronous so we can assert immediately. expect(conn.send_initial_presence).toBe(false); - }, - ), + } + ) ); it( @@ -197,10 +208,7 @@ describe('XEP-0198 Stream Management', function () { await _converse.api.user.login('romeo@montague.lit/orchard', 'secret'); const sent_stanzas = _converse.api.connection.get().sent_stanzas; - let stanza = await u.waitUntil( - () => sent_stanzas.filter((s) => s.tagName === 'enable').pop(), - 1000 - ); + let stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.tagName === 'enable').pop(), 1000); expect(Strophe.serialize(stanza)).toEqual(''); let result = stx``; @@ -212,10 +220,7 @@ describe('XEP-0198 Stream Management', function () { // Reconnect and successfully resume the SMACKS session await _converse.api.connection.reconnect(); - stanza = await u.waitUntil( - () => sent_stanzas.filter((s) => s.tagName === 'resume').pop(), - 1000 - ); + stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.tagName === 'resume').pop(), 1000); const conn = _converse.api.connection.get(); result = stx``; @@ -254,7 +259,7 @@ describe('XEP-0198 Stream Management', function () { await _converse.api.connection.reconnect(); stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.tagName === 'resume').pop()); expect(Strophe.serialize(stanza)).toEqual( - '', + '' ); result = stx` @@ -282,8 +287,8 @@ describe('XEP-0198 Stream Management', function () { // Check that the roster gets fetched await mock.waitForRoster(_converse, 'current', 1); await new Promise((resolve) => _converse.api.listen.once('reconnected', resolve)); - }, - ), + } + ) ); it( @@ -320,7 +325,7 @@ describe('XEP-0198 Stream Management', function () { 'smacks_stream_id': 'some-long-sm-id', 'push_enabled': ['romeo@montague.lit'], 'roster_cached': true, - }), + }) ); const muc_jid = 'lounge@montague.lit'; @@ -338,7 +343,7 @@ describe('XEP-0198 Stream Management', function () { id: muc_jid, box_id: 'box-YXJnQGNvbmZlcmVuY2UuY2hhdC5leGFtcGxlLm9yZw==', nick: 'romeo', - }), + }) ); const proto = Object.getPrototypeOf(api.connection.get()); @@ -358,7 +363,7 @@ describe('XEP-0198 Stream Management', function () { const sent_stanzas = api.connection.get().sent_stanzas; const stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.tagName === 'resume').pop()); expect(Strophe.serialize(stanza)).toEqual( - '', + '' ); const result = stx``; @@ -370,7 +375,7 @@ describe('XEP-0198 Stream Management', function () { spyOn(_converse.chatboxes, 'onChatBoxesFetched').and.callFake((collection) => { const muc = new _converse.ChatRoom( { 'jid': muc_jid, 'id': muc_jid, nick }, - { 'collection': _converse.chatboxes }, + { 'collection': _converse.chatboxes } ); _converse.chatboxes.add(muc); func.call(_converse.chatboxes, collection); @@ -396,7 +401,7 @@ describe('XEP-0198 Stream Management', function () { await muc.messages.fetched; await u.waitUntil(() => muc.messages.length); expect(muc.messages.at(0).get('message')).toBe('First message'); - }, - ), + } + ) ); }); diff --git a/src/headless/shared/model-with-messages.js b/src/headless/shared/model-with-messages.js index 2fe049cefc..562dee4729 100644 --- a/src/headless/shared/model-with-messages.js +++ b/src/headless/shared/model-with-messages.js @@ -334,7 +334,7 @@ export default function ModelWithMessages(BaseModel) { * @property {(ChatBox|MUC)} data.chatbox * @property {(BaseMessage)} data.message */ - api.trigger('sendMessage', { 'chatbox': this, message }); + api.trigger('sendMessage', { chatbox: this, message }); return message; } diff --git a/src/headless/types/plugins/chat/model.d.ts b/src/headless/types/plugins/chat/model.d.ts index 770d562d27..e9f44dcaa3 100644 --- a/src/headless/types/plugins/chat/model.d.ts +++ b/src/headless/types/plugins/chat/model.d.ts @@ -119,18 +119,11 @@ declare const ChatBox_base: { getMostRecentMessage(): import("../../shared/message").default; getMessageReferencedByError(attrs: object): any; findDanglingRetraction(attrs: object): import("../../shared/message").default | null; - getDuplicateMessage(attrs: object): import("../../shared/message").default; + getDuplicateMessage(attrs: object): Promise; getOriginIdQueryAttrs(attrs: object): { origin_id: any; from: any; }; - getReactionQueryAttrs(attrs: object): ({ - origin_id: any; - msgid?: undefined; - } | { - msgid: any; - origin_id?: undefined; - })[]; getStanzaIdQueryAttrs(attrs: object): {}[]; getMessageBodyQueryAttrs(attrs: object): { from: any; diff --git a/src/headless/types/plugins/emoji/constants.d.ts b/src/headless/types/plugins/emoji/constants.d.ts new file mode 100644 index 0000000000..9d9354067d --- /dev/null +++ b/src/headless/types/plugins/emoji/constants.d.ts @@ -0,0 +1,3 @@ +export const SHORTNAME_RE: RegExp; +export const PUBLISH_DEBOUNCE_MILLIS: 30000; +//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/emoji/handlers.d.ts b/src/headless/types/plugins/emoji/handlers.d.ts new file mode 100644 index 0000000000..c51e7950a3 --- /dev/null +++ b/src/headless/types/plugins/emoji/handlers.d.ts @@ -0,0 +1,16 @@ +export function registerPEPPushHandler(): void; +/** + * Given a message, extract emojis from it and use them to update the list of + * popular emojis. + * @param {import('../../plugins/reactions/utils').BaseMessage} message + */ +export function updatePopularEmojis(message: import("../../plugins/reactions/utils").BaseMessage): Promise; +/** + * @param {import('../../shared/types').MessageAttributes} attrs + * @param {String} text + * @returns {Promise} + */ +export function parseMessage(attrs: import("../../shared/types").MessageAttributes, text: string): Promise; +//# sourceMappingURL=handlers.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/emoji/popular-emojis.d.ts b/src/headless/types/plugins/emoji/popular-emojis.d.ts new file mode 100644 index 0000000000..e556aa4325 --- /dev/null +++ b/src/headless/types/plugins/emoji/popular-emojis.d.ts @@ -0,0 +1,49 @@ +export default PopularEmojis; +declare class PopularEmojis extends Model { + constructor(attributes?: Partial, options?: import("@converse/skeletor").ModelOptions); + defaults(): { + timestamps: {}; + }; + initialize(): void; + fetched_flag: string; + debouncedPublish: { + (...args: any[]): void; + flush(): void; + }; + fetchPopularEmojis(): Promise; + /** + * Fetch the user's stored popular emojis from their PEP node and apply them. + * If no item is stored, the default `popular_emojis` setting is left unchanged. + */ + fetchPopularEmojisFromServer(): Promise; + /** + * Parse a list of unicode emoji from a popular-reactions PubSub item and + * merge the received timestamps with local ones, keeping the most recent + * timestamp per emoji (last-write-wins per slot). + * + * @param {Element} stanza - An IQ result or headline message containing the pubsub item + */ + applyPopularEmojisFromStanza(stanza: Element): void; + /** + * Record that an emoji was just used, setting its timestamp to now. + * Accepts unicode emoji or shortnames. Shortnames are converted to unicode + * before storage to prevent duplicates. Unicode is stored as-is (preserving + * variation selectors like U+FE0F). + * + * @param {string[]} emojis - An array of unicode emojis and/or shortnames + */ + recordUsage(emojis: string[]): void; + /** + * Get emojis sorted by most recently used first. + * @param {number} [maxLength=5] - Maximum number of emojis to return + * @returns {string[]} - Array of unicode emoji keys sorted by timestamp descending + */ + getSortedEmojis(maxLength?: number): string[]; + /** + * @returns {Promise} Map from unicode emoji to data + */ + getPopularEmojis(): Promise; + publish(): Promise; +} +import { Model } from '@converse/skeletor'; +//# sourceMappingURL=popular-emojis.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/emoji/types.d.ts b/src/headless/types/plugins/emoji/types.d.ts index 7b231b707a..b7819227e6 100644 --- a/src/headless/types/plugins/emoji/types.d.ts +++ b/src/headless/types/plugins/emoji/types.d.ts @@ -1,5 +1,16 @@ -export type EmojiMarkupOptions = { - unicode_only?: boolean; - add_title_wrapper?: boolean; +export type EmojiData = { + sn: string; + cp: string; + sns: string; + c: string; + url?: string; +}; +export type EmojiDataByUnicode = Record; +export type EmojiReference = { + cp: string; + begin: number; + end: number; + shortname: string; + emoji: string | null; }; //# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/emoji/utils.d.ts b/src/headless/types/plugins/emoji/utils.d.ts index 57a166f379..54f4c61cea 100644 --- a/src/headless/types/plugins/emoji/utils.d.ts +++ b/src/headless/types/plugins/emoji/utils.d.ts @@ -1,21 +1,31 @@ +/** + * If it already looks like a shortname, use it directly. + * Otherwise try to resolve it via codepoint. + * + * @param {string} emoji - A unicode emoji or shortname + */ +export function emojiToShortname(emoji: string): any; +/** + * Convert a unicode emoji string to its codepoint key as used in the emoji data + * (e.g. '❤️' → '2764-fe0f', '👍' → '1f44d'). + * @param {string} emoji + * @returns {string} + */ +export function emojiToCodepointKey(emoji: string): string; /** * @param {string} str */ export function convertASCII2Emoji(str: string): string; /** * @param {string} text + * @returns {import('./types').EmojiReference[]} */ -export function getShortnameReferences(text: string): { - cp: any; - begin: number; - end: number; - shortname: string; - emoji: string; -}[]; +export function getShortnameReferences(text: string): import("./types").EmojiReference[]; /** * @param {string} text + * @returns {import('./types').EmojiReference[]} */ -export function getCodePointReferences(text: string): any[]; +export function getCodePointReferences(text: string): import("./types").EmojiReference[]; /** * Determines whether the passed in string is just a single emoji shortname; * @namespace u diff --git a/src/headless/types/plugins/muc/muc.d.ts b/src/headless/types/plugins/muc/muc.d.ts index 3a1bf48d30..84b5919b00 100644 --- a/src/headless/types/plugins/muc/muc.d.ts +++ b/src/headless/types/plugins/muc/muc.d.ts @@ -119,18 +119,11 @@ declare const MUC_base: { getMostRecentMessage(): import("../../shared/message.js").default; getMessageReferencedByError(attrs: object): any; findDanglingRetraction(attrs: object): import("../../shared/message.js").default | null; - getDuplicateMessage(attrs: object): import("../../shared/message.js").default; + getDuplicateMessage(attrs: object): Promise; getOriginIdQueryAttrs(attrs: object): { origin_id: any; from: any; }; - getReactionQueryAttrs(attrs: object): ({ - origin_id: any; - msgid?: undefined; - } | { - msgid: any; - origin_id?: undefined; - })[]; getStanzaIdQueryAttrs(attrs: object): {}[]; getMessageBodyQueryAttrs(attrs: object): { from: any; @@ -782,6 +775,15 @@ declare class MUC extends MUC_base { * @returns {boolean} */ isOwnMessage(msg: any | Element | import("./message.js").default): boolean; + /** + * Determines whether the incoming message stanza is a MUC reflection + * of a message we previously sent. A MUC reflection is the server + * echoing back our own message with the same `msgid`. + * @param {MUCMessage} message - The existing cached message model + * @param {MUCMessageAttributes} attrs - Attributes of the incoming stanza + * @returns {boolean} + */ + isMUCReflectedMessage(message: import("./message.js").default, attrs: import("./types").MUCMessageAttributes): boolean; /** * @param {MUCMessage} message * @param {MUCMessageAttributes} attrs @@ -862,14 +864,6 @@ declare class MUC extends MUC_base { * @returns {boolean} */ handleMEPNotification(attrs: import("./types").MUCMessageAttributes): boolean; - /** - * Returns an already cached message (if it exists) based on the - * passed in attributes map. - * @param {object} attrs - Attributes representing a received - * message, as returned by {@link parseMUCMessage} - * @returns {MUCMessage|BaseMessage} - */ - getDuplicateMessage(attrs: object): import("./message.js").default | import("../../shared/message.js").default; /** * Handler for all MUC messages sent to this groupchat. This method * shouldn't be called directly, instead {@link MUC#queueMessage} diff --git a/src/headless/types/plugins/muc/occupant.d.ts b/src/headless/types/plugins/muc/occupant.d.ts index 648d53096c..4aff68da24 100644 --- a/src/headless/types/plugins/muc/occupant.d.ts +++ b/src/headless/types/plugins/muc/occupant.d.ts @@ -119,18 +119,11 @@ declare const MUCOccupant_base: { getMostRecentMessage(): import("../../shared/message.js").default; getMessageReferencedByError(attrs: object): any; findDanglingRetraction(attrs: object): import("../../shared/message.js").default | null; - getDuplicateMessage(attrs: object): import("../../shared/message.js").default; + getDuplicateMessage(attrs: object): Promise; getOriginIdQueryAttrs(attrs: object): { origin_id: any; from: any; }; - getReactionQueryAttrs(attrs: object): ({ - origin_id: any; - msgid?: undefined; - } | { - msgid: any; - origin_id?: undefined; - })[]; getStanzaIdQueryAttrs(attrs: object): {}[]; getMessageBodyQueryAttrs(attrs: object): { from: any; diff --git a/src/headless/types/plugins/muc/utils.d.ts b/src/headless/types/plugins/muc/utils.d.ts index 00f361e4ae..fde5bf8162 100644 --- a/src/headless/types/plugins/muc/utils.d.ts +++ b/src/headless/types/plugins/muc/utils.d.ts @@ -7,6 +7,22 @@ export function getDefaultMUCService(): Promise; */ export function isChatRoom(model: import("@converse/skeletor").Model): boolean; export function shouldCreateGroupchatMessage(attrs: any): any; +/** + * Hook handler for the `getDuplicateMessageQueries` hook. + * + * Adds a query object to match MEP (MUC Extended Presence) activity messages + * by their `msgid`. MEP messages carry an `activities` array and are stored + * with `type: 'mep'`; the standard stanza_id / origin_id queries in + * {@link getDuplicateMessage} do not cover this case, so we contribute the + * extra query here rather than overriding `getDuplicateMessage` in the MUC + * model. + * + * @param {import('../../shared/types').ChatBoxOrMUC} _ + * @param {object[]} queries + * @param {object} attrs + * @returns {object[]} + */ +export function getMUCDuplicateMessageQueries(_: import("../../shared/types").ChatBoxOrMUC, queries: object[], attrs: object): object[]; /** * @param {import('./occupant').default} occupant1 * @param {import('./occupant').default} occupant2 diff --git a/src/headless/types/plugins/reactions/popular-model.d.ts b/src/headless/types/plugins/reactions/popular-model.d.ts deleted file mode 100644 index 6ed62618bf..0000000000 --- a/src/headless/types/plugins/reactions/popular-model.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -export default PopularReactions; -/** - * Model for storing popular reactions with timestamps. - * Tracks when each emoji was last used for a reaction. - */ -declare class PopularReactions extends Model { - constructor(attributes?: Partial, options?: import("@converse/skeletor").ModelOptions); - defaults(): { - timestamps: {}; - }; - initialize(): Promise; - fetched_flag: string; - fetchPopularReactions(): Promise; - /** - * Fetch the user's stored popular reactions from their PEP node and apply them. - * If no item is stored, the default `popular_reactions` setting is left unchanged. - */ - fetchPopularReactionsFromServer(): Promise; - /** - * Parse a list of unicode emoji from a popular-reactions PubSub item and - * merge the received timestamps with local ones, keeping the most recent - * timestamp per emoji (last-write-wins per slot). - * - * @param {Element} stanza - An IQ result or headline message containing the pubsub item - */ - applyPopularReactionsFromStanza(stanza: Element): Promise; - /** - * Record that an emoji was just used, setting its timestamp to now. - * @param {string} shortname - The emoji shortname (e.g., ':thumbsup:') - */ - recordUsage(shortname: string): void; - /** - * Get emojis sorted by most recently used first. - * @param {number} [maxLength=5] - Maximum number of emojis to return - * @returns {string[]} - Array of shortnames sorted by timestamp descending - */ - getSortedEmojis(maxLength?: number): string[]; -} -import { Model } from '@converse/skeletor'; -//# sourceMappingURL=popular-model.d.ts.map \ No newline at end of file diff --git a/src/headless/types/plugins/reactions/utils.d.ts b/src/headless/types/plugins/reactions/utils.d.ts index ad5ee1accc..8cef4589bd 100644 --- a/src/headless/types/plugins/reactions/utils.d.ts +++ b/src/headless/types/plugins/reactions/utils.d.ts @@ -6,15 +6,28 @@ * @typedef {import('../../shared/types').ChatBoxOrMUC} ChatBoxOrMUC * @typedef {import('../../shared/message').default} BaseMessage */ -export function registerPEPPushHandler(): void; /** - * @returns {import('shared/types').StorageKeys} - */ -export function getStorageKeys(): import("shared/types").StorageKeys; -/** - * Clear the popular reactions session data. + * Hook handler for the `getDuplicateMessageQueries` hook. + * + * Adds query objects so that incoming reaction stanzas can be matched against + * the message they target. Per XEP-0444, the `` attribute + * contains the id of the original message. Different clients use different id + * types for this reference: + * + * - The sender's client-assigned stanza id (`msgid` / `origin_id`). + * - The MUC-assigned stanza_id (`stanza_id `), as used by Conversations + * and other compliant clients. + * + * By contributing all three query objects here we ensure a single O(n) scan + * in {@link getDuplicateMessage} covers all cases, with no reaction-specific + * logic leaking into shared code. + * + * @param {ChatBoxOrMUC} chatbox + * @param {object[]} queries + * @param {MessageAttrsWithReactions|MUCMessageAttrsWithReactions} attrs + * @returns {object[]} */ -export function clearSession(): void; +export function getDuplicateMessageQueries(chatbox: ChatBoxOrMUC, queries: object[], attrs: MessageAttrsWithReactions | MUCMessageAttrsWithReactions): object[]; /** * This hook handler merges the incoming single-reactor reactions * with all existing reactions from other reactors, so that no @@ -94,26 +107,6 @@ export function onAfterMessageCreated(chatbox: ChatBoxOrMUC, message: BaseMessag * @returns {string} */ export function getOwnReactionJID(chatbox: ChatBoxOrMUC): string; -/** - * Convert a unicode emoji string to its codepoint key as used in the emoji data - * (e.g. '❤️' → '2764', '👍' → '1f44d'). - * Variation selectors (U+FE0F, U+FE0E) are stripped since the emoji data keys - * do not include them. - * @param {string} emoji - * @returns {string} - */ -export function emojiToCodepointKey(emoji: string): string; -/** - * Publish the given list of emoji+timestamp pairs as the user's popular reactions - * to their private PEP node (XEP-0223). Timestamps follow the XEP-0082 datetime - * profile (ISO 8601 UTC), as used by XEP-0203 delayed delivery. - * - * @param {Array<{emoji: string, stamp: string}>} reactions - Emoji/timestamp pairs to store, sorted most-recent first - */ -export function publishPopularReactions(reactions: Array<{ - emoji: string; - stamp: string; -}>): Promise; export type MessageAttributes = import("../../shared/types").MessageAttributes; export type MUCMessageAttributes = import("../../plugins/muc/types").MUCMessageAttributes; export type MessageAttrsWithReactions = import("./types").MessageAttrsWithReactions; diff --git a/src/headless/types/shared/chatbox.d.ts b/src/headless/types/shared/chatbox.d.ts index 2f873d41d6..9b86213c80 100644 --- a/src/headless/types/shared/chatbox.d.ts +++ b/src/headless/types/shared/chatbox.d.ts @@ -44,18 +44,11 @@ declare const ChatBoxBase_base: { getMostRecentMessage(): import("./message.js").default; getMessageReferencedByError(attrs: object): any; findDanglingRetraction(attrs: object): import("./message.js").default | null; - getDuplicateMessage(attrs: object): import("./message.js").default; + getDuplicateMessage(attrs: object): Promise; getOriginIdQueryAttrs(attrs: object): { origin_id: any; from: any; }; - getReactionQueryAttrs(attrs: object): ({ - origin_id: any; - msgid?: undefined; - } | { - msgid: any; - origin_id?: undefined; - })[]; getStanzaIdQueryAttrs(attrs: object): {}[]; getMessageBodyQueryAttrs(attrs: object): { from: any; diff --git a/src/headless/types/shared/model-with-messages.d.ts b/src/headless/types/shared/model-with-messages.d.ts index 155358dc10..b80b69253a 100644 --- a/src/headless/types/shared/model-with-messages.d.ts +++ b/src/headless/types/shared/model-with-messages.d.ts @@ -164,11 +164,13 @@ export default function ModelWithMessages} */ - getDuplicateMessage(attrs: object): import("./message").default; + getDuplicateMessage(attrs: object): Promise; /** * @param {object} attrs - Attributes representing a received */ @@ -176,16 +178,6 @@ export default function ModelWithMessages; clearSession(_converse: ConversePrivateGlobal): any; - debounce(func: Function, timeout: number): (...args: any[]) => void; + debounce(func: Function, timeout: number): { + (...args: any[]): void; + flush(): void; + }; waitUntil(func: Function, max_wait?: number, check_delay?: number): Promise; getOpenPromise: typeof promise.getOpenPromise; merge(dst: any, src: any): void; diff --git a/src/headless/types/utils/promise.d.ts b/src/headless/types/utils/promise.d.ts index a62651961e..a9ed3fb167 100644 --- a/src/headless/types/utils/promise.d.ts +++ b/src/headless/types/utils/promise.d.ts @@ -1,10 +1,14 @@ /** * Debounces a function by waiting for the timeout period before calling it. * If the function gets called again, the timeout period resets. + * The returned function has a `.flush()` method to invoke immediately. * @param {Function} func * @param {number} timeout */ -export function debounce(func: Function, timeout: number): (...args: any[]) => void; +export function debounce(func: Function, timeout: number): { + (...args: any[]): void; + flush(): void; +}; /** * Creates a {@link Promise} that resolves if the passed in function returns a truthy value. * Rejects if it throws or does not return truthy within the given max_wait. @@ -19,5 +23,5 @@ export function debounce(func: Function, timeout: number): (...args: any[]) => v */ export function waitUntil(func: Function, max_wait?: number, check_delay?: number): Promise; export { getOpenPromise }; -import { getOpenPromise } from "@converse/openpromise"; +import { getOpenPromise } from '@converse/openpromise'; //# sourceMappingURL=promise.d.ts.map \ No newline at end of file diff --git a/src/headless/utils/html.js b/src/headless/utils/html.js index 31e395d2bc..0f15b0a8b4 100644 --- a/src/headless/utils/html.js +++ b/src/headless/utils/html.js @@ -40,7 +40,13 @@ function stripEmptyTextNodes(el) { * @returns {Boolean} */ export function isEqualNode(actual, expected) { - if (!isElement(actual)) throw new Error('Element being compared must be an Element!'); + if (!isElement(actual)) { + if (actual instanceof Strophe.Builder) { + actual = actual.tree(); + } else { + throw new Error('Element being compared must be an Element!'); + } + } expected = stripEmptyTextNodes(expected); actual = stripEmptyTextNodes(actual); diff --git a/src/headless/utils/promise.js b/src/headless/utils/promise.js index 40cebce65a..a39ac369af 100644 --- a/src/headless/utils/promise.js +++ b/src/headless/utils/promise.js @@ -1,22 +1,35 @@ -import log from "@converse/log"; -import { getOpenPromise } from "@converse/openpromise"; +import log from '@converse/log'; +import { getOpenPromise } from '@converse/openpromise'; export { getOpenPromise }; /** * Debounces a function by waiting for the timeout period before calling it. * If the function gets called again, the timeout period resets. + * The returned function has a `.flush()` method to invoke immediately. * @param {Function} func * @param {number} timeout */ export function debounce(func, timeout) { let timer; - return function (...args) { + let lastArgs = []; + let lastThis; + + function debounced(...args) { + lastArgs = args; + lastThis = this; clearTimeout(timer); timer = setTimeout(() => { - func.apply(this, args); + func.apply(lastThis, lastArgs); }, timeout); + } + + debounced.flush = function () { + clearTimeout(timer); + func.apply(lastThis, lastArgs); }; + + return debounced; } /** diff --git a/src/plugins/chatview/tests/chatbox.js b/src/plugins/chatview/tests/chatbox.js index cd291ad359..c1c9879213 100644 --- a/src/plugins/chatview/tests/chatbox.js +++ b/src/plugins/chatview/tests/chatbox.js @@ -272,7 +272,7 @@ describe('Chatboxes', function () { const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists')); const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a')); item.click(); - await u.waitUntil(() => counter.textContent === '179'); + await u.waitUntil(() => counter.textContent === '177'); const textarea = view.querySelector('.chat-textarea'); const ev = { diff --git a/src/plugins/chatview/tests/emojis.js b/src/plugins/chatview/tests/emojis.js index 5cc088d3ae..3794e2cef0 100644 --- a/src/plugins/chatview/tests/emojis.js +++ b/src/plugins/chatview/tests/emojis.js @@ -2,9 +2,92 @@ const { stx } = converse.env; const u = converse.env.utils; +const { Strophe, sizzle } = converse.env; const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; describe('Emojis', function () { + beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); + + describe('Emoji PubSub', function () { + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + it( + 'publishes emoji usage to pubsub PEP node when an emoji is sent', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitUntilDiscoConfirmed( + _converse, + _converse.bare_jid, + [{ 'category': 'pubsub', 'type': 'pep' }], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + 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); + + // Type an emoji shortname and send the message + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':thumbsup:'; + 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); + + // Yield to let the async sendMessage event handler run + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify the emoji was actually recorded + const popular_emojis = _converse.state.popular_emojis; + expect(Object.keys(popular_emojis.get('timestamps')).length).toBeGreaterThan(0); + + // Flush the debounced publish so we can assert on it immediately + popular_emojis.debouncedPublish.flush(); + + // Wait for the pubsub publish stanza + const sent_stanzas = _converse.api.connection.get().sent_stanzas; + const sent_stanza = await u.waitUntil(() => + sent_stanzas.find( + (iq) => sizzle(`pubsub publish[node="${Strophe.NS.REACTIONS_POPULAR}"]`, iq).length + ) + ); + + expect(sent_stanza).toEqualStanza(stx` + + + + + + 👍 + + + + + + + http://jabber.org/protocol/pubsub#publish-options + + + true + + whitelist + + + + `); + }) + ); + }); + describe('The emoji picker', function () { beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000)); afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); @@ -22,9 +105,101 @@ describe('Emojis', function () { await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000); const item = view.querySelector('.emoji-picker li.insert-emoji a'); item.click(); - expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); + expect(view.querySelector('textarea.chat-textarea').value).toBe(':thumbsup: '); toolbar.querySelector('.toggle-emojis').click(); // Close the panel again - }), + }) + ); + + it( + 'renders the popular category with recently used emojis', + mock.initConverse(['chatBoxesFetched'], {}, async function (_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); + + // Populate the popular_emojis model with known timestamps + const popular_emojis = _converse.state.popular_emojis; + popular_emojis.save({ + timestamps: { + '👍': '2026-03-29T12:00:00.000Z', + '❤️': '2026-03-29T11:00:00.000Z', + '😂': '2026-03-29T10:00:00.000Z', + }, + }); + + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000); + + const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + + // The popular category tab should exist in the header + const popular_tab = picker.querySelector('.emoji-category[data-category="popular"]'); + expect(popular_tab).toBeDefined(); + + // Click the popular category tab + const popular_link = popular_tab.querySelector('.pick-category'); + expect(popular_link).toBeDefined(); + popular_link.click(); + + // Wait for the popular category content to be visible + const popular_heading = await u.waitUntil(() => picker.querySelector('#emoji-picker-popular'), 1000); + expect(popular_heading).toBeDefined(); + + // The popular emoji list should contain the 3 emojis we set + // plus two from the default config + const popular_list = picker.querySelector('ul.emoji-picker[data-category="popular"]'); + expect(popular_list).toBeDefined(); + const emoji_items = popular_list.querySelectorAll('li.insert-emoji'); + expect(emoji_items.length).toBe(5); + + // Verify the emojis are in the correct order (most recent first) + const emoji_data = Array.from(emoji_items).map((el) => el.getAttribute('data-emoji')); + expect(emoji_data).toEqual([':thumbsup:', ':heart:', ':joy:', ':laughing:', ':tada:']); + + toolbar.querySelector('.toggle-emojis').click(); + }) + ); + + it( + 'shows the default popular emojis when there is no usage history', + mock.initConverse(['chatBoxesFetched'], {}, async function (_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); + + // Ensure no timestamps exist + const popular_emojis = _converse.state.popular_emojis; + expect(Object.keys(popular_emojis.get('timestamps')).length).toBe(0); + + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000); + + const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + + // The popular category tab should still exist + const popular_tab = picker.querySelector('.emoji-category[data-category="popular"]'); + expect(popular_tab).toBeDefined(); + + // Click the popular category tab + const popular_link = popular_tab.querySelector('.pick-category'); + popular_link.click(); + + // The popular emoji list should exist but contain no items + const popular_list = await u.waitUntil( + () => picker.querySelector('ul.emoji-picker[data-category="popular"]'), + 1000 + ); + const emoji_items = popular_list.querySelectorAll('li.insert-emoji'); + expect(emoji_items.length).toBe(5); + + // Verify the emojis are in the correct order (most recent first) + const emoji_data = Array.from(emoji_items).map((el) => el.getAttribute('data-emoji')); + expect(emoji_data).toEqual([':thumbsup:', ':heart:', ':laughing:', ':joy:', ':tada:']); + }) ); }); @@ -42,7 +217,7 @@ describe('Emojis', function () { xmlns="jabber:client"> 😇 - `, + ` ); await new Promise((resolve) => _converse.on('chatBoxViewInitialized', resolve)); const view = _converse.chatboxviews.get(sender_jid); @@ -57,7 +232,7 @@ describe('Emojis', function () { xmlns="jabber:client"> 😇 Hello world! 😇 😇 - `, + ` ); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); @@ -97,8 +272,8 @@ describe('Emojis', function () { await u.waitUntil( () => Array.from(view.querySelectorAll('.chat-msg__text')).filter( - (el) => el.textContent === edited_text, - ).length, + (el) => el.textContent === edited_text + ).length ); expect(view.model.messages.models.length).toBe(3); let message = view.querySelector(last_msg_sel); @@ -122,7 +297,7 @@ describe('Emojis', function () { message = view.querySelector('.message:last-child .chat-msg__text'); expect(u.hasClass('chat-msg__text--larger', message)).toBe(true); - }), + }) ); it( @@ -138,7 +313,7 @@ describe('Emojis', function () { xmlns="jabber:client"> 😇 - `, + ` ); await new Promise((resolve) => _converse.on('chatBoxViewInitialized', resolve)); const view = _converse.chatboxviews.get(contact_jid); @@ -146,7 +321,7 @@ describe('Emojis', function () { await u.waitUntil( () => view.querySelector('.chat-msg__text').innerHTML.replace(//g, '') === - '😇', + '😇' ); const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; @@ -175,7 +350,7 @@ describe('Emojis', function () { const sent_stanzas = _converse.api.connection.get().sent_stanzas; const sent_stanza = sent_stanzas.filter((s) => s.nodeName === 'message').pop(); expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇'); - }), + }) ); it( @@ -208,7 +383,7 @@ describe('Emojis', function () { const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000); const custom_category = picker.querySelector('.pick-category[data-category="custom"]'); expect(custom_category.innerHTML.replace(//g, '').trim()).toBe( - ':xmpp:', + ':xmpp:' ); const textarea = view.querySelector('textarea.chat-textarea'); @@ -224,10 +399,10 @@ describe('Emojis', function () { await u.waitUntil( () => body.innerHTML.replace(//g, '').trim() === - 'Running tests for :converse:', + 'Running tests for :converse:' ); - }, - ), + } + ) ); it( @@ -289,8 +464,8 @@ describe('Emojis', function () { const message = view.model.messages.last(); expect(message.get('body')).toBe('Look at :penguin3: here'); - }, - ), + } + ) ); }); }); diff --git a/src/plugins/chatview/tests/message-avatar.js b/src/plugins/chatview/tests/message-avatar.js index cc933226ea..93ef4e190b 100644 --- a/src/plugins/chatview/tests/message-avatar.js +++ b/src/plugins/chatview/tests/message-avatar.js @@ -78,7 +78,7 @@ describe('A Chat Message', function () { view.querySelector( `converse-chat-message div[data-from="${contact_jid}"] converse-avatar svg image`, ), - 1000, + 2000, ); expect(el.getAttribute('href')).toBe(`data:image/svg+xml;base64,${image}`); diff --git a/src/plugins/muc-views/tests/commands.js b/src/plugins/muc-views/tests/commands.js index 292f955d77..4d3562185a 100644 --- a/src/plugins/muc-views/tests/commands.js +++ b/src/plugins/muc-views/tests/commands.js @@ -188,13 +188,15 @@ describe('Groupchats', function () { _converse.api.connection.get()._dataRecv( mock.createRequest( stx` - - - - `, + xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="lounge@muc.montague.lit/marc"> + + + + `, ), ); await u.waitUntil(() => muc.occupants.length === 2); @@ -233,12 +235,12 @@ describe('Groupchats', function () { expect(sent_stanza).toEqualStanza( stx` - - - Welcome to the club! - - - `, + + + Welcome to the club! + + + `, ); let result = stx`` + - `` + - `` + - `` + - ``, - ); + expect(iq_stanza).toEqualStanza( + stx` + + `); expect(view.model.occupants.length).toBe(2); result = stx` - - - - `; + type="result" + to="romeo@montague.lit/orchard" + from="lounge@muc.montague.lit" + id="${iq_stanza.getAttribute('id')}"> + + + + `; _converse.api.connection.get()._dataRecv(mock.createRequest(result)); expect(view.model.occupants.length).toBe(2); @@ -287,24 +286,21 @@ describe('Groupchats', function () { .pop(), ); - expect(Strophe.serialize(iq_stanza)).toBe( - `` + - `` + - `` + - `` + - ``, - ); + expect(iq_stanza).toEqualStanza( + stx` + + `); expect(view.model.occupants.length).toBe(2); result = stx` - - - - `; + type="result" + to="romeo@montague.lit/orchard" + from="lounge@muc.montague.lit" + id="${iq_stanza.getAttribute('id')}"> + + + + `; _converse.api.connection.get()._dataRecv(mock.createRequest(result)); expect(view.model.occupants.length).toBe(2); @@ -317,13 +313,10 @@ describe('Groupchats', function () { .pop(), ); - expect(Strophe.serialize(iq_stanza)).toBe( - `` + - `` + - `` + - `` + - ``, - ); + expect(iq_stanza).toEqualStanza( + stx` + + `); expect(view.model.occupants.length).toBe(2); result = stx` `; _converse.api.connection.get()._dataRecv(mock.createRequest(result)); - await u.waitUntil(() => view.querySelectorAll('.occupant').length, 500); + + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome + view.model.save('hidden_occupants', false); + } + await u.waitUntil(() => view.querySelectorAll('.occupant').length); await u.waitUntil(() => view.querySelectorAll('.badge').length > 2); expect(view.model.occupants.length).toBe(2); expect(view.querySelectorAll('.occupant').length).toBe(2); @@ -403,9 +401,9 @@ describe('Groupchats', function () { }); sent_stanza = await u.waitUntil(() => sent_stanzas.pop()); expect(sent_stanza).toEqualStanza(stx` - - - `); + + + `); }), ); diff --git a/src/plugins/muc-views/tests/emojis.js b/src/plugins/muc-views/tests/emojis.js index e98d41c314..99cb228dae 100644 --- a/src/plugins/muc-views/tests/emojis.js +++ b/src/plugins/muc-views/tests/emojis.js @@ -189,7 +189,7 @@ describe('Emojis', function () { toolbar.querySelector('.toggle-emojis').click(); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); await u.waitUntil( - () => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589, + () => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length > 500, ); const input = view.querySelector('.emoji-search'); diff --git a/src/plugins/muc-views/tests/location.js b/src/plugins/muc-views/tests/location.js index 5ef4ef33e4..b07bd0c5d0 100644 --- a/src/plugins/muc-views/tests/location.js +++ b/src/plugins/muc-views/tests/location.js @@ -68,6 +68,11 @@ describe('The Location Button', function () { `), ); + + if (view.model.get('hidden_occupants')) { + // Happens in headless chrme + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list converse-avatar').length === 2); // Click the occupant avatar to open the PM panel in the sidebar diff --git a/src/plugins/muc-views/tests/muc-private-messages.js b/src/plugins/muc-views/tests/muc-private-messages.js index be20637231..b582f205b1 100644 --- a/src/plugins/muc-views/tests/muc-private-messages.js +++ b/src/plugins/muc-views/tests/muc-private-messages.js @@ -23,11 +23,15 @@ describe('MUC Private Messages', () => { const view = _converse.chatboxviews.get(muc_jid); await u.waitUntil(() => view.model.occupants.length === 2); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } const avatar_el = await u.waitUntil(() => - view.querySelector('.occupant-list converse-avatar[name="firstwitch"]'), + view.querySelector('.occupant-list converse-avatar[name="firstwitch"]') ); avatar_el.click(); - }), + }) ); describe('When receiving a MUC private message', () => { @@ -49,7 +53,7 @@ describe('MUC Private Messages', () => { - `), + `) ); await u.waitUntil(() => view.model.occupants.length === 2); @@ -63,7 +67,7 @@ describe('MUC Private Messages', () => { I'll give thee a wind. - `), + `) ); _converse.api.connection.get()._dataRecv( @@ -75,7 +79,7 @@ describe('MUC Private Messages', () => { xmlns="jabber:client"> Harpier cries: "tis time, "tis time. - `), + `) ); await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); @@ -87,7 +91,7 @@ describe('MUC Private Messages', () => { expect(occupant.get('num_unread')).toBe(1); expect(occupant.messages.length).toBe(1); expect(occupant.messages.pop().get('message')).toBe("I'll give thee a wind."); - }), + }) ); }); @@ -111,8 +115,12 @@ describe('MUC Private Messages', () => { - `), + `) ); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list converse-avatar').length === 2); // Open the occupant view in the sidebar @@ -125,7 +133,7 @@ describe('MUC Private Messages', () => { button.click(); await u.waitUntil( - () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length, + () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length ); const sent_stanza = api.connection.get().sent_stanzas.pop(); @@ -141,7 +149,7 @@ describe('MUC Private Messages', () => { `); - }), + }) ); it( @@ -163,8 +171,12 @@ describe('MUC Private Messages', () => { - `), + `) ); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list converse-avatar').length === 2); // Open the occupant view in the sidebar @@ -174,14 +186,14 @@ describe('MUC Private Messages', () => { occupant.sendMessage({ body: 'hello world' }); await u.waitUntil( - () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length, + () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length ); const avatar = view.querySelector('converse-muc-occupant converse-chat-message converse-avatar'); expect(avatar).toBeDefined(); expect(avatar.getAttribute('name')).toBe('romeo'); expect(avatar.model).toBe(view.model.getOccupant('romeo')); - }), + }) ); describe('And an error is returned', () => { @@ -204,8 +216,12 @@ describe('MUC Private Messages', () => { - `), + `) ); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list converse-avatar').length === 2); // Open the occupant view in the sidebar @@ -215,7 +231,7 @@ describe('MUC Private Messages', () => { occupant.sendMessage({ body: 'hello world' }); await u.waitUntil( - () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length, + () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length ); const sent_stanza = api.connection.get().sent_stanzas.pop(); @@ -232,13 +248,13 @@ describe('MUC Private Messages', () => { ${err_msg_text} - `), + `) ); expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe( - `Message delivery failed.\n${err_msg_text}`, + `Message delivery failed.\n${err_msg_text}` ); - }), + }) ); }); }); diff --git a/src/plugins/muc-views/tests/muc.js b/src/plugins/muc-views/tests/muc.js index 98514263ba..de9bf1ff47 100644 --- a/src/plugins/muc-views/tests/muc.js +++ b/src/plugins/muc-views/tests/muc.js @@ -1634,6 +1634,11 @@ describe('Groupchats', function () { const view = _converse.chatboxviews.get('lounge@montague.lit'); expect(view.model.getOwnAffiliation()).toBe('owner'); expect(view.model.features.get('open')).toBe(false); + + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelector('.open-invite-modal')); // Members can't invite if the room isn't open diff --git a/src/plugins/muc-views/tests/nickname.js b/src/plugins/muc-views/tests/nickname.js index e08aa2c152..87c150a877 100644 --- a/src/plugins/muc-views/tests/nickname.js +++ b/src/plugins/muc-views/tests/nickname.js @@ -40,7 +40,7 @@ describe('A MUC', function () { stx``, + xmlns="jabber:client"/>` ); // clear sent stanzas @@ -65,8 +65,8 @@ describe('A MUC', function () { - `, - ), + ` + ) ); await u.waitUntil(() => model.get('nick') === newnick); @@ -86,8 +86,8 @@ describe('A MUC', function () { role='participant'/> - `, - ), + ` + ) ); await u.waitUntil(() => model.occupants.at(0).get('nick') === newnick); @@ -95,15 +95,15 @@ describe('A MUC', function () { let stanza = await u.waitUntil(() => IQ_stanzas.find( - (iq) => sizzle(`iq[type="get"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length, - ), + (iq) => sizzle(`iq[type="get"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length + ) ); expect(stanza).toEqualStanza( stx``, + id="${stanza.getAttribute('id')}">` ); _converse.api.connection.get()._dataRecv( @@ -128,14 +128,14 @@ describe('A MUC', function () { - `, - ), + ` + ) ); stanza = await u.waitUntil(() => IQ_stanzas.find( - (iq) => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length, - ), + (iq) => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.MUC_REGISTER}"]`, iq).length + ) ); expect(stanza).toEqualStanza( @@ -146,10 +146,10 @@ describe('A MUC', function () { loverboy - `, + ` ); - }, - ), + } + ) ); it( @@ -194,6 +194,11 @@ describe('A MUC', function () { await mock.openAndEnterMUC(_converse, 'lounge@montague.lit', 'oldnick'); const view = _converse.chatboxviews.get('lounge@montague.lit'); + + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('li .occupant-nick').length, 500); let occupants = view.querySelector('.occupant-list'); expect(occupants.querySelectorAll('.occupant-nick').length).toBe(1); @@ -223,7 +228,7 @@ describe('A MUC', function () { await u.waitUntil(() => view.querySelectorAll('.chat-info').length); expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe( - __(_converse.labels.muc.STATUS_CODE_MESSAGES['303'], 'newnick'), + __(_converse.labels.muc.STATUS_CODE_MESSAGES['303'], 'newnick') ); expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); @@ -248,13 +253,13 @@ describe('A MUC', function () { expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); expect(view.querySelectorAll('div.chat-info').length).toBe(1); expect(sizzle('div.chat-info', view)[0].textContent.trim()).toBe( - __(_converse.labels.muc.STATUS_CODE_MESSAGES['303'], 'newnick'), + __(_converse.labels.muc.STATUS_CODE_MESSAGES['303'], 'newnick') ); occupants = view.querySelector('.occupant-list'); await u.waitUntil(() => sizzle('.occupant-nick:first', occupants).pop().textContent.trim() === 'newnick'); expect(view.model.occupants.length).toBe(1); expect(view.model.get('nick')).toBe('newnick'); - }), + }) ); describe('when being entered', function () { @@ -268,8 +273,8 @@ describe('A MUC', function () { const iq = await u.waitUntil(() => IQ_stanzas.filter( - (s) => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length, - ).pop(), + (s) => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length + ).pop() ); expect(iq).toEqualStanza(stx` @@ -321,7 +326,7 @@ describe('A MUC', function () { await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); const info_text = sizzle('.chat-content .chat-info:first', view).pop().textContent.trim(); expect(info_text).toBe('Your nickname has been automatically set to thirdwitch'); - }), + }) ); it( @@ -340,8 +345,8 @@ describe('A MUC', function () { await mock.waitForReservedNick(_converse, muc_jid, ''); const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch'); - }, - ), + } + ) ); it( @@ -364,15 +369,15 @@ describe('A MUC', function () { - `), + `) ); const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message')); expect(el.textContent.trim()).toBe( - 'The nickname you chose is reserved or currently in use, please choose a different one.', + 'The nickname you chose is reserved or currently in use, please choose a different one.' ); - }), + }) ); it( @@ -390,7 +395,7 @@ describe('A MUC', function () { await u.waitUntil(() => sent_stanzas .filter((s) => s.nodeName === 'presence' && s.getAttribute('to').startsWith(muc_jid)) - .pop(), + .pop() ); const { IQ_stanzas } = api.connection.get(); @@ -418,7 +423,7 @@ describe('A MUC', function () { let sent_stanza = await u.waitUntil(() => sent_stanzas .filter((s) => s.nodeName === 'presence' && s.getAttribute('to').startsWith(muc_jid)) - .pop(), + .pop() ); expect(sent_stanza).toEqualStanza(stx` sent_stanzas .filter((s) => s.nodeName === 'presence' && s.getAttribute('to').startsWith(muc_jid)) - .pop(), + .pop() ); expect(sent_stanza).toEqualStanza(stx` sent_stanzas .filter((s) => s.nodeName === 'presence' && s.getAttribute('to').startsWith(muc_jid)) - .pop(), + .pop() ); expect(sent_stanza).toEqualStanza(stx` > `); - }), + }) ); it( @@ -524,10 +529,10 @@ describe('A MUC', function () { const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); const el = await u.waitUntil(() => - view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'), + view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child') ); expect(el.textContent.trim()).toBe("Your nickname doesn't conform to this groupchat's policies."); - }), + }) ); it( @@ -558,8 +563,8 @@ describe('A MUC', function () { await u.waitUntil(() => _converse.chatboxes.length > 1); const chatroom = _converse.chatboxes.get(muc_jid); expect(chatroom.get('nick')).toBe('romeo'); - }, - ), + } + ) ); it( @@ -582,8 +587,8 @@ describe('A MUC', function () { expect(label_nick.textContent.trim()).toBe('Nickname:'); const nick_input = modal.querySelector('input[name="nickname"]'); expect(nick_input.value).toBe('romeo'); - }, - ), + } + ) ); }); }); diff --git a/src/plugins/muc-views/tests/occupants-filter.js b/src/plugins/muc-views/tests/occupants-filter.js index 9f16470538..001d60dccb 100644 --- a/src/plugins/muc-views/tests/occupants-filter.js +++ b/src/plugins/muc-views/tests/occupants-filter.js @@ -43,7 +43,11 @@ describe('The MUC occupants filter', function () { _converse.api.connection.get()._dataRecv(mock.createRequest(presence)); } - const occupants = view.querySelector('.occupant-list'); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } + const occupants = await u.waitUntil(() => view.querySelector('.occupant-list')); await u.waitUntil(() => occupants.querySelectorAll('li').length > 3); expect(occupants.querySelectorAll('li').length).toBe(3 + mock.chatroom_names.length); expect(view.model.occupants.length).toBe(3 + mock.chatroom_names.length); diff --git a/src/plugins/muc-views/tests/occupants.js b/src/plugins/muc-views/tests/occupants.js index 64d05b85ec..2b6c5df107 100644 --- a/src/plugins/muc-views/tests/occupants.js +++ b/src/plugins/muc-views/tests/occupants.js @@ -34,7 +34,11 @@ describe('The occupants sidebar', function () { _converse.api.connection.get()._dataRecv(mock.createRequest(presence)); } - const occupants = view.querySelector('.occupant-list'); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } + const occupants = await u.waitUntil(() => view.querySelector('.occupant-list')); await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500); expect(occupants.querySelectorAll('li').length).toBe(2 + mock.chatroom_names.length); expect(view.model.occupants.length).toBe(2 + mock.chatroom_names.length); @@ -81,7 +85,7 @@ describe('The occupants sidebar', function () { expect(view.model.occupants.length).toBe(8); view.model.session.set('connection_status', converse.ROOMSTATUS.ENTERED); // Hack await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length === 8); - }), + }) ); it( @@ -89,7 +93,6 @@ describe('The occupants sidebar', function () { mock.initConverse([], {}, async function (_converse) { await mock.openAndEnterMUC(_converse, 'lounge@montague.lit', 'romeo'); var view = _converse.chatboxviews.get('lounge@montague.lit'); - const occupants = view.querySelector('.occupant-list'); for (var i = 0; i < mock.chatroom_names.length; i++) { const name = mock.chatroom_names[i]; // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres @@ -106,7 +109,12 @@ describe('The occupants sidebar', function () { _converse.api.connection.get()._dataRecv(mock.createRequest(presence)); } - await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } + const occupants = await u.waitUntil(() => view.querySelector('.occupant-list')); + await u.waitUntil(() => occupants.querySelectorAll('li').length > 1); expect(occupants.querySelectorAll('li').length).toBe(1 + mock.chatroom_names.length); mock.chatroom_names.forEach((name) => { @@ -133,7 +141,7 @@ describe('The occupants sidebar', function () { _converse.api.connection.get()._dataRecv(mock.createRequest(presence)); } await u.waitUntil(() => occupants.querySelectorAll('li').length === 1); - }), + }) ); it( @@ -143,6 +151,10 @@ describe('The occupants sidebar', function () { const view = _converse.chatboxviews.get('lounge@montague.lit'); let contact_jid = mock.cur_names[2].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length, 500); let occupants = view.querySelectorAll('.occupant-list li'); expect(occupants.length).toBe(1); @@ -169,7 +181,7 @@ describe('The occupants sidebar', function () { expect(occupants.length).toBe(2); expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe('moderatorman'); expect(occupants[0].querySelector('.occupant-nick').getAttribute('title')).toBe( - contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.', + contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.' ); expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe('romeo'); expect(occupants[0].querySelectorAll('.badge').length).toBe(2); @@ -197,11 +209,11 @@ describe('The occupants sidebar', function () { expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe('visitorwoman'); expect(occupants[2].querySelector('.occupant-nick').getAttribute('title')).toBe( contact_jid + - ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.', + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.' ); expect(occupants[2].querySelectorAll('.badge').length).toBe(1); expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('V'); expect(sizzle('.badge', occupants[2]).pop().getAttribute('title').trim()).toBe('Visitor'); - }), + }) ); }); diff --git a/src/plugins/muc-views/tests/xss.js b/src/plugins/muc-views/tests/xss.js index e8685ef7b7..b2c3844c5b 100644 --- a/src/plugins/muc-views/tests/xss.js +++ b/src/plugins/muc-views/tests/xss.js @@ -21,6 +21,10 @@ describe('XSS', function () { _converse.api.connection.get()._dataRecv(mock.createRequest(presence)); const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (view.model.get('hidden_occupants')) { + // Happens in headless chrome due to smaller viewport size + view.model.save('hidden_occupants', false); + } await u.waitUntil(() => view.querySelectorAll('.occupant-list .occupant-nick').length === 2); const occupants = view.querySelectorAll('.occupant-list li .occupant-nick'); expect(occupants.length).toBe(2); diff --git a/src/plugins/reactions-views/plugin.js b/src/plugins/reactions-views/plugin.js index 9dff5ff73a..df066d7bdb 100644 --- a/src/plugins/reactions-views/plugin.js +++ b/src/plugins/reactions-views/plugin.js @@ -17,11 +17,6 @@ converse.plugins.add('converse-reaction-views', { * - Disco feature advertisement and restrictions */ initialize() { - api.settings.extend({ - 'popular_reactions': [':thumbsup:', ':heart:', ':laughing:', ':joy:', ':tada:'] - }); - - // Advertise reactions support api.listen.on('addClientFeatures', () => { api.disco.own.features.add(Strophe.NS.REACTIONS); }); diff --git a/src/plugins/reactions-views/reaction-picker.js b/src/plugins/reactions-views/reaction-picker.js index 819c431530..7ad757e7c3 100644 --- a/src/plugins/reactions-views/reaction-picker.js +++ b/src/plugins/reactions-views/reaction-picker.js @@ -12,7 +12,7 @@ import { CustomElement } from 'shared/components/element.js'; import { api, u, _converse, EmojiPicker } from '@converse/headless'; import { __ } from 'i18n'; import tplReactionPicker from './templates/reaction-picker.js'; -import { sendReaction } from './utils.js'; +import { sendReaction, getPopularReactions } from './utils.js'; import 'shared/components/dropdown.js'; import 'shared/chat/emoji-picker.js'; import 'shared/chat/styles/emoji.scss'; @@ -25,6 +25,7 @@ export default class ReactionPicker extends CustomElement { 'dropup': { type: Boolean }, 'shifted': { type: Boolean }, 'opened': { type: Boolean }, + 'popular_reactions_promise': { state: true }, }; } @@ -39,6 +40,7 @@ export default class ReactionPicker extends CustomElement { this.dropup = false; this.shifted = false; this.opened = false; + this.popular_reactions_promise = null; this.onClickOutside = /** @param {MouseEvent} ev */ (ev) => { @@ -78,12 +80,14 @@ export default class ReactionPicker extends CustomElement { const btn = /** @type {HTMLElement} */ (ev.currentTarget ?? ev.target); this.#anchor_rect = btn?.getBoundingClientRect() ?? null; await api.emojis.initialize(); + this.popular_reactions_promise = getPopularReactions(this.allowed_emojis); this.opened = true; } close() { if (!this.opened) return; this.#anchor_rect = null; + this.popular_reactions_promise = null; this.opened = false; } diff --git a/src/plugins/reactions-views/templates/reaction-picker.js b/src/plugins/reactions-views/templates/reaction-picker.js index 5fe8c1a207..e54205c511 100644 --- a/src/plugins/reactions-views/templates/reaction-picker.js +++ b/src/plugins/reactions-views/templates/reaction-picker.js @@ -1,47 +1,23 @@ import { html } from 'lit'; -import { _converse, api, u } from '@converse/headless'; +import { until } from 'lit/directives/until.js'; /** * @param {import('../reaction-picker').default} el */ export default (el) => { - // Use the PopularReactions model if available, otherwise fall back to the setting - const popular_reactions = _converse.state.popular_reactions; - let popular_emojis; - - const default_setting = api.settings.get('popular_reactions') ?? []; - if (popular_reactions && Object.keys(popular_reactions.get('timestamps') || {}).length > 0) { - // Get the most recently used emojis from the model, then pad with - // emojis from the configured setting to fill up to the expected length. - const sorted = popular_reactions.getSortedEmojis(default_setting.length); - const padded = [...sorted]; - for (const sn of default_setting) { - if (padded.length >= default_setting.length) break; - if (!padded.includes(sn)) padded.push(sn); - } - popular_emojis = padded; - } else { - // No history yet — show the configured default list - popular_emojis = default_setting; - } - const anchor_name = `--reaction-anchor-${el.picker_id}`; - const filtered_emojis = el.allowed_emojis - ? popular_emojis.filter( - /** @param {string} sn */ (sn) => - el.allowed_emojis.includes(u.shortnamesToEmojis(sn, { unicode_only: true }).join('')), - ) - : popular_emojis; + + const renderReactions = async () => { + const popular_reactions = await el.popular_reactions_promise; + return html`${popular_reactions.map( + /** @param {string} sn */ (sn) => + html`` + )}`; + }; return html`