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
This commit is contained in:
Emi 2021-12-27 22:29:39 -05:00 committed by GitHub
parent 6ff339b552
commit 90621bb1e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 182 additions and 26 deletions

View file

@ -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 };

View file

@ -13,7 +13,7 @@ import {
openPublicRooms, openPublicRooms,
openInviteUser, openInviteUser,
} from '../../../client/action/navigation'; } from '../../../client/action/navigation';
import { emojis } from '../emoji-board/emoji'; import { getEmojiForCompletion } from '../emoji-board/custom-emoji';
import AsyncSearch from '../../../util/AsyncSearch'; import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
@ -81,16 +81,11 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
} }
function renderEmojiSuggestion(emPrefix, emos) { function renderEmojiSuggestion(emPrefix, emos) {
return emos.map((emoji) => ( const mx = initMatrix.matrixClient;
<CmdItem
key={emoji.hexcode} // Renders a small Twemoji
onClick={() => fireCmd({ function renderTwemoji(emoji) {
prefix: emPrefix, return parse(twemoji.parse(
result: emoji,
})}
>
{
parse(twemoji.parse(
emoji.unicode, emoji.unicode,
{ {
attributes: () => ({ attributes: () => ({
@ -98,7 +93,39 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
shortcodes: emoji.shortcodes?.toString(), 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.shortcode}
onClick={() => fireCmd({
prefix: emPrefix,
result: emoji,
})}
>
{
renderEmoji(emoji)
} }
<Text variant="b2">{`:${emoji.shortcode}:`}</Text> <Text variant="b2">{`:${emoji.shortcode}:`}</Text>
</CmdItem> </CmdItem>
@ -183,6 +210,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
setCmd({ prefix, suggestions: commands }); setCmd({ prefix, suggestions: commands });
}, },
':': () => { ':': () => {
const emojis = getEmojiForCompletion(mx);
asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }); asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
setCmd({ prefix, suggestions: emojis.slice(26, 46) }); setCmd({ prefix, suggestions: emojis.slice(26, 46) });
}, },
@ -210,7 +238,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
} }
if (myCmd.prefix === ':') { if (myCmd.prefix === ':') {
viewEvent.emit('cmd_fired', { viewEvent.emit('cmd_fired', {
replace: myCmd.result.unicode, replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
}); });
} }
if (myCmd.prefix === '@') { if (myCmd.prefix === '@') {

View file

@ -2,6 +2,7 @@ import EventEmitter from 'events';
import { micromark } from 'micromark'; import { micromark } from 'micromark';
import { gfm, gfmHtml } from 'micromark-extension-gfm'; import { gfm, gfmHtml } from 'micromark-extension-gfm';
import encrypt from 'browser-encrypt-attachment'; import encrypt from 'browser-encrypt-attachment';
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
import cons from './cons'; import cons from './cons';
import settings from './settings'; import settings from './settings';
@ -200,6 +201,54 @@ class RoomsInput extends EventEmitter {
return this.roomIdToInput.get(roomId)?.isSending || false; 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) { async sendInput(roomId) {
const input = this.getInput(roomId); const input = this.getInput(roomId);
input.isSending = true; input.isSending = true;
@ -214,13 +263,15 @@ class RoomsInput extends EventEmitter {
body: input.message, body: input.message,
msgtype: 'm.text', msgtype: 'm.text',
}; };
if (settings.isMarkdown) {
const formattedBody = getFormattedBody(input.message); // Apply formatting if relevant
const formattedBody = this.formatAndEmojifyText(input.message);
if (formattedBody !== input.message) { if (formattedBody !== input.message) {
// Formatting was applied, and we need to switch to custom HTML
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody; content.formatted_body = formattedBody;
} }
}
if (typeof input.replyTo !== 'undefined') { if (typeof input.replyTo !== 'undefined') {
content = bindReplyToContent(roomId, input.replyTo, content); content = bindReplyToContent(roomId, input.replyTo, content);
} }
@ -348,15 +399,15 @@ class RoomsInput extends EventEmitter {
rel_type: 'm.replace', rel_type: 'm.replace',
}, },
}; };
if (settings.isMarkdown) {
const formattedBody = getFormattedBody(editedBody); // Apply formatting if relevant
const formattedBody = this.formatAndEmojifyText(editedBody);
if (formattedBody !== editedBody) { if (formattedBody !== editedBody) {
content.formatted_body = ` * ${formattedBody}`; content.formatted_body = ` * ${formattedBody}`;
content.format = 'org.matrix.custom.html'; content.format = 'org.matrix.custom.html';
content['m.new_content'].formatted_body = formattedBody; content['m.new_content'].formatted_body = formattedBody;
content['m.new_content'].format = 'org.matrix.custom.html'; content['m.new_content'].format = 'org.matrix.custom.html';
} }
}
if (isReply) { if (isReply) {
const evBody = mEvent.getContent().body; const evBody = mEvent.getContent().body;
const replyHead = evBody.slice(0, evBody.indexOf('\n\n')); const replyHead = evBody.slice(0, evBody.indexOf('\n\n'));