Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for sending room-local emoji #209

Merged
merged 5 commits into from
Dec 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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