소스 검색

Initial compose-area integration

Danilo Bargen 6 년 전
부모
커밋
a360d2e181
13개의 변경된 파일261개의 추가작업 그리고 364개의 파일을 삭제
  1. 3 1
      dist/package.sh
  2. 10 0
      package-lock.json
  3. 2 0
      package.json
  4. 75 286
      src/directives/compose_area.ts
  5. 0 55
      src/helpers.ts
  6. 43 0
      src/helpers/emoji.ts
  7. 16 7
      src/threema.d.ts
  8. 10 0
      src/typeguards.ts
  9. 11 4
      tests/testsuite.html
  10. 70 1
      tests/ts/emoji_helpers.ts
  11. 13 3
      tests/ui/compose_area.html
  12. 7 0
      tests/ui/compose_area.ts
  13. 1 7
      tests/ui/run.ts

+ 3 - 1
dist/package.sh

@@ -45,7 +45,9 @@ mkdir -p $DIR/{partials,directives,components,node_modules,partials/messenger.re
 
 echo "+ Copy code..."
 cp -R index.html $DIR/
-cp -R dist/generated/*.js $DIR/
+cp -R dist/generated/*.bundle.js $DIR/
+cp -R dist/generated/*.bundle.js.map $DIR/
+cp -R dist/generated/*.wasm $DIR/
 cp -R public/* $DIR/
 cp -R troubleshoot/* $DIR/troubleshoot/
 cp -R src/partials/*.html $DIR/partials/

+ 10 - 0
package-lock.json

@@ -949,6 +949,11 @@
         "tslib": "^1.9.3"
       }
     },
+    "@threema/compose-area": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@threema/compose-area/-/compose-area-0.1.1.tgz",
+      "integrity": "sha512-9f0oaqgUa9jNEKH+vpd08GkA8GNhyh7TQKcLU3BjfCHq+IlQhm1lAPx1yICXOYJgq/id93TG7ikSUOjeHz8b+A=="
+    },
     "@types/angular": {
       "version": "1.6.53",
       "resolved": "https://registry.npmjs.org/@types/angular/-/angular-1.6.53.tgz",
@@ -3817,6 +3822,11 @@
         "minimalistic-crypto-utils": "^1.0.0"
       }
     },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
+    },
     "emojis-list": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",

+ 2 - 0
package.json

@@ -42,6 +42,7 @@
     "@saltyrtc/client": "^0.13.2",
     "@saltyrtc/task-relayed-data": "^0.3.1",
     "@saltyrtc/task-webrtc": "^0.13.0",
+    "@threema/compose-area": "^0.1.1",
     "@types/angular": "^1.6.53",
     "@types/angular-material": "^1.1.62",
     "@types/angular-sanitize": "^1.3.7",
@@ -65,6 +66,7 @@
     "babel-loader": "^8.0.5",
     "core-js": "^3.0.1",
     "croppie": "^2.6.3",
+    "emoji-regex": "^8.0.0",
     "file-saver": "2.0.0",
     "messageformat": "^2.0.5",
     "msgpack-lite": "~0.1.26",

+ 75 - 286
src/directives/compose_area.ts

@@ -15,15 +15,16 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import {ComposeArea} from '@threema/compose-area';
 import * as twemoji from 'twemoji';
 
-import {extractText, hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
-import {emojify, shortnameToUnicode} from '../helpers/emoji';
+import {hasValue, isActionTrigger, logAdapter, replaceWhitespace} from '../helpers';
+import {emojify, emojifyNew, shortnameToUnicode} from '../helpers/emoji';
 import {BrowserService} from '../services/browser';
 import {ReceiverService} from '../services/receiver';
 import {StringService} from '../services/string';
 import {TimeoutService} from '../services/timeout';
-import {isElementNode, isTextNode} from '../typeguards';
+import {isElementNode, isEmojiInfo, isTextNode} from '../typeguards';
 
 /**
  * The compose area where messages are written.
@@ -52,6 +53,9 @@ export default [
         return {
             restrict: 'EA',
             scope: {
+                // Callback to get a reference to the initialized ComposeArea instance.
+                onInit: '=',
+
                 // Callback to submit text or file data
                 submit: '=',
 
@@ -79,7 +83,7 @@ export default [
                 const TRIGGER_ACTIVE_CSS_CLASS = 'is-active';
 
                 // Elements
-                const composeArea: any = element;
+                const wrapper: any = element;
                 const composeDiv: any = angular.element(element[0].querySelector('div.compose'));
                 const emojiTrigger: any = angular.element(element[0].querySelector('i.emoji-trigger'));
                 const emojiKeyboard: any = angular.element(element[0].querySelector('.emoji-keyboard'));
@@ -87,6 +91,12 @@ export default [
                 const fileTrigger: any = angular.element(element[0].querySelector('i.file-trigger'));
                 const fileInput: any = angular.element(element[0].querySelector('input.file-input'));
 
+                // Initialize compose area lib
+                const composeArea = ComposeArea.bind_to(composeDiv[0]);
+                if (scope.onInit) {
+                    scope.onInit(composeArea);
+                }
+
                 // Set initial text
                 if (scope.initialData.initialText) {
                     composeDiv[0].innerText = scope.initialData.initialText;
@@ -95,16 +105,6 @@ export default [
                     composeDiv[0].innerText = scope.initialData.draft;
                 }
 
-                // The current caret position, used when inserting objects
-                let caretPosition: {
-                    // The position in the source HTML
-                    from?: number,
-                    to?: number,
-                    // The position in the visible character list
-                    fromChar?: number,
-                    toChar?: number,
-                } = null;
-
                 let chatBlocked = false;
 
                 // Function to update blocking state
@@ -158,7 +158,7 @@ export default [
                         get: function() {
                             if (instance === undefined) {
                                 instance = {
-                                    htmlElement: composeArea[0].querySelector('div.twemoji-picker'),
+                                    htmlElement: wrapper[0].querySelector('div.twemoji-picker'),
                                 };
                                 // append stop propagation
                                 angular.element(instance.htmlElement).on('click', click);
@@ -208,24 +208,14 @@ export default [
 
                 // Determine whether field is empty
                 function composeAreaIsEmpty() {
-                    const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
-                    return text.length === 0;
+                    return composeArea.get_text(false).length === 0;
                 }
 
                 // Submit the text from the compose area.
                 //
                 // Emoji images are converted to their alt text in this process.
                 function submitText(): Promise<any> {
-                    const rawText = extractText(composeDiv[0], logAdapter($log.warn, logTag));
-
-                    // Due to #731, and the hack introduced in #706, the
-                    // extracted text may contain non-breaking spaces (U+00A0).
-                    // Replace them with actual whitespace to avoid strange
-                    // behavior when submitting the text.
-                    //
-                    // TODO: Remove this once we have a compose area rewrite and can
-                    // fix the actual bug.
-                    const text = rawText.replace(/\u00A0/g, ' ');
+                    const text = composeArea.get_text(false).replace(/\r/g, '');
 
                     return new Promise((resolve, reject) => {
                         const submitTexts = (strings: string[]) => {
@@ -241,9 +231,8 @@ export default [
                                 .catch(reject);
                         };
 
-                        const fullText = text.trim().replace(/\r/g, '');
-                        if (fullText.length > scope.maxTextLength) {
-                            const pieces: string[] = stringService.byteChunk(fullText, scope.maxTextLength, 50);
+                        if (text.length > scope.maxTextLength) {
+                            const pieces: string[] = stringService.byteChunk(text, scope.maxTextLength, 50);
                             const confirm = $mdDialog.confirm()
                                 .title($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_SUBJECT'))
                                 .textContent($translate.instant('messenger.MESSAGE_TOO_LONG_SPLIT_BODY', {
@@ -259,7 +248,7 @@ export default [
                                 reject();
                             });
                         } else {
-                            submitTexts([fullText]);
+                            submitTexts([text]);
                         }
                     });
                 }
@@ -268,6 +257,7 @@ export default [
                     if (!composeAreaIsEmpty()) {
                         submitText().then(() => {
                             // Clear compose div
+                            // TODO: Use clear() and focus() methods
                             composeDiv[0].innerText = '';
                             composeDiv[0].focus();
 
@@ -321,30 +311,31 @@ export default [
                     $timeout(() => {
                         // If the compose area contains only a single <br>, make it fully empty.
                         // See also: https://stackoverflow.com/q/14638887/284318
-                        const text = extractText(composeDiv[0], logAdapter($log.warn, logTag), false);
+                        const text = composeArea.get_text(true);
                         if (text === '\n') {
                             composeDiv[0].innerText = '';
-                        } else if ((ev.keyCode === 190 || ev.key === ':') && caretPosition !== null) {
-                            // A ':' is pressed, try to parse
-                            const currentWord = stringService.getWord(text, caretPosition.fromChar, [':']);
-                            if (currentWord.realLength > 2 && currentWord.word.substr(0, 1) === ':') {
-                                const trimmed = currentWord.word.substr(1, currentWord.word.length - 2);
-                                const unicodeEmoji = shortnameToUnicode(trimmed);
-                                if (unicodeEmoji !== null) {
-                                    return insertEmoji(unicodeEmoji,
-                                        caretPosition.from - currentWord.realLength,
-                                        caretPosition.to);
-                                }
-                            }
+                        // TODO
+                        // } else if ((ev.keyCode === 190 || ev.key === ':') && caretPosition !== null) {
+                        //    // A ':' is pressed, try to parse
+                        //    const currentWord = stringService.getWord(text, caretPosition.fromChar, [':']);
+                        //    if (currentWord.realLength > 2 && currentWord.word.substr(0, 1) === ':') {
+                        //        const trimmed = currentWord.word.substr(1, currentWord.word.length - 2);
+                        //        const unicodeEmoji = shortnameToUnicode(trimmed);
+                        //        if (unicodeEmoji !== null) {
+                        //            return insertEmoji(unicodeEmoji,
+                        //                caretPosition.from - currentWord.realLength,
+                        //                caretPosition.to);
+                        //        }
+                        //    }
                         }
 
-                        // Update typing information (use text instead method)
-                        if (text.trim().length === 0 || caretPosition === null) {
+                        // Update typing information
+                        if (text.trim().length === 0) {
                             stopTyping();
                             scope.onTyping('');
                         } else {
                             startTyping();
-                            scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
+                            scope.onTyping(text.trim(), null/* TODO stringService.getWord(text, caretPosition.from)*/);
                         }
 
                         updateView();
