Skip to content

Commit

Permalink
Add support for sending room-local emoji (#209)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Alch-Emi authored Dec 29, 2021
1 parent f9b70d6 commit 9ea9bf4
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 70 deletions.
130 changes: 111 additions & 19 deletions src/app/organisms/emoji-board/custom-emoji.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,122 @@ 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
//
// 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) {
function getShortcodeToEmoji(room) {
const allEmoji = new Map();

emojis.forEach((emoji) => {
Expand All @@ -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;
}
Expand All @@ -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 };
2 changes: 1 addition & 1 deletion src/app/organisms/room/RoomViewCmdBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
},
Expand Down
106 changes: 56 additions & 50 deletions src/client/state/RoomsInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -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';
Expand Down

0 comments on commit 9ea9bf4

Please sign in to comment.