diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss
index 852097762ed..8893f670a4c 100644
--- a/res/css/views/elements/_RichText.scss
+++ b/res/css/views/elements/_RichText.scss
@@ -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;
diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss
index 73ff9f048b2..051a74b7ca1 100644
--- a/res/css/views/rooms/_BasicMessageComposer.scss
+++ b/res/css/views/rooms/_BasicMessageComposer.scss
@@ -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;
}
diff --git a/src/Markdown.ts b/src/Markdown.ts
index 9f88fbe41f1..53841c809ef 100644
--- a/src/Markdown.ts
+++ b/src/Markdown.ts
@@ -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('^ {
if (a.group === b.group) {
return a.order - b.order;
@@ -65,6 +73,7 @@ function score(query, space) {
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher;
nameMatcher: QueryMatcher;
+ customEmojiMatcher: QueryMatcher;
constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: EMOJI_REGEX, renderingType });
@@ -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(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(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(
@@ -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[] = [];
// make sure that emoticons come first
sorters.push(c => score(matchedString, c.emoji.emoticon || ""));
@@ -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: (
-
- { c.emoji.unicode }
-
- ),
- range,
- })).slice(0, LIMIT);
+ completionResult = completions.map(c => {
+ if ('unicode' in c.emoji) {
+ return {
+ completion: c.emoji.unicode,
+ component: (
+
+ { c.emoji.unicode }
+
+ ),
+ 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: (
+
+
+
+ ),
+ range,
+ } as const;
+ }
+ }).slice(0, LIMIT);
}
- return completions;
+ return completionResult;
}
getName() {
diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx
index faf0eed4fbf..65624a33285 100644
--- a/src/autocomplete/UserProvider.tsx
+++ b/src/autocomplete/UserProvider.tsx
@@ -54,7 +54,7 @@ export default class UserProvider extends AutocompleteProvider {
renderingType,
});
this.room = room;
- this.matcher = new QueryMatcher([], {
+ this.matcher = new QueryMatcher([], {
keys: ['name'],
funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchWordsOnly: false,
diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts
index 7a6cda9d44d..30e992d629c 100644
--- a/src/editor/autocomplete.ts
+++ b/src/editor/autocomplete.ts
@@ -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);
diff --git a/src/editor/parts.ts b/src/editor/parts.ts
index b8cf61a1e2e..aa5c1fb27b7 100644
--- a/src/editor/parts.ts
+++ b/src/editor/parts.ts
@@ -31,6 +31,7 @@ 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;
@@ -38,7 +39,7 @@ interface ISerializedPart {
}
interface ISerializedPillPart {
- type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
+ type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
text: string;
resourceId?: string;
}
@@ -49,6 +50,7 @@ export enum Type {
Plain = "plain",
Newline = "newline",
Emoji = "emoji",
+ CustomEmoji = "custom-emoji",
Command = "command",
UserPill = "user-pill",
RoomPill = "room-pill",
@@ -80,7 +82,7 @@ interface IPillCandidatePart extends Omit {
- type: Type.AtRoomPill | Type.RoomPill | Type.UserPill;
+ type: Type.AtRoomPill | Type.RoomPill | Type.UserPill | Type.CustomEmoji;
resourceId: string;
}
@@ -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);
@@ -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:
@@ -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,
diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts
index 2c377119ad4..64d05129e48 100644
--- a/src/editor/serialize.ts
+++ b/src/editor/serialize.ts
@@ -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";
@@ -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 +
+ ``;
}
}, "");
}
@@ -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}:`;
}
}, "");
}
diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js
index 085a8afdbae..519987fe438 100644
--- a/test/editor/serialize-test.js
+++ b/test/editor/serialize-test.js
@@ -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("");
+ });
it('any markdown turns message into html', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("*hello* world")]);