浏览代码

Mention selector in groups, add "@" to mention box (#410)

* Mention selector in groups, add "@" to mention box
* Support mention selection by arrow keys and return key
* Show contact detail on click a mention
* Rename threemaAction directive to clickAction
* Escape html in mention names
Silly 7 年之前
父节点
当前提交
686d59050d

+ 8 - 0
src/controller_model/contact.ts

@@ -162,4 +162,12 @@ export class ContactControllerModel implements threema.ControllerModel {
 
         }
     }
+
+    public onChangeMembers(identities: string[]): void {
+        return null;
+    }
+
+    public getMembers(): string[] {
+        return [this.identity];
+    }
 }

+ 4 - 0
src/controller_model/distributionList.ts

@@ -185,4 +185,8 @@ export class DistributionListControllerModel implements threema.ControllerModel
     public onChangeMembers(identities: string[]): void {
         this.members = identities;
     }
+
+    public getMembers(): string[] {
+        return this.members;
+    }
 }

+ 4 - 0
src/controller_model/group.ts

@@ -254,4 +254,8 @@ export class GroupControllerModel implements threema.ControllerModel {
     public onChangeMembers(identities: string[]): void {
         this.members = identities;
     }
+
+    public getMembers(): string[] {
+        return this.members;
+    }
 }

+ 2 - 2
src/directives.ts

@@ -22,6 +22,7 @@ import avatar from './directives/avatar';
 import avatarArea from './directives/avatar_area';
 import avatarEditor from './directives/avatar_editor';
 import batteryStatus from './directives/battery';
+import clickAction from './directives/click_action';
 import composeArea from './directives/compose_area';
 import contactBadge from './directives/contact_badge';
 import distributionListBadge from './directives/distribution_list_badge';
@@ -45,13 +46,13 @@ import messageVoipStatus from './directives/message_voip_status';
 import myIdentity from './directives/my_identity';
 import searchbox from './directives/searchbox';
 import statusBar from './directives/status_bar';
-import threemaAction from './directives/threema_action';
 import verificationLevel from './directives/verification_level';
 
 angular.module('3ema.directives').directive('autofocus', autofocus);
 angular.module('3ema.directives').directive('avatarArea', avatarArea);
 angular.module('3ema.directives').directive('avatarEditor', avatarEditor);
 angular.module('3ema.directives').directive('batteryStatus', batteryStatus);
+angular.module('3ema.directives').directive('clickAction', clickAction);
 angular.module('3ema.directives').directive('composeArea', composeArea);
 angular.module('3ema.directives').directive('eeeAvatar', avatar);
 angular.module('3ema.directives').directive('eeeContactBadge', contactBadge);
