From 9ea9bf40359fcf2c0926b2e55073f5ee63072f40 Mon Sep 17 00:00:00 2001 From: Emi Date: Tue, 28 Dec 2021 23:26:17 -0500 Subject: [PATCH] Add support for sending room-local emoji (#209) * Add support for sending room-local emoji Does not add support for sending a room's emoji outside of that room, but enables users to send an emoji if the packs in a room support it. Does not include room emoji in the picker YET. * Amend PR #209: Don't freak out if the `pack` tag is missing * Amending PR: Refactor emojifier, use better method for retrieving packs * Amending PR: Improve resiliance to bad data in emoji state events * Amend PR: Remove redundant code, fix crash on edit --- src/app/organisms/emoji-board/custom-emoji.js | 142 +++++++++++++++--- src/app/organisms/room/RoomViewCmdBar.jsx | 2 +- src/client/state/RoomsInput.js | 106 +++++++------ 3 files changed, 174 insertions(+), 76 deletions(-) diff --git a/src/app/organisms/emoji-board/custom-emoji.js b/src/app/organisms/emoji-board/custom-emoji.js index b847bd43..650a9620 100644 --- a/src/app/organisms/emoji-board/custom-emoji.js +++ b/src/app/organisms/emoji-board/custom-emoji.js @@ -10,26 +10,114 @@ import { emojis } from './emoji'; // globally, while emojis and packs in rooms and spaces should only be available within // those spaces and rooms -// Retrieve a list of user emojis -// -// Result is a list of objects, each with a shortcode and an mxc property -// -// Accepts a reference to a matrix client as the only argument -function getUserEmoji(mx) { - const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes'); - if (!accountDataEmoji) { - return []; +class ImagePack { + // Convert a raw image pack into a more maliable format + // + // Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a + // format used here, while filling in defaults. + // + // The room argument is the room the pack exists in, which is used as a fallback for + // missing properties + // + // Returns `null` if the rawPack is not a properly formatted image pack, although there + // is still a fair amount of tolerance for malformed packs. + static parsePack(rawPack, room) { + if (typeof rawPack.images === 'undefined') { + return null; + } + + const pack = rawPack.pack ?? {}; + + const displayName = pack.display_name ?? (room ? room.name : undefined); + const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined); + const usage = pack.usage ?? ['emoticon', 'sticker']; + const { attribution } = pack; + const images = Object.entries(rawPack.images).flatMap((e) => { + const data = e[1]; + const shortcode = e[0]; + const mxc = data.url; + const body = data.body ?? shortcode; + const { info } = data; + const usage_ = data.usage ?? usage; + + if (mxc) { + return [{ + shortcode, mxc, body, info, usage: usage_, + }]; + } + return []; + }); + + return new ImagePack(displayName, avatar, usage, attribution, images); } - const { images } = accountDataEmoji.event.content; - const mapped = Object.entries(images).map((e) => ({ - shortcode: e[0], - mxc: e[1].url, - })); - return mapped; + constructor(displayName, avatar, usage, attribution, images) { + this.displayName = displayName; + this.avatar = avatar; + this.usage = usage; + this.attribution = attribution; + this.images = images; + } + + // Produce a list of emoji in this image pack + getEmojis() { + return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1); + } + + // Produce a list of stickers in this image pack + getStickers() { + return this.images.filter((i) => i.usage.indexOf('sticker') !== -1); + } } -// Returns all user emojis and all standard unicode emojis +// Retrieve a list of user emojis +// +// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal +// image pack. +// +// Accepts a reference to a matrix client as the only argument +function getUserImagePack(mx) { + const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes'); + if (!accountDataEmoji) { + return null; + } + + const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content); + if (userImagePack) userImagePack.displayName ??= 'Your Emoji'; + return userImagePack; +} + +// Produces a list of all of the emoji packs in a room +// +// Returns a list of `ImagePack`s. This does not include packs in spaces that contain +// this room. +function getPacksInRoom(room) { + const packs = room.currentState.getStateEvents('im.ponies.room_emotes'); + + return packs + .map((p) => ImagePack.parsePack(p.event.content, room)) + .filter((p) => p !== null); +} + +// Produce a list of all image packs which should be shown for a given room +// +// This includes packs in that room, the user's personal images, and will eventually +// include the user's enabled global image packs and space-level packs. +// +// This differs from getPacksInRoom, as the former only returns packs that are directly in +// a room, whereas this function returns all packs which should be shown to the user while +// they are in this room. +// +// Packs will be returned in the order that shortcode conflicts should be resolved, with +// higher priority packs coming first. +function getRelevantPacks(room) { + return [].concat( + getUserImagePack(room.client) ?? [], + getPacksInRoom(room), + ); +} + +// Returns all user+room emojis and all standard unicode emojis // // Accepts a reference to a matrix client as the only argument // @@ -37,7 +125,7 @@ function getUserEmoji(mx) { // shortcode, only one will be presented, with priority given to custom emoji. // // Will eventually be expanded to include all emojis revelant to a room and the user -function getShortcodeToEmoji(mx) { +function getShortcodeToEmoji(room) { const allEmoji = new Map(); emojis.forEach((emoji) => { @@ -50,9 +138,11 @@ function getShortcodeToEmoji(mx) { } }); - getUserEmoji(mx).forEach((emoji) => { - allEmoji.set(emoji.shortcode, emoji); - }); + getRelevantPacks(room).reverse() + .flatMap((pack) => pack.getEmojis()) + .forEach((emoji) => { + allEmoji.set(emoji.shortcode, emoji); + }); return allEmoji; } @@ -64,14 +154,16 @@ function getShortcodeToEmoji(mx) { // shortcodes for the standard emoji will not be considered. // // Standard emoji are guaranteed to be earlier in the list than custom emoji -function getEmojiForCompletion(mx) { +function getEmojiForCompletion(room) { const allEmoji = new Map(); - getUserEmoji(mx).forEach((emoji) => { - allEmoji.set(emoji.shortcode, emoji); - }); + getRelevantPacks(room).reverse() + .flatMap((pack) => pack.getEmojis()) + .forEach((emoji) => { + allEmoji.set(emoji.shortcode, emoji); + }); return emojis.filter((e) => !allEmoji.has(e.shortcode)) .concat(Array.from(allEmoji.values())); } -export { getUserEmoji, getShortcodeToEmoji, getEmojiForCompletion }; +export { getUserImagePack, getShortcodeToEmoji, getEmojiForCompletion }; diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index cc4a6bb0..8a9feb56 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -210,7 +210,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { setCmd({ prefix, suggestions: commands }); }, ':': () => { - const emojis = getEmojiForCompletion(mx); + const emojis = getEmojiForCompletion(mx.getRoom(roomId)); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }); setCmd({ prefix, suggestions: emojis.slice(26, 46) }); }, diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 6a65c1b7..1f0f8a40 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -113,6 +113,54 @@ function bindReplyToContent(roomId, reply, content) { return newContent; } +// Apply formatting to a plain text message +// +// This includes inserting any custom emoji that might be relevant, and (only if the +// user has enabled it in their settings) formatting the message using markdown. +function formatAndEmojifyText(room, text) { + const allEmoji = getShortcodeToEmoji(room); + + // Start by applying markdown formatting (if relevant) + let formattedText; + if (settings.isMarkdown) { + formattedText = getFormattedBody(text); + } else { + formattedText = text; + } + + // Check to see if there are any :shortcode-style-tags: in the message + Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g)) + // Then filter to only the ones corresponding to a valid emoji + .filter((match) => allEmoji.has(match[1])) + // Reversing the array ensures that indices are preserved as we start replacing + .reverse() + // Replace each :shortcode: with an tag + .forEach((shortcodeMatch) => { + const emoji = allEmoji.get(shortcodeMatch[1]); + + // Render the tag that will replace the shortcode + let tag; + if (emoji.mxc) { + tag = `:${
+          emoji.shortcode
+        }:`; + } else { + tag = emoji.unicode; + } + + // Splice the tag into the text + formattedText = formattedText.substr(0, shortcodeMatch.index) + + tag + + formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length); + }); + + return formattedText; +} + class RoomsInput extends EventEmitter { constructor(mx) { super(); @@ -201,54 +249,6 @@ class RoomsInput extends EventEmitter { return this.roomIdToInput.get(roomId)?.isSending || false; } - // Apply formatting to a plain text message - // - // This includes inserting any custom emoji that might be relevant, and (only if the - // user has enabled it in their settings) formatting the message using markdown. - formatAndEmojifyText(text) { - const allEmoji = getShortcodeToEmoji(this.matrixClient); - - // Start by applying markdown formatting (if relevant) - let formattedText; - if (settings.isMarkdown) { - formattedText = getFormattedBody(text); - } else { - formattedText = text; - } - - // Check to see if there are any :shortcode-style-tags: in the message - Array.from(formattedText.matchAll(/\B:([\w-]+):\B/g)) - // Then filter to only the ones corresponding to a valid emoji - .filter((match) => allEmoji.has(match[1])) - // Reversing the array ensures that indices are preserved as we start replacing - .reverse() - // Replace each :shortcode: with an tag - .forEach((shortcodeMatch) => { - const emoji = allEmoji.get(shortcodeMatch[1]); - - // Render the tag that will replace the shortcode - let tag; - if (emoji.mxc) { - tag = `:${
-            emoji.shortcode
-          }:`; - } else { - tag = emoji.unicode; - } - - // Splice the tag into the text - formattedText = formattedText.substr(0, shortcodeMatch.index) - + tag - + formattedText.substr(shortcodeMatch.index + shortcodeMatch[0].length); - }); - - return formattedText; - } - async sendInput(roomId) { const input = this.getInput(roomId); input.isSending = true; @@ -265,7 +265,10 @@ class RoomsInput extends EventEmitter { }; // Apply formatting if relevant - const formattedBody = this.formatAndEmojifyText(input.message); + const formattedBody = formatAndEmojifyText( + this.matrixClient.getRoom(roomId), + input.message, + ); if (formattedBody !== input.message) { // Formatting was applied, and we need to switch to custom HTML content.format = 'org.matrix.custom.html'; @@ -401,7 +404,10 @@ class RoomsInput extends EventEmitter { }; // Apply formatting if relevant - const formattedBody = this.formatAndEmojifyText(editedBody); + const formattedBody = formatAndEmojifyText( + this.matrixClient.getRoom(roomId), + editedBody + ); if (formattedBody !== editedBody) { content.formatted_body = ` * ${formattedBody}`; content.format = 'org.matrix.custom.html';