Custom emoji & Sticker support (#686)
* Remove comments * Show custom emoji first in suggestions * Show global image packs in emoji picker * Display emoji and sticker in room settings * Fix some pack not visible in emojiboard * WIP * Add/delete/rename images to exisitng packs * Change pack avatar, name & attribution * Add checkbox to make pack global * Bug fix * Create or delete pack * Add personal emoji in settings * Show global pack selector in settings * Show space emoji in emojiboard * Send custom emoji reaction as mxc * Render stickers as stickers * Fix sticker jump bug * Fix reaction width * Fix stretched custom emoji * Fix sending space emoji in message * Remove unnessesary comments * Send user pills * Fix pill generating regex * Add support for sending stickers
This commit is contained in:
@@ -70,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
||||
unicode={`:${emoji.shortcode}:`}
|
||||
shortcodes={emoji.shortcode}
|
||||
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
||||
data-mx-emoticon
|
||||
data-mx-emoticon={emoji.mxc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -141,10 +141,13 @@ function EmojiBoard({ onSelect, searchRef }) {
|
||||
function getEmojiDataFromTarget(target) {
|
||||
const unicode = target.getAttribute('unicode');
|
||||
const hexcode = target.getAttribute('hexcode');
|
||||
const mxc = target.getAttribute('data-mx-emoticon');
|
||||
let shortcodes = target.getAttribute('shortcodes');
|
||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
||||
else shortcodes = shortcodes.split(',');
|
||||
return { unicode, hexcode, shortcodes };
|
||||
return {
|
||||
unicode, hexcode, shortcodes, mxc,
|
||||
};
|
||||
}
|
||||
|
||||
function selectEmoji(e) {
|
||||
@@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) {
|
||||
setAvailableEmojis([]);
|
||||
return;
|
||||
}
|
||||
// Retrieve the packs for the new room
|
||||
// Remove packs that aren't marked as emoji packs
|
||||
// Remove packs without emojis
|
||||
const packs = getRelevantPacks(
|
||||
initMatrix.matrixClient.getRoom(selectedRoomId),
|
||||
)
|
||||
.filter((pack) => pack.usage.indexOf('emoticon') !== -1)
|
||||
.filter((pack) => pack.getEmojis().length !== 0);
|
||||
|
||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
||||
for (let i = 0; i < packs.length; i += 1) {
|
||||
packs[i].packIndex = i;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(selectedRoomId);
|
||||
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
if (room) {
|
||||
const packs = getRelevantPacks(
|
||||
room.client,
|
||||
[room, ...parentRooms],
|
||||
).filter((pack) => pack.getEmojis().length !== 0);
|
||||
|
||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
||||
for (let i = 0; i < packs.length; i += 1) {
|
||||
packs[i].packIndex = i;
|
||||
}
|
||||
setAvailableEmojis(packs);
|
||||
}
|
||||
|
||||
setAvailableEmojis(packs);
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
@@ -260,7 +265,7 @@ function EmojiBoard({ onSelect, searchRef }) {
|
||||
{
|
||||
availableEmojis.map((pack) => (
|
||||
<EmojiGroup
|
||||
name={pack.displayName}
|
||||
name={pack.displayName ?? 'Unknown'}
|
||||
key={pack.packIndex}
|
||||
groupEmojis={pack.getEmojis()}
|
||||
className="custom-emoji-group"
|
||||
@@ -293,13 +298,14 @@ function EmojiBoard({ onSelect, searchRef }) {
|
||||
<div className="emoji-board__nav-custom">
|
||||
{
|
||||
availableEmojis.map((pack) => {
|
||||
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
|
||||
const src = initMatrix.matrixClient
|
||||
.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
||||
src={src}
|
||||
key={pack.packIndex}
|
||||
tooltip={pack.displayName}
|
||||
tooltip={pack.displayName ?? 'Unknown'}
|
||||
tooltipPlacement="right"
|
||||
isImage
|
||||
/>
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
.emoji {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
& > p:last-child {
|
||||
@@ -123,6 +124,7 @@
|
||||
& .emoji {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
object-fit: contain;
|
||||
padding: var(--emoji-padding);
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
|
||||
@@ -1,135 +1,224 @@
|
||||
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
|
||||
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||
|
||||
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') {
|
||||
static parsePack(eventId, packContent) {
|
||||
if (!eventId || typeof packContent?.images !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pack = rawPack.pack ?? {};
|
||||
return new ImagePack(eventId, packContent);
|
||||
}
|
||||
|
||||
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];
|
||||
constructor(eventId, content) {
|
||||
this.id = eventId;
|
||||
this.content = JSON.parse(JSON.stringify(content));
|
||||
|
||||
this.applyPack(content);
|
||||
this.applyImages(content);
|
||||
}
|
||||
|
||||
applyPack(content) {
|
||||
const pack = content.pack ?? {};
|
||||
|
||||
this.displayName = pack.display_name;
|
||||
this.avatarUrl = pack.avatar_url;
|
||||
this.usage = pack.usage ?? ['emoticon', 'sticker'];
|
||||
this.attribution = pack.attribution;
|
||||
}
|
||||
|
||||
applyImages(content) {
|
||||
this.images = new Map();
|
||||
this.emoticons = [];
|
||||
this.stickers = [];
|
||||
|
||||
Object.entries(content.images).forEach(([shortcode, data]) => {
|
||||
const mxc = data.url;
|
||||
const body = data.body ?? shortcode;
|
||||
const usage = data.usage ?? this.usage;
|
||||
const { info } = data;
|
||||
const usage_ = data.usage ?? usage;
|
||||
|
||||
if (mxc) {
|
||||
return [{
|
||||
shortcode, mxc, body, info, usage: usage_,
|
||||
}];
|
||||
if (!mxc) return;
|
||||
const image = {
|
||||
shortcode, mxc, body, usage, info,
|
||||
};
|
||||
|
||||
this.images.set(shortcode, image);
|
||||
if (usage.includes('emoticon')) {
|
||||
this.emoticons.push(image);
|
||||
}
|
||||
if (usage.includes('sticker')) {
|
||||
this.stickers.push(image);
|
||||
}
|
||||
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;
|
||||
getImages() {
|
||||
return this.images;
|
||||
}
|
||||
|
||||
// Produce a list of emoji in this image pack
|
||||
getEmojis() {
|
||||
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
|
||||
return this.emoticons;
|
||||
}
|
||||
|
||||
// Produce a list of stickers in this image pack
|
||||
getStickers() {
|
||||
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
|
||||
return this.stickers;
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
_updatePackProperty(property, value) {
|
||||
if (this.content.pack === undefined) {
|
||||
this.content.pack = {};
|
||||
}
|
||||
this.content.pack[property] = value;
|
||||
this.applyPack(this.content);
|
||||
}
|
||||
|
||||
setAvatarUrl(avatarUrl) {
|
||||
this._updatePackProperty('avatar_url', avatarUrl);
|
||||
}
|
||||
|
||||
setDisplayName(displayName) {
|
||||
this._updatePackProperty('display_name', displayName);
|
||||
}
|
||||
|
||||
setAttribution(attribution) {
|
||||
this._updatePackProperty('attribution', attribution);
|
||||
}
|
||||
|
||||
setUsage(usage) {
|
||||
this._updatePackProperty('usage', usage);
|
||||
}
|
||||
|
||||
addImage(key, imgContent) {
|
||||
this.content.images = {
|
||||
[key]: imgContent,
|
||||
...this.content.images,
|
||||
};
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
removeImage(key) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
delete this.content.images[key];
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
updateImageKey(key, newKey) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
const copyImages = {};
|
||||
Object.keys(this.content.images).forEach((imgKey) => {
|
||||
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
|
||||
});
|
||||
this.content.images = copyImages;
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
_updateImageProperty(key, property, value) {
|
||||
if (this.content.images[key] === undefined) return;
|
||||
this.content.images[key][property] = value;
|
||||
this.applyImages(this.content);
|
||||
}
|
||||
|
||||
setImageUrl(key, url) {
|
||||
this._updateImageProperty(key, 'url', url);
|
||||
}
|
||||
|
||||
setImageBody(key, body) {
|
||||
this._updateImageProperty(key, 'body', body);
|
||||
}
|
||||
|
||||
setImageInfo(key, info) {
|
||||
this._updateImageProperty(key, 'info', info);
|
||||
}
|
||||
|
||||
setImageUsage(key, usage) {
|
||||
this._updateImageProperty(key, 'usage', usage);
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve a list of user emojis
|
||||
//
|
||||
// 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 getGlobalImagePacks(mx) {
|
||||
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||
if (typeof globalContent !== 'object') return [];
|
||||
|
||||
const { rooms } = globalContent;
|
||||
if (typeof rooms !== 'object') return [];
|
||||
|
||||
const roomIds = Object.keys(rooms);
|
||||
|
||||
const packs = roomIds.flatMap((roomId) => {
|
||||
if (typeof rooms[roomId] !== 'object') return [];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return [];
|
||||
const stateKeys = Object.keys(rooms[roomId]);
|
||||
|
||||
return stateKeys.map((stateKey) => {
|
||||
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||
if (pack) {
|
||||
pack.displayName ??= room.name;
|
||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||
}
|
||||
return pack;
|
||||
}).filter((pack) => pack !== null);
|
||||
});
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
function getUserImagePack(mx) {
|
||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||
if (!accountDataEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
|
||||
if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
|
||||
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
||||
userImagePack.displayName ??= 'Personal 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');
|
||||
function getRoomImagePacks(room) {
|
||||
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||
|
||||
return packs
|
||||
.map((p) => ImagePack.parsePack(p.event.content, room))
|
||||
.filter((p) => p !== null);
|
||||
return dataEvents
|
||||
.map((data) => {
|
||||
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||
if (pack) {
|
||||
pack.displayName ??= room.name;
|
||||
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||
}
|
||||
return pack;
|
||||
})
|
||||
.filter((pack) => pack !== 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) {
|
||||
/**
|
||||
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||
* @returns {ImagePack[]} packs
|
||||
*/
|
||||
function getRelevantPacks(mx, rooms) {
|
||||
const userPack = mx ? getUserImagePack(mx) : [];
|
||||
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||
|
||||
return [].concat(
|
||||
getUserImagePack(room.client) ?? [],
|
||||
getPacksInRoom(room),
|
||||
userPack ?? [],
|
||||
globalPacks,
|
||||
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// 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(room) {
|
||||
function getShortcodeToEmoji(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
if (emoji.shortcodes.constructor.name === 'Array') {
|
||||
if (Array.isArray(emoji.shortcodes)) {
|
||||
emoji.shortcodes.forEach((shortcode) => {
|
||||
allEmoji.set(shortcode, emoji);
|
||||
});
|
||||
@@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
|
||||
}
|
||||
});
|
||||
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
@@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
|
||||
function getShortcodeToCustomEmoji(room) {
|
||||
const allEmoji = new Map();
|
||||
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(room.client, [room])
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
@@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
|
||||
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(room) {
|
||||
function getEmojiForCompletion(mx, rooms) {
|
||||
const allEmoji = new Map();
|
||||
getRelevantPacks(room).reverse()
|
||||
getRelevantPacks(mx, rooms)
|
||||
.flatMap((pack) => pack.getEmojis())
|
||||
.forEach((emoji) => {
|
||||
allEmoji.set(emoji.shortcode, emoji);
|
||||
});
|
||||
|
||||
return emojis.filter((e) => !allEmoji.has(e.shortcode))
|
||||
.concat(Array.from(allEmoji.values()));
|
||||
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
||||
}
|
||||
|
||||
export {
|
||||
getUserImagePack,
|
||||
ImagePack,
|
||||
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
||||
getRelevantPacks, getEmojiForCompletion,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user