Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
support custom emoji in editor and completions
Browse files Browse the repository at this point in the history
  • Loading branch information
AndrewRyanChama committed Mar 19, 2022
1 parent d7a3f39 commit f71b1e3
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 21 deletions.
8 changes: 8 additions & 0 deletions res/css/views/elements/_RichText.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
padding-left: 0;
}

.mx_CustomEmojiPill {
display: inline-flex;
align-items: center;
vertical-align: middle;
padding-left: 1px;
font-size: 0;
}

a.mx_Pill {
text-overflow: ellipsis;
white-space: nowrap;
Expand Down
18 changes: 18 additions & 0 deletions res/css/views/rooms/_BasicMessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ limitations under the License.
}
}

span.mx_CustomEmojiPill {
position: relative;
user-select: all;

// avatar psuedo element
&::before {
content: var(--avatar-letter);
width: $font-18px;
height: $font-18px;
background: var(--avatar-background), $background;
color: $avatar-initial-color;
background-repeat: no-repeat;
background-size: $font-18px;
text-align: center;
font-weight: normal;
}
}

span.mx_UserPill {
cursor: pointer;
}
Expand Down
3 changes: 3 additions & 0 deletions src/Markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ function isAllowedHtmlTag(node: commonmark.Node): boolean {
if (node.literal != null &&
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
return true;
} else if (node.literal != null &&
node.literal.match('^<img data-mx-emoticon') != null) {
return true;
}

// Regex won't work for tags with attrs, but we only
Expand Down
4 changes: 2 additions & 2 deletions src/autocomplete/Autocompleter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ export interface ISelectionRange {
}

export interface ICompletion {
type: "at-room" | "command" | "community" | "room" | "user";
type?: "at-room" | "command" | "community" | "room" | "user" | "customEmoji";
completion: string;
completionId?: string;
component?: ReactElement;
range: ISelectionRange;
range?: ISelectionRange;
command?: string;
suffix?: string;
// If provided, apply a LINK entity to the completion with the
Expand Down
102 changes: 86 additions & 16 deletions src/autocomplete/EmojiProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ limitations under the License.
*/

import React from 'react';
import { uniq, sortBy } from 'lodash';
import { uniq, sortBy, ListIteratee } from 'lodash';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixEvent } from 'matrix-js-sdk/src/matrix';

import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
Expand All @@ -30,6 +31,7 @@ import { ICompletion, ISelectionRange } from './Autocompleter';
import SettingsStore from "../settings/SettingsStore";
import { EMOJI, IEmoji } from '../emoji';
import { TimelineRenderingType } from '../contexts/RoomContext';
import { mediaFromMxc } from '../customisations/Media';

const LIMIT = 20;

Expand All @@ -38,10 +40,16 @@ const LIMIT = 20;
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g');

interface ISortedEmoji {
emoji: IEmoji;
emoji: IEmoji | ICustomEmoji;
_orderBy: number;
}

export interface ICustomEmoji {
shortcodes: string[];
emoticon?: string;
url: string;
}

const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => {
if (a.group === b.group) {
return a.order - b.order;
Expand All @@ -65,6 +73,7 @@ function score(query, space) {
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<ISortedEmoji>;
nameMatcher: QueryMatcher<ISortedEmoji>;
customEmojiMatcher: QueryMatcher<ISortedEmoji>;

constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: EMOJI_REGEX, renderingType });
Expand All @@ -74,11 +83,42 @@ export default class EmojiProvider extends AutocompleteProvider {
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new QueryMatcher(SORTED_EMOJI, {
this.nameMatcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
keys: ['emoji.annotation'],
// For removing punctuation
shouldMatchWordsOnly: true,
});

// Load this room's image sets.
const loadedImages: ICustomEmoji[] = [];
const imageSetEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
imageSetEvents.forEach(imageSetEvent => {
this.loadImageSet(loadedImages, imageSetEvent);
});
const sortedCustomImages = loadedImages.map((emoji, index) => ({
emoji,
// Include the index so that we can preserve the original order
_orderBy: index,
}));
this.customEmojiMatcher = new QueryMatcher<ISortedEmoji>(sortedCustomImages, {
keys: [],
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
shouldMatchWordsOnly: true,
});
}

private loadImageSet(loadedImages: ICustomEmoji[], imageSetEvent: MatrixEvent): void {
const images = imageSetEvent.getContent().images;
if (!images) {
return;
}
for (const imageKey in images) {
const imageData = images[imageKey];
loadedImages.push({
shortcodes: [imageKey],
url: imageData.url,
});
}
}

async getCompletions(
Expand All @@ -91,17 +131,23 @@ export default class EmojiProvider extends AutocompleteProvider {
return []; // don't give any suggestions if the user doesn't want them
}

let completions = [];
let completionResult: ICompletion[] = [];
const { command, range } = this.getCurrentCommand(query, selection);

if (command && command[0].length > 2) {
let completions: ISortedEmoji[] = [];

// find completions
const matchedString = command[0];
completions = this.matcher.match(matchedString, limit);

// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
completions = completions.concat(this.nameMatcher.match(matchedString, limit));

// do a match for the custom emoji
completions = completions.concat(this.customEmojiMatcher.match(matchedString, limit));

const sorters = [];
const sorters: ListIteratee<ISortedEmoji>[] = [];
// make sure that emoticons come first
sorters.push(c => score(matchedString, c.emoji.emoticon || ""));

Expand All @@ -121,17 +167,41 @@ export default class EmojiProvider extends AutocompleteProvider {
sorters.push(c => c._orderBy);
completions = sortBy(uniq(completions), sorters);

completions = completions.map(c => ({
completion: c.emoji.unicode,
component: (
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
<span>{ c.emoji.unicode }</span>
</PillCompletion>
),
range,
})).slice(0, LIMIT);
completionResult = completions.map(c => {
if ('unicode' in c.emoji) {
return {
completion: c.emoji.unicode,
component: (
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
<span>{ c.emoji.unicode }</span>
</PillCompletion>
),
range,
};
} else {
const mediaUrl = mediaFromMxc(c.emoji.url).getThumbnailOfSourceHttp(24, 24, 'scale');
return {
completion: c.emoji.shortcodes[0],
type: "customEmoji",
completionId: c.emoji.url,
component: (
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`}>
<img
className="mx_BaseAvatar_image"
src={mediaUrl}
alt={c.emoji.shortcodes[0]}
style={{
width: '24px',
height: '24px',
}} />
</PillCompletion>
),
range,
} as const;
}
}).slice(0, LIMIT);
}
return completions;
return completionResult;
}

getName() {
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/UserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default class UserProvider extends AutocompleteProvider {
renderingType,
});
this.room = room;
this.matcher = new QueryMatcher([], {
this.matcher = new QueryMatcher<RoomMember>([], {
keys: ['name'],
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchWordsOnly: false,
Expand Down
2 changes: 2 additions & 0 deletions src/editor/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export default class AutocompleteWrapperModel {
case "command":
// command needs special handling for auto complete, but also renders as plain texts
return [(this.partCreator as CommandPartCreator).command(text)];
case "customEmoji":
return [this.partCreator.customEmoji(text, completionId)];
default:
// used for emoji and other plain text completion replacement
return this.partCreator.plainWithEmoji(text);
Expand Down
40 changes: 38 additions & 2 deletions src/editor/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import SettingsStore from "../settings/SettingsStore";
import { mediaFromMxc } from "../customisations/Media";

interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Emoji | Type.Command | Type.PillCandidate;
text: string;
}

interface ISerializedPillPart {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
text: string;
resourceId?: string;
}
Expand All @@ -49,6 +50,7 @@ export enum Type {
Plain = "plain",
Newline = "newline",
Emoji = "emoji",
CustomEmoji = "custom-emoji",
Command = "command",
UserPill = "user-pill",
RoomPill = "room-pill",
Expand Down Expand Up @@ -80,7 +82,7 @@ interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplet
}

interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
resourceId: string;
}

Expand Down Expand Up @@ -403,6 +405,34 @@ class EmojiPart extends BasePart implements IBasePart {
}
}

class CustomEmojiPart extends PillPart implements IPillPart {
protected get className(): string {
return "mx_CustomEmojiPill";
}
protected setAvatar(node: HTMLElement): void {
const url = mediaFromMxc(this.resourceId).getThumbnailOfSourceHttp(24, 24, "crop");
this.setAvatarVars(node, url, this.text[0]);
}
constructor(shortCode: string, url: string) {
super(url, shortCode);
}
protected acceptsInsertion(chr: string): boolean {
return false;
}

protected acceptsRemoval(position: number, chr: string): boolean {
return false;
}

public get type(): IPillPart["type"] {
return Type.CustomEmoji;
}

public get canEdit(): boolean {
return false;
}
}

class RoomPillPart extends PillPart {
constructor(resourceId: string, label: string, private room: Room) {
super(resourceId, label);
Expand Down Expand Up @@ -574,6 +604,8 @@ export class PartCreator {
return this.newline();
case Type.Emoji:
return this.emoji(part.text);
case Type.CustomEmoji:
return this.customEmoji(part.text, part.resourceId);
case Type.AtRoomPill:
return this.atRoomPill(part.text);
case Type.PillCandidate:
Expand Down Expand Up @@ -645,6 +677,10 @@ export class PartCreator {
return parts;
}

public customEmoji(shortcode: string, url: string) {
return new CustomEmojiPart(shortcode, url);
}

public createMentionParts(
insertTrailingCharacter: boolean,
displayName: string,
Expand Down
7 changes: 7 additions & 0 deletions src/editor/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.

import { AllHtmlEntities } from 'html-entities';
import cheerio from 'cheerio';
import _ from 'lodash';

import Markdown from '../Markdown';
import { makeGenericPermalink } from "../utils/permalinks/Permalinks";
Expand Down Expand Up @@ -44,6 +45,10 @@ export function mdSerialize(model: EditorModel): string {
case Type.UserPill:
return html +
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
case Type.CustomEmoji:
return html +
`<img data-mx-emoticon height="18" src="${encodeURI(part.resourceId)}"`
+ ` title=":${_.escape(part.text)}:" alt=":${_.escape(part.text)}:">`;
}
}, "");
}
Expand Down Expand Up @@ -176,6 +181,8 @@ export function textSerialize(model: EditorModel): string {
return text + `${part.resourceId}`;
case Type.UserPill:
return text + `${part.text}`;
case Type.CustomEmoji:
return text + `:${part.text}:`;
}
}, "");
}
Expand Down
7 changes: 7 additions & 0 deletions test/editor/serialize-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ describe('editor/serialize', function() {
const html = htmlSerializeIfNeeded(model, {});
expect(html).toBeFalsy();
});
it('custom emoji pill turns message into html', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.customEmoji("poggers", "mxc://matrix.org/test")]);
const html = htmlSerializeIfNeeded(model, {});
expect(html).toBe("<img data-mx-emoticon height=\"18\" src=\"mxc://matrix.org/test\""
+ " title=\":poggers:\" alt=\":poggers:\">");
});
it('any markdown turns message into html', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("*hello* world")]);
Expand Down

0 comments on commit f71b1e3

Please sign in to comment.