@@ -76,5 +77,4 @@ angular.module('3ema.directives').directive('memberListEditor', memberListEditor
 angular.module('3ema.directives').directive('mediabox', mediabox);
 angular.module('3ema.directives').directive('searchbox', searchbox);
 angular.module('3ema.directives').directive('statusBar', statusBar);
-angular.module('3ema.directives').directive('threemaAction', threemaAction);
 angular.module('3ema.directives').directive('dragFile', dragFile);

+ 136 - 0
src/directives/click_action.ts

@@ -0,0 +1,136 @@
+/**
+ * This file is part of Threema Web.
+ *
+ * Threema Web is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
+ */
+import {UriService} from '../services/uri';
+import {WebClientService} from '../services/webclient';
+
+export default [
+    '$timeout',
+    '$state',
+    'UriService',
+    'WebClientService',
+    function($timeout, $state: ng.ui.IStateService, uriService: UriService, webClientService: WebClientService) {
+
+        const validateThreemaId = (id: string): boolean => {
+            return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);
+        };
+        const viewReceiver = (receiver: threema.Receiver) => {
+            return function(e: Event) {
+                if (!receiver) {
+                    return false;
+                }
+                $state.go('messenger.home.detail', receiver);
+            };
+        };
+        const addAction = (params) => {
+            return function(e: Event) {
+                if (!validateThreemaId(params.id)) {
+                    return false;
+                }
+
+                // Verify the receiver already exists
+                const contactReceiver = webClientService.contacts.get(params.id);
+                if (contactReceiver) {
+                    return viewReceiver(contactReceiver)(e);
+                }
+
+                $state.go('messenger.home.create', {
+                    type: 'contact',
+                    initParams: {
+                        identity: params.id,
+                    },
+                });
+            };
+        };
+
+        const composeAction = (params) => {
+            return function(e: Event) {
+                if (!validateThreemaId(params.id)) {
+                    return false;
+                }
+                const text = params.text || '';
+                $state.go('messenger.home.conversation', {
+                    type: 'contact',
+                    id: params.id,
+                    initParams: {
+                        text: text,
+                    },
+                });
+            };
+        };
+
+        const getThreemaActionHandler = (name: string) => {
+            switch (name.toLowerCase()) {
+                case 'add':
+                    return addAction;
+                case 'compose':
+                    return composeAction;
+                default:
+                    return null;
+            }
+        };
+
+        return {
+            restrict: 'A',
+            scope: {},
+            link(scope, el, attrs) {
+                $timeout(() => {
+                    // tslint:disable-next-line: prefer-for-of (see #98)
+                    for (let i = 0; i < el[0].childNodes.length; i++) {
+                        const node: HTMLElement = el[0].childNodes[i];
+
+                        if (node.nodeType === Node.ELEMENT_NODE) {
+                            switch ( node.tagName.toLowerCase()) {
+                                case 'a':
+                                    const link = (node as HTMLElement).innerText;
+                                    if (link !== undefined && link.toLowerCase().startsWith('threema://')) {
+                                        const matches = (/\bthreema:\/\/([a-z]+)\?([^\s]+)\b/gi).exec(link);
+                                        if (matches !== null) {
+                                            const handler = getThreemaActionHandler(matches[1]);
+                                            const params = uriService.parseQueryParams(matches[2]);
+                                            if (handler !== null && params !== null) {
+                                                node.addEventListener('click', handler(params));
+                                            }
+                                        }
+                                    }
+                                    break;
+                                case 'span':
+                                    // Support only id mentions (not all or me)
+                                    const mentionCssClass = 'mention id';
+                                    const cls = node.getAttribute('class');
+                                    // Solved with the css classes, because angularJS removes
+                                    // all other attributes from the DOMElement
+                                    if (cls.substr(0, mentionCssClass.length) === mentionCssClass) {
+                                        // Hack to extract the identity from class name
+                                        const identity = cls.substring(mentionCssClass.length).trim();
+                                        if (validateThreemaId(identity)) {
+                                            const contactReceiver = webClientService.contacts.get(identity);
+                                            node.addEventListener('click', viewReceiver(contactReceiver));
+                                            node.setAttribute('class', cls + ' link');
+                                            node.setAttribute('title', contactReceiver.displayName);
+                                        }
+                                    }
+
+                                default:
+                                    // ignore
+                            }
+                        }
+                    }
+                }, 0);
+            },
+        };
+    },
+];

+ 41 - 9
src/directives/compose_area.ts

