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: ( + + {c.emoji.shortcodes[0]} + + ), + 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 + + `:${_.escape(part.text)}:`; } }, ""); } @@ -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("\":poggers:\""); + }); it('any markdown turns message into html', function() { const pc = createPartCreator(); const model = new EditorModel([pc.plain("*hello* world")]);