Add support for sending room-local emoji (#209)
* 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
This commit is contained in:
parent
f9b70d65d8
commit
9ea9bf4035
3 changed files with 174 additions and 76 deletions
|
@ -10,26 +10,114 @@ import { emojis } from './emoji';
|
||||||
// globally, while emojis and packs in rooms and spaces should only be available within
|
// globally, while emojis and packs in rooms and spaces should only be available within
|
||||||
// those spaces and rooms
|
// 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
|
// 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
|
// 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');
|
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||||
if (!accountDataEmoji) {
|
if (!accountDataEmoji) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { images } = accountDataEmoji.event.content;
|
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
|
||||||
const mapped = Object.entries(images).map((e) => ({
|
if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
|
||||||
shortcode: e[0],
|
return userImagePack;
|
||||||
mxc: e[1].url,
|
|
||||||
}));
|
|
||||||
return mapped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all user emojis and all standard unicode emojis
|
// 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+room emojis and all standard unicode emojis
|
||||||
//
|
//
|
||||||
// Accepts a reference to a matrix client as the only argument
|
// 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.
|
// 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
|
// 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();
|
const allEmoji = new Map();
|
||||||
|
|
||||||
emojis.forEach((emoji) => {
|
emojis.forEach((emoji) => {
|
||||||
|
@ -50,7 +138,9 @@ function getShortcodeToEmoji(mx) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
getUserEmoji(mx).forEach((emoji) => {
|
getRelevantPacks(room).reverse()
|
||||||
|
.flatMap((pack) => pack.getEmojis())
|
||||||
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,9 +154,11 @@ function getShortcodeToEmoji(mx) {
|
||||||
// shortcodes for the standard emoji will not be considered.
|
// shortcodes for the standard emoji will not be considered.
|
||||||
//
|
//
|
||||||
// Standard emoji are guaranteed to be earlier in the list than custom emoji
|
// Standard emoji are guaranteed to be earlier in the list than custom emoji
|
||||||
function getEmojiForCompletion(mx) {
|
function getEmojiForCompletion(room) {
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
getUserEmoji(mx).forEach((emoji) => {
|
getRelevantPacks(room).reverse()
|
||||||
|
.flatMap((pack) => pack.getEmojis())
|
||||||
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -74,4 +166,4 @@ function getEmojiForCompletion(mx) {
|
||||||
.concat(Array.from(allEmoji.values()));
|
.concat(Array.from(allEmoji.values()));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getUserEmoji, getShortcodeToEmoji, getEmojiForCompletion };
|
export { getUserImagePack, getShortcodeToEmoji, getEmojiForCompletion };
|
||||||
|
|
|
@ -210,7 +210,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
||||||
setCmd({ prefix, suggestions: commands });
|
setCmd({ prefix, suggestions: commands });
|
||||||
},
|
},
|
||||||
':': () => {
|
':': () => {
|
||||||
const emojis = getEmojiForCompletion(mx);
|
const emojis = getEmojiForCompletion(mx.getRoom(roomId));
|
||||||
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) });
|
||||||
},
|
},
|
||||||
|
|
|
@ -113,6 +113,54 @@ function bindReplyToContent(roomId, reply, content) {
|
||||||
return newContent;
|
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 {
|
class RoomsInput extends EventEmitter {
|
||||||
constructor(mx) {
|
constructor(mx) {
|
||||||
super();
|
super();
|
||||||
|
@ -201,54 +249,6 @@ 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;
|
||||||
|
@ -265,7 +265,10 @@ class RoomsInput extends EventEmitter {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply formatting if relevant
|
// Apply formatting if relevant
|
||||||
const formattedBody = this.formatAndEmojifyText(input.message);
|
const formattedBody = formatAndEmojifyText(
|
||||||
|
this.matrixClient.getRoom(roomId),
|
||||||
|
input.message,
|
||||||
|
);
|
||||||
if (formattedBody !== input.message) {
|
if (formattedBody !== input.message) {
|
||||||
// Formatting was applied, and we need to switch to custom HTML
|
// Formatting was applied, and we need to switch to custom HTML
|
||||||
content.format = 'org.matrix.custom.html';
|
content.format = 'org.matrix.custom.html';
|
||||||
|
@ -401,7 +404,10 @@ class RoomsInput extends EventEmitter {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply formatting if relevant
|
// Apply formatting if relevant
|
||||||
const formattedBody = this.formatAndEmojifyText(editedBody);
|
const formattedBody = formatAndEmojifyText(
|
||||||
|
this.matrixClient.getRoom(roomId),
|
||||||
|
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';
|
||||||
|
|
Loading…
Reference in a new issue