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:
parent
6ff339b552
commit
90621bb1e3
3 changed files with 182 additions and 26 deletions
77
src/app/organisms/emoji-board/custom-emoji.js
Normal file
77
src/app/organisms/emoji-board/custom-emoji.js
Normal 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 };
|
|
@ -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 === '@') {
|
||||||
|
|
|
@ -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'));
|
||||||
|
|
Loading…
Reference in a new issue