@@ -479,23 +470,17 @@ export default [
                     // Handle pasting of text
                     } else if (textIdx !== null) {
                         const text = ev.clipboardData.getData('text/plain');
-
-                        // Look up some filter functions
-                        // tslint:disable-next-line:max-line-length
-                        const escapeHtml = $filter('escapeHtml') as (a: string) => string;
-                        const mentionify = $filter('mentionify') as (a: string) => string;
-                        const nlToBr = $filter('nlToBr') as (a: string, b?: boolean) => string;
-
-                        // Escape HTML markup
-                        const escaped = escapeHtml(text);
-
-                        // Apply filters (emojify, convert newline, etc)
-                        const formatted = emojify(mentionify(replaceWhitespace(nlToBr(escaped, true))));
-
-                        // Insert resulting HTML
-                        document.execCommand('insertHTML', false, formatted);
-
-                        updateView();
+                        if (text) {
+                            const tokens = emojifyNew(text);
+                            for (const token of tokens) {
+                                if (isEmojiInfo(token)) {
+                                    insertEmoji(token);
+                                } else {
+                                    composeArea.insert_text(token);
+                                }
+                            }
+                            updateView();
+                        }
                     }
                 }
 
@@ -563,7 +548,7 @@ export default [
                 // Emoji is chosen
                 function onEmojiChosen(ev: MouseEvent): void {
                     ev.stopPropagation();
-                    insertEmoji(this.textContent);
+                    insertEmojiString(this.textContent);
                 }
 
                 // Emoji tab is selected
@@ -574,113 +559,30 @@ export default [
                     }
                 }
 
