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

Commit f71b1e3

Browse files
support custom emoji in editor and completions
1 parent d7a3f39 commit f71b1e3

File tree

10 files changed

+172
-21
lines changed

10 files changed

+172
-21
lines changed

res/css/views/elements/_RichText.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
padding-left: 0;
1515
}
1616

17+
.mx_CustomEmojiPill {
18+
display: inline-flex;
19+
align-items: center;
20+
vertical-align: middle;
21+
padding-left: 1px;
22+
font-size: 0;
23+
}
24+
1725
a.mx_Pill {
1826
text-overflow: ellipsis;
1927
white-space: nowrap;

res/css/views/rooms/_BasicMessageComposer.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ limitations under the License.
7373
}
7474
}
7575

76+
span.mx_CustomEmojiPill {
77+
position: relative;
78+
user-select: all;
79+
80+
// avatar psuedo element
81+
&::before {
82+
content: var(--avatar-letter);
83+
width: $font-18px;
84+
height: $font-18px;
85+
background: var(--avatar-background), $background;
86+
color: $avatar-initial-color;
87+
background-repeat: no-repeat;
88+
background-size: $font-18px;
89+
text-align: center;
90+
font-weight: normal;
91+
}
92+
}
93+
7694
span.mx_UserPill {
7795
cursor: pointer;
7896
}

