From 6806c2cdca98fd3867b29d7bffc8d687db0a6f05 Mon Sep 17 00:00:00 2001
From: Robin <robin@robin.town>
Date: Mon, 24 Jan 2022 07:53:05 -0500
Subject: [PATCH] Enlarge emoji in composer (#7602)

---
 res/css/views/elements/_RichText.scss         |  5 ++
 res/css/views/rooms/_EventBubbleTile.scss     |  2 +
 res/css/views/rooms/_EventTile.scss           |  7 +-
 res/css/views/rooms/_SendMessageComposer.scss |  2 +
 src/HtmlUtils.tsx                             |  7 +-
 .../views/rooms/BasicMessageComposer.tsx      |  4 +-
 src/editor/autocomplete.ts                    |  2 +-
 src/editor/caret.ts                           | 10 +--
 src/editor/deserialize.ts                     | 38 ++++----
 src/editor/parts.ts                           | 87 ++++++++++++++++++-
 src/editor/render.ts                          |  4 +-
 src/editor/serialize.ts                       |  2 +
 12 files changed, 128 insertions(+), 42 deletions(-)

diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss
index 03c3741d5e..6402de1b62 100644
--- a/res/css/views/elements/_RichText.scss
+++ b/res/css/views/elements/_RichText.scss
@@ -86,6 +86,11 @@ a.mx_Pill {
     margin-right: 0.24rem;
 }
 
+.mx_Emoji {
+    font-size: 1.8rem;
+    vertical-align: bottom;
+}
+
 .mx_Markdown_BOLD {
     font-weight: bold;
 }
diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index 5b18797527..8ab391facf 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -88,6 +88,8 @@ limitations under the License.
     .mx_EventTile_line {
         width: fit-content;
         max-width: 70%;
+        // fixed line height to prevent emoji from being taller than text
+        line-height: $font-18px;
     }
 
     > .mx_SenderProfile {
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index ddf10840c8..3cd5bb9c5f 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -233,11 +233,6 @@ $left-gutter: 64px;
         overflow-y: hidden;
     }
 
-    .mx_EventTile_Emoji {
-        font-size: 1.8rem;
-        vertical-align: bottom;
-    }
-
     &.mx_EventTile_selected .mx_EventTile_line,
     &:hover .mx_EventTile_line {
         border-top-left-radius: 4px;
@@ -391,7 +386,7 @@ $left-gutter: 64px;
     position: absolute;
 }
 
-.mx_EventTile_bigEmoji .mx_EventTile_Emoji {
+.mx_EventTile_bigEmoji .mx_Emoji {
     font-size: 48px !important;
     line-height: 57px;
 }
diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss
index c7e6ea6a6e..1e2b060096 100644
--- a/res/css/views/rooms/_SendMessageComposer.scss
+++ b/res/css/views/rooms/_SendMessageComposer.scss
@@ -19,6 +19,8 @@ limitations under the License.
     display: flex;
     flex-direction: column;
     font-size: $font-14px;
+    // fixed line height to prevent emoji from being taller than text
+    line-height: calc(1.2 * $font-14px);
     justify-content: center;
     margin-right: 6px;
     // don't grow wider than available space
diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index 2f0e4fc8c5..3c8d100be3 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -89,9 +89,8 @@ const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)
  * Uses a much, much simpler regex than emojibase's so will give false
  * positives, but useful for fast-path testing strings to see if they
  * need emojification.
- * unicodeToImage uses this function.
  */
-function mightContainEmoji(str: string): boolean {
+export function mightContainEmoji(str: string): boolean {
     return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
 }
 
@@ -412,9 +411,9 @@ export interface IOptsReturnString extends IOpts {
 }
 
 const emojiToHtmlSpan = (emoji: string) =>
-    `<span class='mx_EventTile_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`;
+    `<span class='mx_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`;
 const emojiToJsxSpan = (emoji: string, key: number) =>
-    <span key={key} className='mx_EventTile_Emoji' title={unicodeToShortcode(emoji)}>{ emoji }</span>;
+    <span key={key} className='mx_Emoji' title={unicodeToShortcode(emoji)}>{ emoji }</span>;
 
 /**
  * Wraps emojis in <span> to style them separately from the rest of message. Consecutive emojis (and modifiers) are wrapped
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index 2d5598af92..1bce5031da 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -199,7 +199,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
 
                 // this returns the amount of added/removed characters during the replace
                 // so the caret position can be adjusted.
-                return range.replace([partCreator.plain(data.unicode)]);
+                return range.replace([partCreator.emoji(data.unicode)]);
             }
         }
     }
@@ -831,7 +831,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
         const caret = this.getCaret();
         const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
         model.transform(() => {
-            const addedLen = model.insert([partCreator.plain(text)], position);
+            const addedLen = model.insert(partCreator.plainWithEmoji(text), position);
             return model.positionForOffset(caret.offset + addedLen, true);
         });
     }
diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts
index 10e1c60695..7a6cda9d44 100644
--- a/src/editor/autocomplete.ts
+++ b/src/editor/autocomplete.ts
@@ -111,7 +111,7 @@ export default class AutocompleteWrapperModel {
                 return [(this.partCreator as CommandPartCreator).command(text)];
             default:
                 // used for emoji and other plain text completion replacement
-                return [this.partCreator.plain(text)];
+                return this.partCreator.plainWithEmoji(text);
         }
     }
 }
diff --git a/src/editor/caret.ts b/src/editor/caret.ts
index 2b5035b567..b2b7846880 100644
--- a/src/editor/caret.ts
+++ b/src/editor/caret.ts
@@ -99,7 +99,7 @@ export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosit
         offset = 0;
     } else {
         // move caret out of uneditable part (into caret node, or empty line br) if needed
-        ({ nodeIndex, offset } = moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset));
+        ({ nodeIndex, offset } = moveOutOfUnselectablePart(parts, partIndex, nodeIndex, offset));
     }
     return { lineIndex, nodeIndex, offset };
 }
@@ -123,7 +123,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
                 nodeIndex += 1;
             }
             // only jump over caret node if we're not at our destination node already,
-            // as we'll assume in moveOutOfUneditablePart that nodeIndex
+            // as we'll assume in moveOutOfUnselectablePart that nodeIndex
             // refers to the node  corresponding to the part,
             // and not an adjacent caret node
             if (i < partIndex) {
@@ -140,10 +140,10 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
     return { lineIndex, nodeIndex };
 }
 
-function moveOutOfUneditablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
-    // move caret before or after uneditable part
+function moveOutOfUnselectablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
+    // move caret before or after unselectable part
     const part = parts[partIndex];
-    if (part && !part.canEdit) {
+    if (part && !part.acceptsCaret) {
         if (offset === 0) {
             nodeIndex -= 1;
             const prevPart = parts[partIndex - 1];
diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts
index 0215785acf..f016a1f61c 100644
--- a/src/editor/deserialize.ts
+++ b/src/editor/deserialize.ts
@@ -29,7 +29,7 @@ function parseAtRoomMentions(text: string, partCreator: PartCreator): Part[] {
     const parts: Part[] = [];
     text.split(ATROOM).forEach((textPart, i, arr) => {
         if (textPart.length) {
-            parts.push(partCreator.plain(textPart));
+            parts.push(...partCreator.plainWithEmoji(textPart));
         }
         // it's safe to never append @room after the last textPart
         // as split will report an empty string at the end if
@@ -42,28 +42,28 @@ function parseAtRoomMentions(text: string, partCreator: PartCreator): Part[] {
     return parts;
 }
 
-function parseLink(a: HTMLAnchorElement, partCreator: PartCreator): Part {
+function parseLink(a: HTMLAnchorElement, partCreator: PartCreator): Part[] {
     const { href } = a;
     const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID
     const prefix = resourceId ? resourceId[0] : undefined; // First character of ID
     switch (prefix) {
         case "@":
-            return partCreator.userPill(a.textContent, resourceId);
+            return [partCreator.userPill(a.textContent, resourceId)];
         case "#":
-            return partCreator.roomPill(resourceId);
+            return [partCreator.roomPill(resourceId)];
         default: {
             if (href === a.textContent) {
-                return partCreator.plain(a.textContent);
+                return partCreator.plainWithEmoji(a.textContent);
             } else {
-                return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`);
+                return partCreator.plainWithEmoji(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`);
             }
         }
     }
 }
 
-function parseImage(img: HTMLImageElement, partCreator: PartCreator): Part {
+function parseImage(img: HTMLImageElement, partCreator: PartCreator): Part[] {
     const { src } = img;
-    return partCreator.plain(`![${img.alt.replace(/[[\\\]]/g, c => "\\" + c)}](${src})`);
+    return partCreator.plainWithEmoji(`![${img.alt.replace(/[[\\\]]/g, c => "\\" + c)}](${src})`);
 }
 
 function parseCodeBlock(n: HTMLElement, partCreator: PartCreator): Part[] {
@@ -79,7 +79,7 @@ function parseCodeBlock(n: HTMLElement, partCreator: PartCreator): Part[] {
     }
     const preLines = ("```" + language + "\n" + n.textContent + "```").split("\n");
     preLines.forEach((l, i) => {
-        parts.push(partCreator.plain(l));
+        parts.push(...partCreator.plainWithEmoji(l));
         if (i < preLines.length - 1) {
             parts.push(partCreator.newline());
         }
@@ -126,21 +126,21 @@ function parseElement(
                 partCreator.newline(),
             ];
         case "EM":
-            return partCreator.plain(`_${n.textContent}_`);
+            return partCreator.plainWithEmoji(`_${n.textContent}_`);
         case "STRONG":
-            return partCreator.plain(`**${n.textContent}**`);
+            return partCreator.plainWithEmoji(`**${n.textContent}**`);
         case "PRE":
             return parseCodeBlock(n, partCreator);
         case "CODE":
-            return partCreator.plain(`\`${n.textContent}\``);
+            return partCreator.plainWithEmoji(`\`${n.textContent}\``);
         case "DEL":
-            return partCreator.plain(`<del>${n.textContent}</del>`);
+            return partCreator.plainWithEmoji(`<del>${n.textContent}</del>`);
         case "SUB":
-            return partCreator.plain(`<sub>${n.textContent}</sub>`);
+            return partCreator.plainWithEmoji(`<sub>${n.textContent}</sub>`);
         case "SUP":
-            return partCreator.plain(`<sup>${n.textContent}</sup>`);
+            return partCreator.plainWithEmoji(`<sup>${n.textContent}</sup>`);
         case "U":
-            return partCreator.plain(`<u>${n.textContent}</u>`);
+            return partCreator.plainWithEmoji(`<u>${n.textContent}</u>`);
         case "LI": {
             const BASE_INDENT = 4;
             const depth = state.listDepth - 1;
@@ -171,9 +171,9 @@ function parseElement(
                     ((SdkConfig.get()['latex_maths_delims'] || {})['inline'] || {})['right'] || "\\)" :
                     ((SdkConfig.get()['latex_maths_delims'] || {})['display'] || {})['right'] || "\\]";
                 const tex = n.getAttribute("data-mx-maths");
-                return partCreator.plain(delimLeft + tex + delimRight);
+                return partCreator.plainWithEmoji(delimLeft + tex + delimRight);
             } else if (!checkDescendInto(n)) {
-                return partCreator.plain(n.textContent);
+                return partCreator.plainWithEmoji(n.textContent);
             }
             break;
         }
@@ -186,7 +186,7 @@ function parseElement(
         default:
             // don't textify block nodes we'll descend into
             if (!checkDescendInto(n)) {
-                return partCreator.plain(n.textContent);
+                return partCreator.plainWithEmoji(n.textContent);
             }
     }
 }
diff --git a/src/editor/parts.ts b/src/editor/parts.ts
index 277b4bb526..70e6f82518 100644
--- a/src/editor/parts.ts
+++ b/src/editor/parts.ts
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import { split } from "lodash";
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { Room } from "matrix-js-sdk/src/models/room";
@@ -24,12 +25,13 @@ import AutocompleteWrapperModel, {
     UpdateCallback,
     UpdateQuery,
 } from "./autocomplete";
+import { mightContainEmoji, unicodeToShortcode } from "../HtmlUtils";
 import * as Avatar from "../Avatar";
 import defaultDispatcher from "../dispatcher/dispatcher";
 import { Action } from "../dispatcher/actions";
 
 interface ISerializedPart {
-    type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
+    type: Type.Plain | Type.Newline | Type.Emoji | Type.Command | Type.PillCandidate;
     text: string;
 }
 
@@ -44,6 +46,7 @@ export type SerializedPart = ISerializedPart | ISerializedPillPart;
 export enum Type {
     Plain = "plain",
     Newline = "newline",
+    Emoji = "emoji",
     Command = "command",
     UserPill = "user-pill",
     RoomPill = "room-pill",
@@ -53,8 +56,9 @@ export enum Type {
 
 interface IBasePart {
     text: string;
-    type: Type.Plain | Type.Newline;
+    type: Type.Plain | Type.Newline | Type.Emoji;
     canEdit: boolean;
+    acceptsCaret: boolean;
 
     createAutoComplete(updateCallback: UpdateCallback): void;
 
@@ -165,6 +169,10 @@ abstract class BasePart {
         return true;
     }
 
+    public get acceptsCaret(): boolean {
+        return this.canEdit;
+    }
+
     public toString(): string {
         return `${this.type}(${this.text})`;
     }
@@ -183,7 +191,7 @@ abstract class BasePart {
 
 abstract class PlainBasePart extends BasePart {
     protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
-        if (chr === "\n") {
+        if (chr === "\n" || mightContainEmoji(chr)) {
             return false;
         }
         // when not pasting or dropping text, reject characters that should start a pill candidate
@@ -351,6 +359,48 @@ class NewlinePart extends BasePart implements IBasePart {
     }
 }
 
+class EmojiPart extends BasePart implements IBasePart {
+    protected acceptsInsertion(chr: string, offset: number): boolean {
+        return false;
+    }
+
+    protected acceptsRemoval(position: number, chr: string): boolean {
+        return false;
+    }
+
+    public toDOMNode(): Node {
+        const span = document.createElement("span");
+        span.className = "mx_Emoji";
+        span.setAttribute("title", unicodeToShortcode(this.text));
+        span.appendChild(document.createTextNode(this.text));
+        return span;
+    }
+
+    public updateDOMNode(node: HTMLElement): void {
+        const textNode = node.childNodes[0];
+        if (textNode.textContent !== this.text) {
+            node.setAttribute("title", unicodeToShortcode(this.text));
+            textNode.textContent = this.text;
+        }
+    }
+
+    public canUpdateDOMNode(node: HTMLElement): boolean {
+        return node.className === "mx_Emoji";
+    }
+
+    public get type(): IBasePart["type"] {
+        return Type.Emoji;
+    }
+
+    public get canEdit(): boolean {
+        return false;
+    }
+
+    public get acceptsCaret(): boolean {
+        return true;
+    }
+}
+
 class RoomPillPart extends PillPart {
     constructor(resourceId: string, label: string, private room: Room) {
         super(resourceId, label);
@@ -503,6 +553,9 @@ export class PartCreator {
             case "\n":
                 return new NewlinePart();
             default:
+                if (mightContainEmoji(input[0])) {
+                    return new EmojiPart();
+                }
                 return new PlainPart();
         }
     }
@@ -517,6 +570,8 @@ export class PartCreator {
                 return this.plain(part.text);
             case Type.Newline:
                 return this.newline();
+            case Type.Emoji:
+                return this.emoji(part.text);
             case Type.AtRoomPill:
                 return this.atRoomPill(part.text);
             case Type.PillCandidate:
@@ -536,6 +591,10 @@ export class PartCreator {
         return new NewlinePart("\n");
     }
 
+    public emoji(text: string): EmojiPart {
+        return new EmojiPart(text);
+    }
+
     public pillCandidate(text: string): PillCandidatePart {
         return new PillCandidatePart(text, this.autoCompleteCreator);
     }
@@ -562,6 +621,28 @@ export class PartCreator {
         return new UserPillPart(userId, displayName, member);
     }
 
+    public plainWithEmoji(text: string): (PlainPart | EmojiPart)[] {
+        const parts = [];
+        let plainText = "";
+
+        // We use lodash's grapheme splitter to avoid breaking apart compound emojis
+        for (const char of split(text, "")) {
+            if (mightContainEmoji(char)) {
+                if (plainText) {
+                    parts.push(this.plain(plainText));
+                    plainText = "";
+                }
+                parts.push(this.emoji(char));
+            } else {
+                plainText += char;
+            }
+        }
+        if (plainText) {
+            parts.push(this.plain(plainText));
+        }
+        return parts;
+    }
+
     public createMentionParts(
         insertTrailingCharacter: boolean,
         displayName: string,
diff --git a/src/editor/render.ts b/src/editor/render.ts
index d9997de855..e3e6fcb413 100644
--- a/src/editor/render.ts
+++ b/src/editor/render.ts
@@ -20,11 +20,11 @@ import EditorModel from "./model";
 
 export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
     const isFirst = !prevPart || prevPart.type === Type.Newline;
-    return !part.canEdit && (isFirst || !prevPart.canEdit);
+    return !part.acceptsCaret && (isFirst || !prevPart.acceptsCaret);
 }
 
 export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
-    return !part.canEdit && isLastOfLine;
+    return !part.acceptsCaret && isLastOfLine;
 }
 
 function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts
index 8dc4ed58df..4618cf79c1 100644
--- a/src/editor/serialize.ts
+++ b/src/editor/serialize.ts
@@ -31,6 +31,7 @@ export function mdSerialize(model: EditorModel): string {
             case Type.Newline:
                 return html + "\n";
             case Type.Plain:
+            case Type.Emoji:
             case Type.Command:
             case Type.PillCandidate:
             case Type.AtRoomPill:
@@ -164,6 +165,7 @@ export function textSerialize(model: EditorModel): string {
             case Type.Newline:
                 return text + "\n";
             case Type.Plain:
+            case Type.Emoji:
             case Type.Command:
             case Type.PillCandidate:
             case Type.AtRoomPill: