Add support for sending room-local emoji (#209)
authorEmi <emi@alchemi.dev>
Wed, 29 Dec 2021 04:26:17 +0000 (23:26 -0500)
committerGitHub <noreply@github.com>
Wed, 29 Dec 2021 04:26:17 +0000 (09:56 +0530)
* 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
src/app/organisms/room/RoomViewCmdBar.jsx
src/client/state/RoomsInput.js

index b847bd43b3872fb290f07012a847f13fa3b27064..650a9620c0b544ad64c495e4960cf7fd02f3ef92 100644 (file)
@@ -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
 
+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);
+  }
+
+  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);
+  }
+}
+
 // Retrieve a list of user emojis
 //
-// Result is a list of objects, each with a shortcode and an mxc property
+// 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 getUserEmoji(mx) {
+function getUserImagePack(mx) {
   const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
   if (!accountDataEmoji) {
-    return [];
+    return null;
   }
 
-  const { images } = accountDataEmoji.event.content;
-  const mapped = Object.entries(images).map((e) => ({
-    shortcode: e[0],
-    mxc: e[1].url,
-  }));
-  return mapped;
+  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 emojis and all standard unicode emojis
+// 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 };
index cc4a6bb0c5f1ae0c35a88fa81419113a0ba72ee0..8a9feb568e3ad0547f46ff10193eb87c6c573a19 100644 (file)
@@ -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) });
       },
index 6a65c1b7b83095fcc6cbf5ef429b579c1328e136..1f0f8a4037ca178dfe241abc3e412fc228b6a20e 100644 (file)
@@ -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 <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;
+}
+
 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 <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;
@@ -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';