src/Markdown.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ function isAllowedHtmlTag(node: commonmark.Node): boolean {
4141
if (node.literal != null &&
4242
node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) {
4343
return true;
44+
} else if (node.literal != null &&
45+
node.literal.match('^<img data-mx-emoticon') != null) {
46+
return true;
4447
}
4548

4649
// Regex won't work for tags with attrs, but we only

src/autocomplete/Autocompleter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ export interface ISelectionRange {
3737
}
3838

3939
export interface ICompletion {
40-
type: "at-room" | "command" | "community" | "room" | "user";
40+
type?: "at-room" | "command" | "community" | "room" | "user" | "customEmoji";
4141
completion: string;
4242
completionId?: string;
4343
component?: ReactElement;
44-
range: ISelectionRange;
44+
range?: ISelectionRange;
4545
command?: string;
4646
suffix?: string;
4747
// If provided, apply a LINK entity to the completion with the

src/autocomplete/EmojiProvider.tsx

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ limitations under the License.
1818
*/
1919

2020
import React from 'react';
21-
import { uniq, sortBy } from 'lodash';
21+
import { uniq, sortBy, ListIteratee } from 'lodash';
2222
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
2323
import { Room } from 'matrix-js-sdk/src/models/room';
24+
import { MatrixEvent } from 'matrix-js-sdk/src/matrix';
2425

2526
import { _t } from '../languageHandler';
2627
import AutocompleteProvider from './AutocompleteProvider';
@@ -30,6 +31,7 @@ import { ICompletion, ISelectionRange } from './Autocompleter';
3031
import SettingsStore from "../settings/SettingsStore";
3132
import { EMOJI, IEmoji } from '../emoji';
3233
import { TimelineRenderingType } from '../contexts/RoomContext';
34+
import { mediaFromMxc } from '../customisations/Media';
3335

3436
const LIMIT = 20;
3537

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

4042
interface ISortedEmoji {
41-
emoji: IEmoji;
43+
emoji: IEmoji | ICustomEmoji;
4244
_orderBy: number;
4345
}
4446

47+
export interface ICustomEmoji {
48+
shortcodes: string[];
49+
emoticon?: string;
50+
url: string;
51+
}
52+
4553
const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => {
4654
if (a.group === b.group) {
4755
return a.order - b.order;
@@ -65,6 +73,7 @@ function score(query, space) {
6573
export default class EmojiProvider extends AutocompleteProvider {
6674
matcher: QueryMatcher<ISortedEmoji>;
6775
nameMatcher: QueryMatcher<ISortedEmoji>;
76+
customEmojiMatcher: QueryMatcher<ISortedEmoji>;
6877

6978
constructor(room: Room, renderingType?: TimelineRenderingType) {
7079
super({ commandRegex: EMOJI_REGEX, renderingType });
@@ -74,11 +83,42 @@ export default class EmojiProvider extends AutocompleteProvider {
7483
// For matching against ascii equivalents
7584
shouldMatchWordsOnly: false,
7685
});
77-
this.nameMatcher = new QueryMatcher(SORTED_EMOJI, {
86+
this.nameMatcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
7887
keys: ['emoji.annotation'],
7988
// For removing punctuation
8089
shouldMatchWordsOnly: true,
8190
});
91+
92+
// Load this room's image sets.
93+
const loadedImages: ICustomEmoji[] = [];
94+
const imageSetEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
95+
imageSetEvents.forEach(imageSetEvent => {
96+
this.loadImageSet(loadedImages, imageSetEvent);
97+
});
98+
const sortedCustomImages = loadedImages.map((emoji, index) => ({
99+
emoji,
100+
// Include the index so that we can preserve the original order
101+
_orderBy: index,
102+
}));
103+
this.customEmojiMatcher = new QueryMatcher<ISortedEmoji>(sortedCustomImages, {
104+
keys: [],
105+
funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)],
106+
shouldMatchWordsOnly: true,
107+
});
108+
}
109+
110+
private loadImageSet(loadedImages: ICustomEmoji[], imageSetEvent: MatrixEvent): void {
111+
const images = imageSetEvent.getContent().images;
112+
if (!images) {
113+
return;
114+
}
115+
for (const imageKey in images) {
116+
const imageData = images[imageKey];
117+
loadedImages.push({
118+
shortcodes: [imageKey],
119+
url: imageData.url,
120+
});
121+
}
82122
}
83123

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

94-
let completions = [];
134+
let completionResult: ICompletion[] = [];
95135
const { command, range } = this.getCurrentCommand(query, selection);
96136

97137
if (command && command[0].length > 2) {
138+
let completions: ISortedEmoji[] = [];
139+
140+
// find completions
98141
const matchedString = command[0];
99142
completions = this.matcher.match(matchedString, limit);
100143

101144
// Do second match with shouldMatchWordsOnly in order to match against 'name'
102-
completions = completions.concat(this.nameMatcher.match(matchedString));
145+
completions = completions.concat(this.nameMatcher.match(matchedString, limit));
146+
147+
// do a match for the custom emoji
148+
completions = completions.concat(this.customEmojiMatcher.match(matchedString, limit));
103149

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

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

124-
completions = completions.map(c => ({
125-
completion: c.emoji.unicode,
126-
component: (
127-
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
128-
<span>{ c.emoji.unicode }</span>
129-
</PillCompletion>
130-
),
131-
range,
132-
})).slice(0, LIMIT);
170+
completionResult = completions.map(c => {
171+
if ('unicode' in c.emoji) {
172+
return {
173+
completion: c.emoji.unicode,
174+
component: (
175+
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
176+
<span>{ c.emoji.unicode }</span>
177+
</PillCompletion>
178+
),
179+
range,
180+
};
181+
} else {
182+
const mediaUrl = mediaFromMxc(c.emoji.url).getThumbnailOfSourceHttp(24, 24, 'scale');
183+
return {
184+
completion: c.emoji.shortcodes[0],
185+
type: "customEmoji",
186+
completionId: c.emoji.url,
187+
component: (
188+
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`}>
189+
<img
190+
className="mx_BaseAvatar_image"
191+
src={mediaUrl}
192+
alt={c.emoji.shortcodes[0]}
193+
style={{
194+
width: '24px',
195+
height: '24px',
196+
}} />
197+
</PillCompletion>
198+
),
199+
range,
200+
} as const;
201+
}
202+
}).slice(0, LIMIT);
133203
}
134-
return completions;
204+
return completionResult;
135205
}
136206

137207
getName() {

src/autocomplete/UserProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default class UserProvider extends AutocompleteProvider {
5454
renderingType,
5555
});
5656
this.room = room;
57-
this.matcher = new QueryMatcher([], {
57+
this.matcher = new QueryMatcher<RoomMember>([], {
5858
keys: ['name'],
5959
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
6060
shouldMatchWordsOnly: false,

src/editor/autocomplete.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export default class AutocompleteWrapperModel {
109109
case "command":
110110
// command needs special handling for auto complete, but also renders as plain texts
111111
return [(this.partCreator as CommandPartCreator).command(text)];
112+
case "customEmoji":
113+
return [this.partCreator.customEmoji(text, completionId)];
112114
default:
113115
// used for emoji and other plain text completion replacement
114116
return this.partCreator.plainWithEmoji(text);

src/editor/parts.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ import * as Avatar from "../Avatar";
3131
import defaultDispatcher from "../dispatcher/dispatcher";
3232
import { Action } from "../dispatcher/actions";
3333
import SettingsStore from "../settings/SettingsStore";
34+
import { mediaFromMxc } from "../customisations/Media";
3435

3536
interface ISerializedPart {
3637
type: Type.Plain | Type.Newline | Type.Emoji | Type.Command | Type.PillCandidate;
3738
text: string;
3839
}
3940

4041
interface ISerializedPillPart {
41-
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
42+
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
4243
text: string;
4344
resourceId?: string;
4445
}
@@ -49,6 +50,7 @@ export enum Type {
4950
Plain = "plain",
5051
Newline = "newline",
5152
Emoji = "emoji",
53+
CustomEmoji = "custom-emoji",
5254
Command = "command",
5355
UserPill = "user-pill",
5456
RoomPill = "room-pill",
@@ -80,7 +82,7 @@ interface IPillCandidatePart extends Omit<IBasePart, "type" | "createAutoComplet
8082
}
8183

8284
interface IPillPart extends Omit<IBasePart, "type" | "resourceId"> {
83-
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
85+
type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
8486
resourceId: string;
8587
}
8688

@@ -403,6 +405,34 @@ class EmojiPart extends BasePart implements IBasePart {
403405
}
404406
}
405407

408+
class CustomEmojiPart extends PillPart implements IPillPart {
409+
protected get className(): string {
410+
return "mx_CustomEmojiPill";
411+
}
412+
protected setAvatar(node: HTMLElement): void {
413+
const url = mediaFromMxc(this.resourceId).getThumbnailOfSourceHttp(24, 24, "crop");
414+
this.setAvatarVars(node, url, this.text[0]);
415+
}
416+
constructor(shortCode: string, url: string) {
417+
super(url, shortCode);
418+
}
419+
protected acceptsInsertion(chr: string): boolean {
420+
return false;
421+
}
422+
423+
protected acceptsRemoval(position: number, chr: string): boolean {
424+
return false;
425+
}
426+
427+
public get type(): IPillPart["type"] {
428+
return Type.CustomEmoji;
429+
}
430+
431+
public get canEdit(): boolean {
432+
return false;
433+
}
434+
}
435+
406436
class RoomPillPart extends PillPart {
407437
constructor(resourceId: string, label: string, private room: Room) {
408438
super(resourceId, label);
@@ -574,6 +604,8 @@ export class PartCreator {
574604
return this.newline();
575605
case Type.Emoji:
576606
return this.emoji(part.text);
607+
case Type.CustomEmoji:
608+
return this.customEmoji(part.text, part.resourceId);
577609
case Type.AtRoomPill:
578610
return this.atRoomPill(part.text);
579611
case Type.PillCandidate:
@@ -645,6 +677,10 @@ export class PartCreator {
645677
return parts;
646678
}
647679

680+
public customEmoji(shortcode: string, url: string) {
681+
return new CustomEmojiPart(shortcode, url);
682+
}
683+
648684
public createMentionParts(
649685
insertTrailingCharacter: boolean,
650686
displayName: string,

src/editor/serialize.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717

1818
import { AllHtmlEntities } from 'html-entities';
1919
import cheerio from 'cheerio';
20+
import _ from 'lodash';
2021

2122
import Markdown from '../Markdown';
2223
import { makeGenericPermalink } from "../utils/permalinks/Permalinks";
@@ -44,6 +45,10 @@ export function mdSerialize(model: EditorModel): string {
4445
case Type.UserPill:
4546
return html +
4647
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
48+
case Type.CustomEmoji:
49+
return html +
50+
`<img data-mx-emoticon height="18" src="${encodeURI(part.resourceId)}"`
51+
+ ` title=":${_.escape(part.text)}:" alt=":${_.escape(part.text)}:">`;
4752
}
4853
}, "");
4954
}
@@ -176,6 +181,8 @@ export function textSerialize(model: EditorModel): string {
176181
return text + `${part.resourceId}`;
177182
case Type.UserPill:
178183
return text + `${part.text}`;
184+
case Type.CustomEmoji:
185+
return text + `:${part.text}:`;
179186
}
180187
}, "");
181188
}

test/editor/serialize-test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ describe('editor/serialize', function() {
3838
const html = htmlSerializeIfNeeded(model, {});
3939
expect(html).toBeFalsy();
4040
});
41+
it('custom emoji pill turns message into html', function() {
42+
const pc = createPartCreator();
43+
const model = new EditorModel([pc.customEmoji("poggers", "mxc://matrix.org/test")]);
44+
const html = htmlSerializeIfNeeded(model, {});
45+
expect(html).toBe("<img data-mx-emoticon height=\"18\" src=\"mxc://matrix.org/test\""
46+
+ " title=\":poggers:\" alt=\":poggers:\">");
47+
});
4148
it('any markdown turns message into html', function() {
4249
const pc = createPartCreator();
4350
const model = new EditorModel([pc.plain("*hello* world")]);

0 commit comments

Comments
 (0)