From 90621bb1e3c331326684440368120601e766442a Mon Sep 17 00:00:00 2001 From: Emi Date: Mon, 27 Dec 2021 22:29:39 -0500 Subject: [PATCH] Add support for sending user emoji using autocomplete (#205) * Add support for sending user emoji using autocomplete What's included: - An implementation for detecting user emojis - Addition of user emojis to the emoji autocomplete in the command bar - Translation of shortcodes into image tags on message sending What's not included: - Loading emojis from the active room, loading the user's global emoji packs, loading emoji from spaces - Selecting custom emoji using the emoji picker This is a predominantly proof-of-concept change, and everything here may be subject to architectural review and reworking. * Amending PR: Allow sending multiple of the same emoji * Amending PR: Add support for emojis in edited messages * Amend PR: Apply requested revisions This commit consists of several small changes, including: - Fix crash when the user doesn't have the im.ponies.user_emotes account data entry - Add mx-data-emoticon attribute to command bar emoji - Rewrite alt text in the command bar interface - Remove "vertical-align" attribute from sent emoji * Amending PR: Fix bugs (listed below) - Fix bug where sending emoji w/ markdown off resulted in a crash - Fix bug where alt text in the command bar was wrong * Amending PR: Add support for replacement of twemoji shortcodes * Amending PR: Fix & refactor getAllEmoji -> getShortcodeToEmoji * Amending PR: Fix bug: Sending two of the same emoji corrupts message * Amending PR: Stylistic fixes --- src/app/organisms/emoji-board/custom-emoji.js | 77 ++++++++++++++++++ src/app/organisms/room/RoomViewCmdBar.jsx | 52 +++++++++--- src/client/state/RoomsInput.js | 79 +++++++++++++++---- 3 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 src/app/organisms/emoji-board/custom-emoji.js diff --git a/src/app/organisms/emoji-board/custom-emoji.js b/src/app/organisms/emoji-board/custom-emoji.js new file mode 100644 index 0000000..b847bd4 --- /dev/null +++ b/src/app/organisms/emoji-board/custom-emoji.js @@ -0,0 +1,77 @@ +import { emojis } from './emoji'; + +// Custom emoji are stored in one of three places: +// - User emojis, which are stored in account data +// - Room emojis, which are stored in state events in a room +// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's +// cannonical space +// +// Emojis and packs referenced from within a user's account data should be available +// 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 []; + } + + const { images } = accountDataEmoji.event.content; + const mapped = Object.entries(images).map((e) => ({ + shortcode: e[0], + mxc: e[1].url, + })); + return mapped; +} + +// Returns all user emojis and all standard unicode emojis +// +// Accepts a reference to a matrix client as the only argument +// +// Result is a map from shortcode to the corresponding emoji. If two emoji share a +// 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) { + const allEmoji = new Map(); + + emojis.forEach((emoji) => { + if (emoji.shortcodes.constructor.name === 'Array') { + emoji.shortcodes.forEach((shortcode) => { + allEmoji.set(shortcode, emoji); + }); + } else { + allEmoji.set(emoji.shortcodes, emoji); + } + }); + + getUserEmoji(mx).forEach((emoji) => { + allEmoji.set(emoji.shortcode, emoji); + }); + + return allEmoji; +} + +// Produces a special list of emoji specifically for auto-completion +// +// This list contains each emoji once, with all emoji being deduplicated by shortcode. +// However, the order of the standard emoji will have been preserved, and alternate +// 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) { + const allEmoji = new Map(); + getUserEmoji(mx).forEach((emoji) => { + allEmoji.set(emoji.shortcode, emoji); + }); + + return emojis.filter((e) => !allEmoji.has(e.shortcode)) + .concat(Array.from(allEmoji.values())); +} + +export { getUserEmoji, getShortcodeToEmoji, getEmojiForCompletion }; diff --git a/src/app/organisms/room/RoomViewCmdBar.jsx b/src/app/organisms/room/RoomViewCmdBar.jsx index 676e8f8..cc4a6bb 100644 --- a/src/app/organisms/room/RoomViewCmdBar.jsx +++ b/src/app/organisms/room/RoomViewCmdBar.jsx @@ -13,7 +13,7 @@ import { openPublicRooms, openInviteUser, } from '../../../client/action/navigation'; -import { emojis } from '../emoji-board/emoji'; +import { getEmojiForCompletion } from '../emoji-board/custom-emoji'; import AsyncSearch from '../../../util/AsyncSearch'; import Text from '../../atoms/text/Text'; @@ -81,24 +81,51 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) { } function renderEmojiSuggestion(emPrefix, emos) { + const mx = initMatrix.matrixClient; + + // Renders a small Twemoji + function renderTwemoji(emoji) { + return parse(twemoji.parse( + emoji.unicode, + { + attributes: () => ({ + unicode: emoji.unicode, + shortcodes: emoji.shortcodes?.toString(), + }), + }, + )); + } + + // Render a custom emoji + function renderCustomEmoji(emoji) { + return ( + {`:${emoji.shortcode}:`} + ); + } + + // Dynamically render either a custom emoji or twemoji based on what the input is + function renderEmoji(emoji) { + if (emoji.mxc) { + return renderCustomEmoji(emoji); + } + return renderTwemoji(emoji); + } + return emos.map((emoji) => ( fireCmd({ prefix: emPrefix, result: emoji, })} > { - parse(twemoji.parse( - emoji.unicode, - { - attributes: () => ({ - unicode: emoji.unicode, - shortcodes: emoji.shortcodes?.toString(), - }), - }, - )) + renderEmoji(emoji) } {`:${emoji.shortcode}:`} @@ -183,6 +210,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { setCmd({ prefix, suggestions: commands }); }, ':': () => { + const emojis = getEmojiForCompletion(mx); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }); setCmd({ prefix, suggestions: emojis.slice(26, 46) }); }, @@ -210,7 +238,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) { } if (myCmd.prefix === ':') { viewEvent.emit('cmd_fired', { - replace: myCmd.result.unicode, + replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode, }); } if (myCmd.prefix === '@') { diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index a8ee805..6a65c1b 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -2,6 +2,7 @@ import EventEmitter from 'events'; import { micromark } from 'micromark'; import { gfm, gfmHtml } from 'micromark-extension-gfm'; import encrypt from 'browser-encrypt-attachment'; +import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji'; import cons from './cons'; import settings from './settings'; @@ -200,6 +201,54 @@ 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; @@ -214,13 +263,15 @@ class RoomsInput extends EventEmitter { body: input.message, msgtype: 'm.text', }; - if (settings.isMarkdown) { - const formattedBody = getFormattedBody(input.message); - if (formattedBody !== input.message) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = formattedBody; - } + + // Apply formatting if relevant + const formattedBody = this.formatAndEmojifyText(input.message); + if (formattedBody !== input.message) { + // Formatting was applied, and we need to switch to custom HTML + content.format = 'org.matrix.custom.html'; + content.formatted_body = formattedBody; } + if (typeof input.replyTo !== 'undefined') { content = bindReplyToContent(roomId, input.replyTo, content); } @@ -348,14 +399,14 @@ class RoomsInput extends EventEmitter { rel_type: 'm.replace', }, }; - if (settings.isMarkdown) { - const formattedBody = getFormattedBody(editedBody); - if (formattedBody !== editedBody) { - content.formatted_body = ` * ${formattedBody}`; - content.format = 'org.matrix.custom.html'; - content['m.new_content'].formatted_body = formattedBody; - content['m.new_content'].format = 'org.matrix.custom.html'; - } + + // Apply formatting if relevant + const formattedBody = this.formatAndEmojifyText(editedBody); + if (formattedBody !== editedBody) { + content.formatted_body = ` * ${formattedBody}`; + content.format = 'org.matrix.custom.html'; + content['m.new_content'].formatted_body = formattedBody; + content['m.new_content'].format = 'org.matrix.custom.html'; } if (isReply) { const evBody = mEvent.getContent().body;