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 (
+
+ );
+ }
+
+ // 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 = ``;
+ } 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;