@@ -49,6 +49,7 @@ export default [
                 startTyping: '=',
                 stopTyping: '=',
                 onTyping: '=',
+                onKeyDown: '=',
 
                 // Reference to initial text and draft
                 initialData: '=',
@@ -171,6 +172,9 @@ export default [
                                     } else if (tag === 'br') {
                                         text += '\n';
                                         break;
+                                    } else if (tag === 'span' && node.hasAttribute('text')) {
+                                        text += node.getAttributeNode('text').value;
+                                        break;
                                     }
                                 default:
                                     $log.warn(logTag, 'Unhandled node:', node);
@@ -256,15 +260,21 @@ export default [
                 // Handle typing events
                 function onKeyDown(ev: KeyboardEvent): void {
                     // If enter is pressed, prevent default event from being dispatched
-                    if (!ev.shiftKey && ev.which === 13) {
+                    if (!ev.shiftKey && ev.key === 'Enter') {
                         ev.preventDefault();
                     }
 
+                    // If the keydown is handled and aborted outside
+                    if (scope.onKeyDown && scope.onKeyDown(ev) !== true) {
+                        ev.preventDefault();
+                        return;
+                    }
+
                     // At link time, the element is not yet evaluated.
                     // Therefore add following code to end of event loop.
                     $timeout(() => {
                         // Shift + enter to insert a newline. Enter to send.
-                        if (!ev.shiftKey && ev.which === 13) {
+                        if (!ev.shiftKey && ev.key === 'Enter') {
                             if (sendText()) {
                                 return;
                             }
@@ -301,10 +311,11 @@ export default [
                         // Update typing information (use text instead method)
                         if (text.trim().length === 0) {
                             stopTyping();
+                            scope.onTyping('');
                         } else {
                             startTyping();
+                            scope.onTyping(text.trim(), stringService.getWord(text, caretPosition.from));
                         }
-                        scope.onTyping(text.trim());
 
                         updateView();
                     }, 0);
@@ -438,13 +449,14 @@ export default [
                         // Look up some filter functions
                         const escapeHtml = $filter('escapeHtml') as (a: string) => string;
                         const emojify = $filter('emojify') as (a: string, b?: boolean) => 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 = nlToBr(emojify(escaped, true), true);
+                        const formatted = nlToBr(mentionify(emojify(escaped, true)), true);
 
                         // Insert resulting HTML
                         document.execCommand('insertHTML', false, formatted);
@@ -513,7 +525,16 @@ export default [
                 }
 
                 function insertEmoji(emoji, posFrom = null, posTo = null): void {
-                    const formatted = ($filter('emojify') as any)(emoji, true, true);
+                    const emojiElement = ($filter('emojify') as any)(emoji, true, true) as string;
+                    insertHTMLElement(emoji, emojiElement, posFrom, posTo);
+                }
+
+                function insertMention(mentionString, posFrom = null, posTo = null): void {
+                    const mentionElement = ($filter('mentionify') as any)(mentionString) as string;
+                    insertHTMLElement(mentionString, mentionElement, posFrom, posTo);
+                }
+
+                function insertHTMLElement(original: string, formatted: string, posFrom = null, posTo = null): void {
 
                     // In Chrome in right-to-left mode, our content editable
                     // area may contain a DIV element.
@@ -536,7 +557,7 @@ export default [
                             currentHTML += node.textContent;
                         } else if (node.nodeType === node.ELEMENT_NODE) {
                             let tag = node.tagName.toLowerCase();
-                            if (tag === 'img') {
+                            if (tag === 'img' || tag === 'span') {
                                 currentHTML += getOuterHtml(node);
                             } else if (tag === 'br') {
                                 // Firefox inserts a <br> after editing content editable fields.
@@ -556,8 +577,9 @@ export default [
                             + currentHTML.substr(posTo);
 
                         // change caret position
-                        caretPosition.from += formatted.length - 1;
-                        caretPosition.fromBytes++;
+                        caretPosition.from += formatted.length;
+                        caretPosition.fromBytes += original.length;
+                        posFrom += formatted.length;
                     } else {
                         // insert at the end of line
                         posFrom = currentHTML.length;
@@ -604,6 +626,9 @@ export default [
                     for (let img of composeDiv[0].getElementsByTagName('img')) {
                         img.ondragstart = () => false;
                     }
+                    for (let span of composeDiv[0].getElementsByTagName('span')) {
+                        span.setAttribute('contenteditable', false);
+                    }
 
                     if (browserService.getBrowser().firefox) {
                         // disable object resizing is the only way to disable resizing of
@@ -625,7 +650,7 @@ export default [
                 // return the outer html of a node element
                 function getOuterHtml(node: Node): string {
                     let pseudoElement = document.createElement('pseudo');
-                    pseudoElement.appendChild(node.cloneNode());
+                    pseudoElement.appendChild(node.cloneNode(true));
                     return pseudoElement.innerHTML;
                 }
 
@@ -761,6 +786,13 @@ export default [
                 $rootScope.$on('onQuoted', (event: ng.IAngularEvent, args: any) => {
                     composeDiv[0].focus();
                 });
+                $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);
+                    }
+                });
             },
             // tslint:disable:max-line-length
             template: `

+ 3 - 1
src/directives/message_text.ts

@@ -57,7 +57,9 @@ export default [
                 }
             }],
             template: `
-                <span threema-action ng-bind-html="ctrl.text | escapeHtml | markify | emojify | mentionify | linkify | nlToBr: ctrl.multiLine"></span>
+                <span click-action
+                    ng-bind-html="ctrl.text | escapeHtml | markify | emojify | mentionify | linkify | nlToBr: ctrl.multiLine">
+                </span>
             `,
         };
     },

+ 0 - 99
src/directives/threema_action.ts

@@ -1,99 +0,0 @@
-/**
- * This file is part of Threema Web.
- *
- * Threema Web is free software: you can redistribute it and/or modify it
- * under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or (at
- * your option) any later version.
- *
- * This program is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
- * General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
- */
-import {UriService} from '../services/uri';
-
-export default [
-    '$timeout',
-    '$state',
-    'UriService',
-    function($timeout, $state: ng.ui.IStateService, uriService: UriService) {
-
-        const validateThreemaId = (id: string): boolean => {
-            return id !== undefined && id !== null && /^[0-9A-Z]{8}/.test(id);
-        };
-
-        const addAction = (params) => {
-            return function(e: Event) {
-                if (!validateThreemaId(params.id)) {
-                    return false;
-                }
-                $state.go('messenger.home.create', {
-                    type: 'contact',
-                    initParams: {
-                        identity: params.id,
-                    },
-                });
-            };
-        };
-
-        const composeAction = (params) => {
-            return function(e: Event) {
-                if (!validateThreemaId(params.id)) {
-                    return false;
-                }
-                const text = params.text || '';
-                $state.go('messenger.home.conversation', {
-                    type: 'contact',
-                    id: params.id,
-                    initParams: {
-                        text: text,
-                    },
-                });
-            };
-        };
-
-        const getThreemaActionHandler = (name: string) => {
-            switch (name.toLowerCase()) {
-                case 'add':
-                    return addAction;
-                case 'compose':
-                    return composeAction;
-                default:
-                    return null;
-            }
-        };
-
-        return {
-            restrict: 'A',
-            scope: {},
-            link(scope, el, attrs) {
-                $timeout(() => {
-                    // tslint:disable-next-line: prefer-for-of (see #98)
-                    for (let i = 0; i < el[0].childNodes.length; i++) {
-                        const node: HTMLElement = el[0].childNodes[i];
-
-                        if (node.nodeType === Node.ELEMENT_NODE
-                            && node.tagName.toLowerCase() === 'a') {
-
-                            const link = (node as HTMLElement).innerText;
-                            if (link !== undefined && link.toLowerCase().startsWith('threema://')) {
-                                const matches = (/\bthreema:\/\/([a-z]+)\?([^\s]+)\b/gi).exec(link);
-                                if (matches !== null) {
-                                    const handler = getThreemaActionHandler(matches[1]);
-                                    const params = uriService.parseQueryParams(matches[2]);
-                                    if (handler !== null && params !== null) {
-                                        node.addEventListener('click', handler(params));
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }, 0);
-            },
-        };
-    },
-];

+ 15 - 8
src/filters.ts

@@ -129,8 +129,8 @@ angular.module('3ema.filters', [])
 /**
  * Convert mention elements to html elements
  */
-.filter('mentionify', ['WebClientService', '$translate',
-    function (webClientService: WebClientService, $translate) {
+.filter('mentionify', ['WebClientService', '$translate', 'escapeHtmlFilter',
+    function (webClientService: WebClientService, $translate: ng.translate.ITranslateService, escapeHtmlFilter) {
         return(text) => {
             if (text !== null && text.length > 10) {
                 let result = text.match(/@\[([\*\@a-zA-Z0-9][\@a-zA-Z0-9]{7})\]/g);
@@ -139,21 +139,28 @@ angular.module('3ema.filters', [])
                     // Unique
                     for (let possibleMention of result) {
                         let identity = possibleMention.substr(2, 8);
-                        let html;
-
+                        let mentionName;
+                        let cssClass;
                         if (identity === '@@@@@@@@') {
-                            html = '<span class="mention all">' + $translate.instant('messenger.ALL') + '</span>';
+                            mentionName = $translate.instant('messenger.ALL');
+                            cssClass = 'all';
+                        } else if (identity === webClientService.me.id) {
+                            mentionName = webClientService.me.displayName;
+                            cssClass = 'me';
                         } else {
                             const contact = webClientService.contacts.get(possibleMention.substr(2, 8));
                             if (contact !== null) {
-                                html = '<span class="mention contact">' + contact.displayName + '</span>';
+                                // Add identity to class for a simpler parsing
+                                cssClass = 'id ' + identity;
+                                mentionName = contact.displayName;
                             }
                         }
 
-                        if (html !== undefined) {
+                        if (mentionName !== undefined) {
                             text = text.replace(
                                 new RegExp(escapeRegExp(possibleMention), 'g'),
-                                html,
+                                '<span class="mention ' + cssClass + '"'
+                                    + ' text="@[' + identity + ']">' + escapeHtmlFilter(mentionName) + '</span>',
                             );
                         }
                     }

+ 22 - 0
src/partials/messenger.conversation.html

@@ -77,11 +77,33 @@
                 <i class="material-icons md-dark md-24" translate translate-attr-title="common.CANCEL">clear</i>
             </md-button>
         </div>
+        <div id="mention-selector" ng-if="ctrl.showMentionSelector()">
+            <ul class="member-list">
+                <li ng-repeat="mention in ctrl.currentMentions"
+                    ng-click="ctrl.onMentionSelected(mention.identity)"
+                    ng-class="{selected: ctrl.selectedMention == $index}">
+                    <div class="contact-badge receiver-badge" ng-if="mention.isAll">
+                        <section class="avatar-box">
+                            <eee-avatar eee-type="'group'"
+                                        eee-receiver="ctrl.receiver"
+                                        eee-resolution="'low'"></eee-avatar>
+                        </section>
+                        <div translate>messenger.ALL</div>
+                    </div>
+
+                    <eee-contact-badge
+                            ng-if="!mention.isAll"
+                            eee-identity="mention.identity"
+                            eee-disable-click="true"/>
+                </li>
+            </ul>
+        </div>
         <div class="chat-input">
             <compose-area
                     submit="ctrl.submit"
                     initial-data="ctrl.initialData"
                     on-typing="ctrl.onTyping"
+                    on-key-down="ctrl.onComposeKeyDown"
                     start-typing="ctrl.startTyping"
                     stop-typing="ctrl.stopTyping"
                     on-uploading="ctrl.onUploading"

+ 109 - 1
src/partials/messenger.ts

@@ -178,6 +178,7 @@ class ConversationController {
     private $state: ng.ui.IStateService;
     private $log: ng.ILogService;
     private $scope: ng.IScope;
+    private $rootScope: ng.IRootScopeService;
     private $filter: ng.IFilterService;
 
     // Own services
@@ -206,6 +207,7 @@ class ConversationController {
     public msgReadReportPending = false;
     private hasMore = true;
     private latestRefMsgId: number = null;
+    private allText: string;
     private messages: threema.Message[];
     public initialData: threema.InitialConversationData = {
         draft: '',
@@ -216,6 +218,14 @@ class ConversationController {
     public maxTextLength: number;
     public isTyping = (): boolean => false;
 
+    public allMentions: threema.Mention[] = [];
+    public currentMentions: threema.Mention[] = [];
+    public currentMentionFilterWord = null;
+    public selectedMention: number = null;
+    public showMentionSelector = (): boolean => this.type === 'group'
+        && this.currentMentionFilterWord != null
+        && this.currentMentions.length > 0;
+
     private uploading = {
         enabled: false,
         value1: 0,
@@ -256,6 +266,7 @@ class ConversationController {
         this.$state = $state;
         this.$scope = $scope;
         this.$filter = $filter;
+        this.$rootScope = $rootScope;
 
         this.$mdDialog = $mdDialog;
         this.$mdToast = $mdToast;
@@ -265,6 +276,7 @@ class ConversationController {
         this.$mdDialog.cancel();
 
         this.maxTextLength = this.webClientService.getMaxTextLength();
+        this.allText = this.$translate.instant('messenger.ALL');
 
         // On every navigation event, close all dialogs.
         // Note: Deprecated. When migrating ui-router ($state),
@@ -360,6 +372,25 @@ class ConversationController {
                     },
                 );
 
+                // Enable mentions only in group chats
+                if (this.type === 'group') {
+                    this.allMentions.push({
+                        identity: null,
+                        query: this.$translate.instant('messenger.ALL').toLowerCase(),
+                        isAll: true,
+                    });
+                    this.controllerModel.getMembers().forEach((identity: string) => {
+                        const contactReceiver = this.webClientService.contacts.get(identity);
+                        if (contactReceiver) {
+                            this.allMentions.push({
+                                identity: identity,
+                                query: (contactReceiver.displayName + ' ' + identity).toLowerCase(),
+                                isAll: false,
+                            });
+                        }
+                    });
+                }
+
                 this.initialData = {
                     draft: webClientService.getDraft(this.receiver),
                     initialText: $stateParams.initParams ? $stateParams.initParams.text : '',
@@ -557,9 +588,86 @@ class ConversationController {
      * In contrast to startTyping, this method is is always called, not just if
      * the text field is non-empty.
      */
-    public onTyping = (text: string) => {
+    public onTyping = (text: string, currentWord: string = null) => {
         // Update draft
         this.webClientService.setDraft(this.receiver, text);
+        if (currentWord && currentWord.substr(0, 1) === '@') {
+            this.currentMentionFilterWord = currentWord.substr(1);
+            let query = this.currentMentionFilterWord.toLowerCase().trim();
+            const selectedMentionObject = this.getSelectedMention();
+            this.currentMentions = this.allMentions.filter((i) => {
+                if (query.length === 0) {
+                    return true;
+                }
+                return i.query.indexOf(query) >= 0;
+            });
+            // If only one mention is filtered, select them
+            if (this.currentMentions.length === 1) {
+                this.selectedMention = 0;
+            } else if (selectedMentionObject !== null) {
+                // Get the new position of the latest selected mention object
+                this.selectedMention = null;
+                this.selectedMention = this.currentMentions.findIndex((m) => {
+                    return m.identity === selectedMentionObject.identity;
+                });
+            }
+        } else {
+            this.currentMentionFilterWord = null;
+        }
+    }
+
+    public getSelectedMention = (): threema.Mention => {
+        if (this.selectedMention === null
+            || this.selectedMention < 0
+            || this.selectedMention > this.currentMentions.length - 1) {
+            return null;
+        }
+
+        return this.currentMentions[this.selectedMention];
+    }
+    /**
+     * Handle mention selector navigation
+     */
+    public onComposeKeyDown = (ev: KeyboardEvent): boolean => {
+        if (this.showMentionSelector() && !ev.shiftKey) {
+            let move = ev.key === 'ArrowDown' ? 1 : (ev.key === 'ArrowUp' ? - 1 : 0);
+            if (move !== 0) {
+                // Move cursors position in mention selector
+                if (this.selectedMention !== null) {
+                    this.selectedMention += move;
+                    // Fix positions
+                    if (this.selectedMention > this.currentMentions.length - 1) {
+                        this.selectedMention = 0;
+                    } else if (this.selectedMention < 0) {
+                        this.selectedMention = this.currentMentions.length - 1;
+                    }
+                } else {
+                    this.selectedMention = 0;
+                }
+                return false;
+            }
+
+            if (ev.key === 'Enter') {
+                // Enter, select current mention
+                const selectedMentionObject = this.getSelectedMention();
+                if (selectedMentionObject === null) {
+                    // If no (or a invalid) mention is selected, select the first mention
+                    this.selectedMention = 0;
+                } else {
+                    this.onMentionSelected(selectedMentionObject.identity);
+                }
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    public onMentionSelected(identity: string = null): void {
+        this.$rootScope.$broadcast('onMentionSelected', {
+            query: '@' + this.currentMentionFilterWord,
+            mention: '@[' + (identity === null ? '@@@@@@@@' : identity.toUpperCase()) + ']',
+        });
     }
 
     public onUploading = (inProgress: boolean, percentCurrent: number = null, percentFull: number = null)  => {

+ 26 - 5
src/sass/components/_mention.scss

@@ -1,10 +1,31 @@
 .mention {
-    background-color: #E0E0E0;
-    padding: 2px 5px;
-    position: relative;
+    padding: 0 5px 0 20px;
     -webkit-border-radius:5px;
     -moz-border-radius:5px;
     border-radius:5px;
-    box-shadow: 0 1px 1px rgba(0,0,0,0.1);
-    color: black;
+    line-height: 10pt !important;
+
+    &.me {
+        background-color: #8b8b8b;
+        color: white;
+    }
+
+    &.id,&.all {
+
+        box-shadow: 0 1px 1px rgba(0,0,0,0.1);
+        color: black;
+        background-color: #E0E0E0;
+    }
+    &:before {
+        content: '@';
+        font-size: 10pt;
+        position: relative;
+        top: -2px;
+        margin-left: -16px;
+        opacity: 0.6;
+    }
+
+    &.link {
+        @include mouse-hand;
+    }
 }

+ 1 - 1
src/sass/layout/_main.scss

@@ -259,7 +259,7 @@ input.threema-id {
     text-transform: uppercase;
 }
 
-a.threema-action {
+a.click-action {
     @include mouse-hand;
 }
 

+ 34 - 0
src/sass/sections/_conversation.scss

@@ -92,6 +92,40 @@
             margin: 0;
             padding: 0;
         }
+
+        #mention-selector {
+            padding: 0;
+            ul {
+                margin: 0;
+                padding: 0;
+                list-style-type: none;
+
+                > li {
+                    &:not(:hover):not(.selected) {
+                        background-color: white;
+                    }
+                    @include mouse-hand;
+                }
+            }
+
+            .contact-badge {
+                .avatar-box {
+                    margin: $main-padding;
+                    padding: 0;
+                    .avatar {
+                        width: 28px;
+                        height: 28px;
+                        img {
+                            width: inherit;
+                            height: inherit;
+                        }
+                    }
+                }
+                .contact-badge-identity {
+                    margin-right: $main-padding;
+                }
+            }
+        }
     }
 }
 

+ 4 - 0
src/services/string.ts

@@ -64,6 +64,10 @@ export class StringService {
             let charFound = false;
             let realPos = Math.min(pos, chars.length) - 1;
 
+            if (realPos < 0) {
+                return '';
+            }
+
             let wordChars = new Array(realPos);
             for (let n = realPos; n >= 0; n--) {
                 let realChar = chars[n].trim();

+ 8 - 0
src/threema.d.ts

@@ -430,6 +430,8 @@ declare namespace threema {
         canClean(): boolean;
         getMode(): number;
         setOnRemoved(callback: any): void;
+        onChangeMembers(identities: string[]): void;
+        getMembers(): string[];
     }
 
     interface Alert {
@@ -487,6 +489,12 @@ declare namespace threema {
         myAccount: MyAccount;
     }
 
+    interface Mention {
+        identity: string;
+        query: string;
+        isAll: boolean;
+    }
+
     namespace Container {
         interface ReceiverData {
             me: MeReceiver;

+ 30 - 7
tests/filters.js

@@ -5,6 +5,10 @@ describe('Filters', function() {
     // Ignoring page reload request
     beforeAll(() => window.onbeforeunload = () => null);
     let webClientServiceMock = {
+        me: {
+            id: 'MEMEMEME',
+            displayName: 'Er'
+        },
         contacts: {
             get: function(id) {
                 if (id === 'AAAAAAAA') {
@@ -22,6 +26,11 @@ describe('Filters', function() {
                         displayName: 'GWContactA'
                     }
                 }
+                else if (id === 'BAD0BAD1') {
+                    return {
+                        displayName: '<b>< script >foo&ndash;</b>< script>',
+                    }
+                }
                 return null;
             }
         }
@@ -178,23 +187,37 @@ describe('Filters', function() {
 
         it('mention - contact', () => {
             this.testPatterns([
-                ['@[AAAAAAAA]', '<span class="mention contact">ContactA</span>'],
-                ['hello @[AAAAAAAA]. @[AAAAAAAA] you are my friend', 'hello <span class="mention contact">ContactA</span>. <span class="mention contact">ContactA</span> you are my friend'],
-                ['@[AAAAAAAA] @[AAAAAAAA] @[AAAAAAAA]', '<span class="mention contact">ContactA</span> <span class="mention contact">ContactA</span> <span class="mention contact">ContactA</span>']
+                ['@[AAAAAAAA]', '<span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span>'],
+                ['hello @[AAAAAAAA]. @[AAAAAAAA] you are my friend', 'hello <span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span>. <span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span> you are my friend'],
+                ['@[AAAAAAAA] @[AAAAAAAA] @[AAAAAAAA]', '<span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span> <span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span> <span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span>']
             ]);
         });
 
         it('mention - all', () => {
             this.testPatterns([
-                ['@[@@@@@@@@]', '<span class="mention all">messenger.ALL</span>'],
-                ['@[@@@@@@@@] your base are belong to us', '<span class="mention all">messenger.ALL</span> your base are belong to us'],
-                ['@[@@@@@@@@] @[@@@@@@@@] @[@@@@@@@@]', '<span class="mention all">messenger.ALL</span> <span class="mention all">messenger.ALL</span> <span class="mention all">messenger.ALL</span>']
+                ['@[@@@@@@@@]', '<span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span>'],
+                ['@[@@@@@@@@] your base are belong to us', '<span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span> your base are belong to us'],
+                ['@[@@@@@@@@] @[@@@@@@@@] @[@@@@@@@@]', '<span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span> <span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span> <span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span>']
             ]);
         });
 
         it('mention - mixed', () => {
             this.testPatterns([
-                ['@[@@@@@@@@] @[AAAAAAAA] @[BBBBBBBB]', '<span class="mention all">messenger.ALL</span> <span class="mention contact">ContactA</span> @[BBBBBBBB]'],
+                ['@[@@@@@@@@] @[AAAAAAAA] @[BBBBBBBB]', '<span class="mention all" text="@[@@@@@@@@]">messenger.ALL</span> <span class="mention id AAAAAAAA" text="@[AAAAAAAA]">ContactA</span> @[BBBBBBBB]'],
+            ]);
+        });
+
+        it('mention - me contact', () => {
+            this.testPatterns([
+                ['@[MEMEMEME]', '<span class="mention me" text="@[MEMEMEME]">Er</span>'],
+                ['hello @[MEMEMEME]. @[MEMEMEME] you are my friend', 'hello <span class="mention me" text="@[MEMEMEME]">Er</span>. <span class="mention me" text="@[MEMEMEME]">Er</span> you are my friend'],
+                ['@[MEMEMEME] @[MEMEMEME] @[MEMEMEME]', '<span class="mention me" text="@[MEMEMEME]">Er</span> <span class="mention me" text="@[MEMEMEME]">Er</span> <span class="mention me" text="@[MEMEMEME]">Er</span>']
+            ]);
+        });
+
+        it('mention - escape html parameters', () => {
+            this.testPatterns([
+                ['@[BAD0BAD1]', '<span class="mention id BAD0BAD1" text="@[BAD0BAD1]">&lt;b&gt;&lt; script &gt;foo&amp;ndash;&lt;/b&gt;&lt; script&gt;</span>'],
             ]);
         });
     });