Add support for sending user emoji using autocomplete (#205)
authorEmi <emi@alchemi.dev>
Tue, 28 Dec 2021 03:29:39 +0000 (22:29 -0500)
committerGitHub <noreply@github.com>
Tue, 28 Dec 2021 03:29:39 +0000 (08:59 +0530)
* 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 [new file with mode: 0644]
src/app/organisms/room/RoomViewCmdBar.jsx
src/client/state/RoomsInput.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 (file)
index 0000000..b847bd4
--- /dev/null
@@ -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 };
index 676e8f8524e67f79157403a18434ada40a0e6772..cc4a6bb0c5f1ae0c35a88fa81419113a0ba72ee0 100644 (file)
@@ -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 (
+        <img
+          className="emoji"
+          src={mx.mxcUrlToHttp(emoji.mxc)}
+          data-mx-emoticon=""
+          alt={`:${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) => (
       <CmdItem
-        key={emoji.hexcode}
+        key={emoji.shortcode}
         onClick={() => fireCmd({
           prefix: emPrefix,
           result: emoji,
         })}
       >
         {
-          parse(twemoji.parse(
-            emoji.unicode,
-            {
-              attributes: () => ({
-                unicode: emoji.unicode,
-                shortcodes: emoji.shortcodes?.toString(),
-              }),
-            },
-          ))
+          renderEmoji(emoji)
         }
         <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
       </CmdItem>
@@ -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 === '@') {
index a8ee805916f37fa843e91849e15648354589ea05..6a65c1b7b83095fcc6cbf5ef429b579c1328e136 100644 (file)
@@ -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 <img/> tag
+      .forEach((shortcodeMatch) => {
+        const emoji = allEmoji.get(shortcodeMatch[1]);
+
+        // Render the tag that will replace the shortcode
+        let tag;
+        if (emoji.mxc) {
+          tag = `<img data-mx-emoticon="" src="${
+            emoji.mxc
+          }" alt=":${
+            emoji.shortcode
+          }:" title=":${
+            emoji.shortcode
+          }:" height="32" />`;
+        } 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;