-                function insertEmoji(emoji, posFrom?: number, posTo?: number): void {
-                    const emojiElement = emojify(emoji);
-                    insertHTMLElement(emoji, emojiElement, posFrom, posTo);
-                }
-
-                function insertMention(mentionString, posFrom?: number, posTo?: number): void {
-                    const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
-                    insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
-                }
-
-                function insertHTMLElement(
-                    elementText: string, // The element as the original text representation, not yet converted to HTML
-                    elementHtml: string, // The element converted to HTML
-                    posFrom?: number,
-                    posTo?: number,
-                ): void {
-                    // In Chrome in right-to-left mode, our content editable
-                    // area may contain a DIV element.
-                    const childNodes = composeDiv[0].childNodes;
-                    const nestedDiv = childNodes.length === 1
-                        && childNodes[0].tagName !== undefined
-                        && childNodes[0].tagName.toLowerCase() === 'div';
-                    let contentElement;
-                    if (nestedDiv === true) {
-                        contentElement = composeDiv[0].childNodes[0];
-                    } else {
-                        contentElement = composeDiv[0];
-                    }
-
-                    let currentHtml = '';
-                    for (let i = 0; i < contentElement.childNodes.length; i++) {
-                        const node: Node = contentElement.childNodes[i];
-
-                        if (isTextNode(node)) {
-                            currentHtml += node.textContent;
-                        } else if (isElementNode(node)) {
-                            const tag = node.tagName.toLowerCase();
-                            if (tag === 'img' || tag === 'span') {
-                                currentHtml += getOuterHtml(node);
-                            } else if (tag === 'br') {
-                                // Firefox inserts a <br> after editing content editable fields.
-                                // Remove the last <br> to fix this.
-                                if (i < contentElement.childNodes.length - 1) {
-                                    currentHtml += getOuterHtml(node);
-                                }
-                            } else if (tag === 'div') {
-                                // Safari inserts a <div><br></div> after editing content editable fields.
-                                // Remove the last instance to fix this.
-                                if (node.childNodes.length === 1
-                                    && isElementNode(node.lastChild)
-                                    && node.lastChild.tagName.toLowerCase() === 'br') {
-                                    // Ignore
-                                } else {
-                                    currentHtml += getOuterHtml(node);
-                                }
-                            }
-                        }
+                // Insert a single emoji, passed in as string
+                function insertEmojiString(emojiString: string): void {
+                    const tokens = emojifyNew(emojiString);
+                    if (tokens.length !== 1) {
+                        throw new Error(`Emoji parsing failed: Expected 1 element, found ${tokens.length}`);
                     }
-
-                    // Because the browser may transform HTML code when
-                    // inserting it into the DOM, we temporarily write it to a
-                    // DOM element to ensure that the current representation
-                    // corresponds to the representation when inserted into the
-                    // DOM. (See #671 for details.)
-                    const tmpDiv = document.createElement('div');
-                    tmpDiv.innerHTML = elementHtml;
-                    const cleanedElementHtml = tmpDiv.innerHTML;
-
-                    // Insert element into currentHtml and determine new caret position
-                    let newPos = posFrom;
-                    if (caretPosition !== null) {
-                        // If the caret position is set, then the user has moved around
-                        // in the contenteditable field and might not be ad the end
-                        // of the line.
-                        posFrom = posFrom === undefined ? caretPosition.from : posFrom;
-                        posTo = posTo === undefined ? caretPosition.to : posTo;
-
-                        currentHtml = currentHtml.substr(0, posFrom)
-                            + cleanedElementHtml
-                            + currentHtml.substr(posTo);
-
-                        // Change caret position
-                        caretPosition.from += cleanedElementHtml.length;
-                        caretPosition.fromChar += elementText.length;
-                        newPos = posFrom + cleanedElementHtml.length;
-                    } else {
-                        // If the caret position is not set, then the user must be at the
-                        // end of the line. Insert element there.
-                        newPos = currentHtml.length;
-                        currentHtml += cleanedElementHtml;
-                        caretPosition = {
-                            from: currentHtml.length,
-                        };
+                    const emoji = tokens[0];
+                    if (!isEmojiInfo(emoji)) {
+                        throw new Error(`Emoji parsing failed: Returned text, not emoji info`);
                     }
-                    caretPosition.to = caretPosition.from;
-                    caretPosition.toChar = caretPosition.fromChar;
-
-                    contentElement.innerHTML = currentHtml;
-                    setCaretPosition(newPos);
-
-                    // Update the draft text
-                    const text = extractText(composeDiv[0], logAdapter($log.warn, logTag));
-                    scope.onTyping(text);
+                    insertEmoji(emoji);
+                }
 
-                    updateView();
+                // Insert a single emoji
+                function insertEmoji(emoji: threema.EmojiInfo): void {
+                    composeArea.insert_image(emoji.imgPath, emoji.emojiString, 'em');
                 }
 
+                // TODO
+                // function insertMention(mentionString, posFrom?: number, posTo?: number): void {
+                //     const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
+                //     insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
+                // }
+
                 // File trigger is clicked
                 function onFileTrigger(ev: UIEvent): void {
                     ev.preventDefault();
@@ -716,120 +618,14 @@ export default [
                     }
                 }
 
-                // return the outer html of a node element
-                function getOuterHtml(node: Node): string {
-                    const pseudoElement = document.createElement('pseudo');
-                    pseudoElement.appendChild(node.cloneNode(true));
-                    return pseudoElement.innerHTML;
-                }
-
-                // return the html code position of the container element
-                function getPositions(offset: number, container: Node): { html: number, text: number } {
-                    let pos = null;
-                    let textPos = null;
-
-                    if (composeDiv[0].contains(container)) {
-                        let selectedElement;
-                        if (container === composeDiv[0]) {
-                            if (offset === 0) {
-                                return {
-                                    html: 0, text: 0,
-                                };
-                            }
-                            selectedElement = composeDiv[0].childNodes[offset - 1];
-                            pos = 0;
-                            textPos = 0;
-                        } else {
-                            selectedElement = container.previousSibling;
-                            pos = offset;
-                            textPos = offset;
-                        }
-
-                        while (selectedElement !== null) {
-                            if (selectedElement.nodeType === Node.TEXT_NODE) {
-                                pos += selectedElement.textContent.length;
-                                textPos += selectedElement.textContent.length;
-                            } else {
-                                pos += getOuterHtml(selectedElement).length;
-                                textPos += 1;
-                            }
-                            selectedElement = selectedElement.previousSibling;
-                        }
-                    }
-                    return {
-                        html: pos,
-                        text: textPos,
-                    };
-                }
-
-                // Update the current caret position or selection
-                function updateCaretPosition() {
-                    caretPosition = null;
-                    if (window.getSelection && composeDiv[0].innerHTML.length > 0) {
-                        const selection = window.getSelection();
-                        if (selection.rangeCount) {
-                            const range = selection.getRangeAt(0);
-                            const from = getPositions(range.startOffset, range.startContainer);
-                            if (from !== null && from.html >= 0) {
-                                const to = getPositions(range.endOffset, range.endContainer);
-                                caretPosition = {
-                                    from: from.html,
-                                    to: to.html,
-                                    fromChar: from.text,
-                                    toChar: to.text,
-                                };
-                            }
-                        }
-                    }
-                }
-
-                // Set the correct cart position in the content editable div.
-                // Pos is the position in the html content (not in the visible plain text).
-                function setCaretPosition(pos: number) {
-                    const rangeAt = (node: Node, offset?: number) => {
-                        const range = document.createRange();
-                        range.collapse(false);
-                        if (offset !== undefined) {
-                            range.setStart(node, offset);
-                        } else {
-                            range.setStartAfter(node);
-                        }
-                        const sel = window.getSelection();
-                        sel.removeAllRanges();
-                        sel.addRange(range);
-                    };
-
-                    for (let i = 0; i < composeDiv[0].childNodes.length; i++) {
-                        const node = composeDiv[0].childNodes[i];
-                        let size;
-                        let offset;
-                        switch (node.nodeType) {
-                            case Node.TEXT_NODE:
-                                size = node.textContent.length;
-                                offset = pos;
-                                break;
-                            case Node.ELEMENT_NODE:
-                                size = getOuterHtml(node).length;
-                                break;
-                            default:
-                                $log.warn(logTag, 'Unhandled node:', node);
-                        }
-
-                        if (pos < size) {
-                            // use this node
-                            rangeAt(node, offset);
-                        } else if (i === composeDiv[0].childNodes.length - 1) {
-                            rangeAt(node);
-                        }
-                        pos -= size;
-                    }
-                }
-
                 // Handle typing events
                 composeDiv.on('keydown', onKeyDown);
                 composeDiv.on('keyup', onKeyUp);
-                composeDiv.on('keyup mouseup', updateCaretPosition);
-                composeDiv.on('selectionchange', updateCaretPosition);
+
+                // Handle selection change
+                document.addEventListener('selectionchange', () => {
+                    composeArea.store_selection_range();
+                });
 
                 // Handle paste event
                 composeDiv.on('paste', onPaste);
@@ -869,14 +665,6 @@ export default [
                     composeDiv[0].focus();
                 }));
 
-                unsubscribeListeners.push($rootScope.$on('onMentionSelected', (event: ng.IAngularEvent, args: any) => {
-                    if (args.query && args.mention) {
-                        // Insert resulting HTML
-                        insertMention(args.mention, caretPosition ? caretPosition.to - args.query.length : null,
-                            caretPosition ? caretPosition.to : null);
-                    }
-                }));
-
                 // When switching chat, send stopTyping message
                 scope.$on('$destroy', () => {
                     unsubscribeListeners.forEach((u) => {
@@ -895,6 +683,7 @@ export default [
                     <div>
                         <div
                             class="compose"
+                            id="composeDiv"
                             contenteditable
                             autofocus
                             translate

+ 0 - 55
src/helpers.ts

@@ -396,61 +396,6 @@ export function copyDeep<T extends object>(obj: T): T {
     return JSON.parse(JSON.stringify(obj));
 }
 
-/**
- * Process a DOM node recursively and extract text from compose area.
- */
-export function extractText(targetNode: HTMLElement, logWarning: (msg: string) => void, trim = true) {
-    let text = '';
-    const visitChildNodes = (parentNode: HTMLElement) => {
-        // When pressing shift-enter and typing more text:
-        //
-        // - Firefox and chrome insert a <br> between two text nodes
-        // - Safari creates two <div>s without any line break in between
-        //   (except for the first line, which stays plain text)
-        //
-        // Thus, for Safari, we need to detect <div>s and insert a newline.
-
-        let lastNodeType;
-        // tslint:disable-next-line: prefer-for-of (see #98)
-        for (let i = 0; i < parentNode.childNodes.length; i++) {
-            const node = parentNode.childNodes[i] as HTMLElement;
-            switch (node.nodeType) {
-                case Node.TEXT_NODE:
-                    lastNodeType = 'text';
-                    // Append text, but strip leading and trailing newlines
-                    text += node.nodeValue.replace(/(^[\r\n]*|[\r\n]*$)/g, '');
-                    break;
-                case Node.ELEMENT_NODE:
-                    const tag = node.tagName.toLowerCase();
-                    const _lastNodeType = lastNodeType;
-                    lastNodeType = tag;
-                    if (tag === 'div') {
-                        text += '\n';
-                        visitChildNodes(node);
-                        break;
-                    } else if (tag === 'img') {
-                        if (_lastNodeType === 'div') {
-                            // An image following a div should go on a new line
-                            text += '\n';
-                        }
-                        text += (node as HTMLImageElement).alt;
-                        break;
-                    } else if (tag === 'br') {
-                        text += '\n';
-                        break;
-                    } else if (tag === 'span' && node.hasAttribute('text')) {
-                        text += node.getAttributeNode('text').value;
-                        break;
-                    }
-                default:
-                    logWarning(`Unhandled node: ${node}`);
-            }
-        }
-    };
-    visitChildNodes(targetNode);
-    return trim ? text.trim() : text;
-}
-
 /**
  * Replace spaces with `&nbsp;` and tabs with `&nbsp;&nbsp;`.
  */

+ 43 - 0
src/helpers/emoji.ts

@@ -15,6 +15,7 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
+import * as emojiRegex from 'emoji-regex/es2015/index';
 import twemoji from 'twemoji';
 
 // This file contains helper functions related to emoji.
@@ -3770,6 +3771,48 @@ export function emojify(text: string): string {
     return text;
 }
 
+/**
+ * Process emoji unicode characters.
+ * TODO: Rename once emojify fn is removed.
+ */
+export function emojifyNew(text: string): Array<threema.EmojiInfo | string> {
+    const regex: RegExp = emojiRegex();
+    const result = [];
+
+    let match: string[];
+    let startIndex: number = 0;
+    let endIndex: number = 0;
+
+    // tslint:disable-next-line:no-conditional-assignment
+    while (match = regex.exec(text)) {
+        // Detect emoji
+        const emoji: string = match[0];
+        const prevEndIndex = endIndex;
+        startIndex = regex.lastIndex - emoji.length;
+        endIndex = regex.lastIndex;
+
+        // Push text preceding emoji
+        if (prevEndIndex < startIndex) {
+            result.push(text.substring(prevEndIndex, startIndex));
+        }
+
+        // Push emoji
+        const codepoint = twemoji.convert.toCodePoint(emoji);
+        const strippedCodepoint = codepoint.replace(/-fe0[ef]$/, '');
+        result.push({
+            emojiString: emoji,
+            imgPath: `emoji/png32/${strippedCodepoint}.png`,
+            codepoint: codepoint,
+        });
+    }
+
+    if (endIndex < text.length) {
+        result.push(text.substring(endIndex, text.length));
+    }
+
+    return result;
+}
+
 /**
  * Translate a shortname to UTF8.
  */

+ 16 - 7
src/threema.d.ts

@@ -27,9 +27,9 @@ declare namespace threema {
     }
 
     interface WireMessageAcknowledgement {
-        id: string,
-        success: boolean,
-        error?: string,
+        id: string;
+        success: boolean;
+        error?: string;
     }
 
     /**
@@ -748,10 +748,10 @@ declare namespace threema {
     }
 
     interface WebClientServiceStopArguments {
-        reason: DisconnectReason,
-        send: boolean,
-        close: boolean | string,
-        connectionBuildupState?: ConnectionBuildupState,
+        reason: DisconnectReason;
+        send: boolean;
+        close: boolean | string;
+        connectionBuildupState?: ConnectionBuildupState;
     }
 
     const enum ChosenTask {
@@ -768,6 +768,15 @@ declare namespace threema {
         SessionError = 'error',
     }
 
+    interface EmojiInfo {
+        // The plain emoji string
+        emojiString: string;
+        // The image path, e.g. emoji/png32/1f9df-200d-2640-fe0f.png
+        imgPath: string;
+        // The codepoint string, e.g. 1f9df-200d-2640-fe0f
+        codepoint: string;
+    }
+
     namespace Container {
         interface ReceiverData {
             contacts: ContactReceiver[];

+ 10 - 0
src/typeguards.ts

@@ -91,3 +91,13 @@ export function isTextNode(node: Node): node is Text {
 export function isElementNode(node: Node): node is HTMLElement {
     return node.nodeType === node.ELEMENT_NODE;
 }
+
+/**
+ * Emoji info type guard.
+ */
+export function isEmojiInfo(val: string | threema.EmojiInfo): val is threema.EmojiInfo {
+    return typeof val === 'object'
+        && val.emojiString !== undefined
+        && val.imgPath !== undefined
+        && val.codepoint !== undefined;
+}

+ 11 - 4
tests/testsuite.html

@@ -7,17 +7,24 @@
 
         <link rel="stylesheet" href="../node_modules/jasmine-core/lib/jasmine-core/jasmine.css">
 
+        <!-- Jasmine -->
         <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine.js"></script>
         <script src="../node_modules/jasmine-core/lib/jasmine-core/jasmine-html.js"></script>
         <script src="../node_modules/jasmine-core/lib/jasmine-core/boot.js"></script>
 
+        <!-- Angular core -->
         <script src="../node_modules/angular/angular.js"></script>
-        <script src="../node_modules/angular-mocks/angular-mocks.js"></script>
-        <script src="../node_modules/angular-translate/dist/angular-translate.min.js"></script>
-        <script src="../node_modules/angular-material/angular-material.min.js"></script>
-        <script src="../node_modules/angular-animate/angular-animate.min.js"></script>
         <script src="../node_modules/angular-aria/angular-aria.min.js"></script>
+        <script src="../node_modules/angular-animate/angular-animate.min.js"></script>
+        <script src="../node_modules/angular-sanitize/angular-sanitize.min.js"></script>
+        <script src="../node_modules/angular-route/angular-route.min.js"></script>
+        <script src="../node_modules/angular-material/angular-material.min.js"></script>
+        <script src="../node_modules/angular-translate/dist/angular-translate.min.js"></script>
+
+        <!-- Angular mocking -->
+        <script src="../node_modules/angular-mocks/angular-mocks.js"></script>
 
+        <!-- SaltyRTC -->
         <script src="../node_modules/@saltyrtc/chunked-dc/dist/chunked-dc.es5.js"></script>
 
         <!-- App bundles -->

+ 70 - 1
tests/ts/emoji_helpers.ts

@@ -17,7 +17,30 @@
  * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  */
 
-import {emojify, enlargeSingleEmoji, shortnameToUnicode} from '../../src/helpers/emoji';
+import twemoji from 'twemoji';
+import {emojify, emojifyNew, enlargeSingleEmoji, shortnameToUnicode} from '../../src/helpers/emoji';
+
+
+const textVariantSelector = '\ufe0e';
+const emojiVariantSelector = '\ufe0f';
+
+const beer = '\ud83c\udf7b';
+const bird = '\ud83d\udc26';
+
+function makeEmoji(emojiString: string, codepoint?: string, imgCodepoint?: string): threema.EmojiInfo {
+    if (codepoint === undefined) {
+        codepoint = twemoji.convert.toCodePoint(emojiString);
+    }
+    const imgPath = imgCodepoint === undefined
+        ? `emoji/png32/${codepoint}.png`
+        : `emoji/png32/${imgCodepoint}.png`;
+    return {
+        emojiString: emojiString,
+        imgPath: imgPath,
+        codepoint: codepoint,
+    }
+}
+
 
 describe('Emoji Helpers', () => {
     describe('emojify', () => {
@@ -34,6 +57,52 @@ describe('Emoji Helpers', () => {
         });
     });
 
+    describe('emojifyNew', () => {
+        it('returns text unmodified', function() {
+            expect(emojifyNew('hello world')).toEqual(['hello world']);
+        });
+
+        it('emojifies single emoji', function() {
+            expect(emojifyNew(bird))
+                .toEqual([makeEmoji(bird)]);
+        });
+
+        it('emojifies multiple emoji', function() {
+            expect(emojifyNew(`${beer}${bird}`))
+                .toEqual([makeEmoji(beer), makeEmoji(bird)]);
+        });
+
+        it('emojifies mixed content', function() {
+            expect(emojifyNew(`hi ${bird}`))
+                .toEqual(['hi ', makeEmoji(bird)]);
+            expect(emojifyNew(`${bird} bird`))
+                .toEqual([makeEmoji(bird), ' bird']);
+            expect(emojifyNew(`hi ${bird} bird`))
+                .toEqual(['hi ', makeEmoji(bird), ' bird']);
+            expect(emojifyNew(`hi ${bird}${beer}`))
+                .toEqual(['hi ', makeEmoji(bird), makeEmoji(beer)]);
+        });
+
+        it('ignores certain codepoints', function() {
+            expect(emojifyNew('©')).toEqual(['©']);
+            expect(emojifyNew('®')).toEqual(['®']);
+            expect(emojifyNew('™')).toEqual(['™']);
+        });
+
+        it('properly handles variant selectors', function() {
+            // https://www.unicode.org/emoji/charts/emoji-variants.html
+            const copy = '©';
+            const codepoint = 'a9-fe0f'; // Should include variant selector
+            const imgCodepoint = 'a9'; // Should not include variant selector
+            expect(emojifyNew(copy))
+                .toEqual([copy]);
+            expect(emojifyNew(copy + textVariantSelector))
+                .toEqual([copy + textVariantSelector]);
+            expect(emojifyNew(copy + emojiVariantSelector))
+                .toEqual([makeEmoji(copy + emojiVariantSelector, codepoint, imgCodepoint)]);
+        });
+    });
+
     describe('shortnameToUnicode', () => {
         it('converts valid shortnames', function() {
             expect(shortnameToUnicode('+1')).toEqual('\ud83d\udc4d');

+ 13 - 3
tests/ui/compose_area.html

@@ -23,12 +23,22 @@
     <link rel="stylesheet" href="../../public/css/app.css?v=[[VERSION]]">
 
     <!-- Scripts -->
-    <script src="../../dist/uitest.bundle.js"></script>
-    <script>window.uiTests.initComposeArea();</script>
+    <script src="../../dist/generated/uitest.bundle.js"></script>
+    <script>
+        function init() {
+            if (window.uiTests === undefined) {
+                window.setTimeout(init, 100);
+            } else {
+                window.uiTests.initComposeArea();
+            }
+        }
+        init();
+    </script>
 </head>
-<body ng-app="uitest">
+<body>
     <div ng-controller="ComposeAreaController as ctrl">
         <compose-area
+            on-init="ctrl.onInit"
             submit="ctrl.submit"
             initial-data="ctrl.initialData"
             start-typing="ctrl.startTyping"

+ 7 - 0
tests/ui/compose_area.ts

@@ -58,6 +58,8 @@ export function init() {
         }]);
     }]);
 
+    // Bootstrap application
+    angular.bootstrap(document, ['uitest']);
 }
 
 class ComposeAreaController {
@@ -72,6 +74,11 @@ class ComposeAreaController {
         };
     }
 
+    public onInit(composeArea) {
+        // tslint:disable-next-line:no-string-literal
+        window['composeArea'] = composeArea;
+    }
+
     public startTyping() {
         // ignore
     }

+ 1 - 7
tests/ui/run.ts

@@ -14,8 +14,6 @@ import { expect } from 'chai';
 import { Builder, By, Key, until, WebDriver, WebElement } from 'selenium-webdriver';
 import * as TermColor from 'term-color';
 
-import { extractText as extractTextFunc } from '../../src/helpers';
-
 // Script arguments
 const browser = process.argv[2];
 const filterQuery = process.argv[3];
@@ -32,11 +30,7 @@ const emojiTrigger = By.css('.emoji-trigger');
  * Helper function to extract text.
  */
 async function extractText(driver: WebDriver): Promise<string> {
-    const script = `
-        ${extractTextFunc.toString()}
-        const element = document.querySelector("div.compose");
-        return extractText(element);
-    `;
+    const script = `return window.composeArea.getText();`;
     return driver.executeScript<string>(script);
 }