Bladeren bron

Add converse-autocomplete and use that in the chat textarea

JC Brand 7 jaren geleden
bovenliggende
commit
b6f4f05b9e

+ 2 - 5
.eslintrc.json

@@ -19,7 +19,7 @@
     "rules": {
     "rules": {
         "lodash/prefer-lodash-method": [2, {
         "lodash/prefer-lodash-method": [2, {
             "ignoreMethods": [
             "ignoreMethods": [
-                "find", "endsWith", "startsWith", "filter", "reduce",
+                "find", "endsWith", "startsWith", "filter", "reduce", "isArray", "create",
                 "map", "replace", "toLower", "split", "trim", "forEach", "toUpperCase", "includes"
                 "map", "replace", "toLower", "split", "trim", "forEach", "toUpperCase", "includes"
             ]
             ]
         }],
         }],
@@ -215,10 +215,7 @@
         "one-var": "off",
         "one-var": "off",
         "one-var-declaration-per-line": "off",
         "one-var-declaration-per-line": "off",
         "operator-assignment": "off",
         "operator-assignment": "off",
-        "operator-linebreak": [
-            "error",
-            "after"
-        ],
+        "operator-linebreak": "off",
         "padded-blocks": "off",
         "padded-blocks": "off",
         "prefer-arrow-callback": "off",
         "prefer-arrow-callback": "off",
         "prefer-const": "error",
         "prefer-const": "error",

+ 60 - 13
css/converse.css

@@ -7748,6 +7748,8 @@ body.reset {
     line-height: 27px; }
     line-height: 27px; }
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage ul {
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage ul {
     width: 100%; }
     width: 100%; }
+  #conversejs.converse-fullscreen .chatbox .sendXMPPMessage .suggestion-box__results:after {
+    display: none; }
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
     margin-right: 5em; }
     margin-right: 5em; }
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {
@@ -8681,8 +8683,7 @@ body.reset {
       color: #E77051; }
       color: #E77051; }
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-textarea,
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-textarea,
   #conversejs .chatroom .sendXMPPMessage .chat-textarea {
   #conversejs .chatroom .sendXMPPMessage .chat-textarea {
-    border-bottom-right-radius: 0;
-    resize: none; }
+    border-bottom-right-radius: 0; }
     #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-textarea.correcting,
     #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-textarea.correcting,
     #conversejs .chatroom .sendXMPPMessage .chat-textarea.correcting {
     #conversejs .chatroom .sendXMPPMessage .chat-textarea.correcting {
       background-color: #fadfd7; }
       background-color: #fadfd7; }
@@ -9040,20 +9041,26 @@ body.reset {
 #conversejs .visually-hidden {
 #conversejs .visually-hidden {
   position: absolute;
   position: absolute;
   clip: rect(0, 0, 0, 0); }
   clip: rect(0, 0, 0, 0); }
+#conversejs .form-group .suggestion-box,
 #conversejs .form-group .awesomplete {
 #conversejs .form-group .awesomplete {
   width: 100%; }
   width: 100%; }
-#conversejs div.awesomplete {
-  display: inline-block;
+#conversejs .suggestion-box,
+#conversejs .awesomplete {
   position: relative; }
   position: relative; }
-  #conversejs div.awesomplete mark {
+  #conversejs .suggestion-box mark,
+  #conversejs .awesomplete mark {
     background: #FFB9A7; }
     background: #FFB9A7; }
-  #conversejs div.awesomplete > input {
+  #conversejs .suggestion-box > input,
+  #conversejs .awesomplete > input {
     display: block; }
     display: block; }
-  #conversejs div.awesomplete > ul {
+  #conversejs .suggestion-box .suggestion-box__results,
+  #conversejs .suggestion-box > ul,
+  #conversejs .awesomplete .suggestion-box__results,
+  #conversejs .awesomplete > ul {
     position: absolute;
     position: absolute;
     left: 0;
     left: 0;
     right: 0;
     right: 0;
-    z-index: 1;
+    z-index: 2;
     min-width: 100%;
     min-width: 100%;
     box-sizing: border-box;
     box-sizing: border-box;
     list-style: none;
     list-style: none;
@@ -9061,51 +9068,91 @@ body.reset {
     border-radius: .3em;
     border-radius: .3em;
     margin: .2em 0 0;
     margin: .2em 0 0;
     background: rgba(255, 255, 255, 0.9);
     background: rgba(255, 255, 255, 0.9);
-    background: linear-gradient(to bottom right, white, rgba(255, 255, 255, 0.8));
+    background: linear-gradient(to bottom right, white, rgba(255, 255, 255, 0.9));
     border: 1px solid rgba(0, 0, 0, 0.3);
     border: 1px solid rgba(0, 0, 0, 0.3);
-    box-shadow: 0.05em 0.2em 0.6em rgba(0, 0, 0, 0.2);
+    box-shadow: 0.05em 0.2em 0.6em rgba(0, 0, 0, 0.1);
     text-shadow: none; }
     text-shadow: none; }
-    #conversejs div.awesomplete > ul:before {
+    #conversejs .suggestion-box .suggestion-box__results:before,
+    #conversejs .suggestion-box > ul:before,
+    #conversejs .awesomplete .suggestion-box__results:before,
+    #conversejs .awesomplete > ul:before {
       content: "";
       content: "";
       position: absolute;
       position: absolute;
       top: -.43em;
       top: -.43em;
       left: 1em;
       left: 1em;
       width: 0;
       width: 0;
       height: 0;
       height: 0;
+      padding: .4em;
       background: white;
       background: white;
       border: inherit;
       border: inherit;
       border-right: 0;
       border-right: 0;
       border-bottom: 0;
       border-bottom: 0;
       -webkit-transform: rotate(45deg);
       -webkit-transform: rotate(45deg);
-      transform: rotate(45deg); }
-    #conversejs div.awesomplete > ul > li {
+      transform: rotate(45deg);
+      z-index: 1; }
+    #conversejs .suggestion-box .suggestion-box__results > li,
+    #conversejs .suggestion-box > ul > li,
+    #conversejs .awesomplete .suggestion-box__results > li,
+    #conversejs .awesomplete > ul > li {
       text-overflow: ellipsis;
       text-overflow: ellipsis;
       overflow-x: hidden;
       overflow-x: hidden;
       position: relative;
       position: relative;
       cursor: pointer;
       cursor: pointer;
       padding: 1em; }
       padding: 1em; }
+  #conversejs .suggestion-box .suggestion-box__results--above,
+  #conversejs .awesomplete .suggestion-box__results--above {
+    bottom: 4.5em; }
+    #conversejs .suggestion-box .suggestion-box__results--above:before,
+    #conversejs .awesomplete .suggestion-box__results--above:before {
+      display: none; }
+    #conversejs .suggestion-box .suggestion-box__results--above:after,
+    #conversejs .awesomplete .suggestion-box__results--above:after {
+      z-index: 1;
+      content: "";
+      position: absolute;
+      bottom: -.43em;
+      left: 1em;
+      width: 0;
+      height: 0;
+      padding: .4em;
+      background: white;
+      border: inherit;
+      border-left: 0;
+      border-top: 0;
+      -webkit-transform: rotate(45deg);
+      transform: rotate(45deg); }
+#conversejs .suggestion-box > ul[hidden],
+#conversejs .suggestion-box > ul:empty,
 #conversejs div.awesomplete > ul[hidden],
 #conversejs div.awesomplete > ul[hidden],
 #conversejs div.awesomplete > ul:empty {
 #conversejs div.awesomplete > ul:empty {
   display: none; }
   display: none; }
 @supports (transform: scale(0)) {
 @supports (transform: scale(0)) {
+  #conversejs .suggestion-box > ul,
   #conversejs div.awesomplete > ul {
   #conversejs div.awesomplete > ul {
     transition: 0.3s cubic-bezier(0.4, 0.2, 0.5, 1.4);
     transition: 0.3s cubic-bezier(0.4, 0.2, 0.5, 1.4);
     transform-origin: 1.43em -.43em; }
     transform-origin: 1.43em -.43em; }
+  #conversejs .suggestion-box > ul[hidden],
+  #conversejs .suggestion-box > ul:empty,
   #conversejs div.awesomplete > ul[hidden],
   #conversejs div.awesomplete > ul[hidden],
   #conversejs div.awesomplete > ul:empty {
   #conversejs div.awesomplete > ul:empty {
     opacity: 0;
     opacity: 0;
     transform: scale(0);
     transform: scale(0);
     display: block;
     display: block;
     transition-timing-function: ease; } }
     transition-timing-function: ease; } }
+#conversejs .suggestion-box > ul > li:hover,
 #conversejs div.awesomplete > ul > li:hover {
 #conversejs div.awesomplete > ul > li:hover {
+  z-index: 2;
   background: #E77051;
   background: #E77051;
   color: white; }
   color: white; }
+#conversejs .suggestion-box > ul > li[aria-selected="true"],
 #conversejs div.awesomplete > ul > li[aria-selected="true"] {
 #conversejs div.awesomplete > ul > li[aria-selected="true"] {
   background: #3d6d8f;
   background: #3d6d8f;
   color: white; }
   color: white; }
+#conversejs .suggestion-box li:hover mark,
 #conversejs div.awesomplete li:hover mark {
 #conversejs div.awesomplete li:hover mark {
   background: #A53214;
   background: #A53214;
   color: white; }
   color: white; }
+#conversejs .suggestion-box li[aria-selected="true"] mark,
 #conversejs div.awesomplete li[aria-selected="true"] mark {
 #conversejs div.awesomplete li[aria-selected="true"] mark {
   background: #3d6b00;
   background: #3d6b00;
   color: inherit; }
   color: inherit; }

File diff suppressed because it is too large
+ 512 - 22
dist/converse.js


+ 40 - 6
sass/_awesomplete.scss

@@ -7,13 +7,14 @@
     }
     }
 
 
     .form-group {
     .form-group {
+        .suggestion-box,
         .awesomplete {
         .awesomplete {
             width: 100%;
             width: 100%;
         }
         }
     }
     }
 
 
-    div.awesomplete {
-        display: inline-block;
+    .suggestion-box,
+    .awesomplete {
         position: relative;
         position: relative;
         mark {
         mark {
             background: $lightest-red;
             background: $lightest-red;
@@ -23,6 +24,7 @@
             display: block;
             display: block;
         }
         }
 
 
+        .suggestion-box__results,
         > ul {
         > ul {
             &:before {
             &:before {
                 content: "";
                 content: "";
@@ -30,18 +32,19 @@
                 top: -.43em;
                 top: -.43em;
                 left: 1em;
                 left: 1em;
                 width: 0; height: 0;
                 width: 0; height: 0;
+                padding: .4em;
                 background: white;
                 background: white;
                 border: inherit;
                 border: inherit;
                 border-right: 0;
                 border-right: 0;
                 border-bottom: 0;
                 border-bottom: 0;
                 -webkit-transform: rotate(45deg);
                 -webkit-transform: rotate(45deg);
                 transform: rotate(45deg);
                 transform: rotate(45deg);
+                z-index: 1;
             }
             }
-
             position: absolute;
             position: absolute;
             left: 0;
             left: 0;
             right: 0;
             right: 0;
-            z-index: 1;
+            z-index: 2;
             min-width: 100%;
             min-width: 100%;
             box-sizing: border-box;
             box-sizing: border-box;
             list-style: none;
             list-style: none;
@@ -49,9 +52,9 @@
             border-radius: .3em;
             border-radius: .3em;
             margin: .2em 0 0;
             margin: .2em 0 0;
             background: hsla(0,0%,100%,.9);
             background: hsla(0,0%,100%,.9);
-            background: linear-gradient(to bottom right, white, hsla(0,0%,100%,.8));
+            background: linear-gradient(to bottom right, white, hsla(0,0%,100%,.9));
             border: 1px solid rgba(0,0,0,.3);
             border: 1px solid rgba(0,0,0,.3);
-            box-shadow: .05em .2em .6em rgba(0,0,0,.2);
+            box-shadow: .05em .2em .6em rgba(0,0,0,.1);
             text-shadow: none;
             text-shadow: none;
 
 
             > li {
             > li {
@@ -62,19 +65,45 @@
                 padding: 1em;
                 padding: 1em;
             }
             }
         }
         }
+        .suggestion-box__results--above {
+            bottom: 4.5em;
+            &:before {
+                display: none;
+            }
+            &:after {
+                z-index: 1;
+                content: "";
+                position: absolute;
+                bottom: -.43em;
+                left: 1em;
+                width: 0; height: 0;
+                padding: .4em;
+                background: white;
+                border: inherit;
+                border-left: 0;
+                border-top: 0;
+                -webkit-transform: rotate(45deg);
+                transform: rotate(45deg);
+            }
+        }
     }
     }
 
 
+    .suggestion-box > ul[hidden],
+    .suggestion-box > ul:empty,
     div.awesomplete > ul[hidden],
     div.awesomplete > ul[hidden],
     div.awesomplete > ul:empty {
     div.awesomplete > ul:empty {
         display: none;
         display: none;
     }
     }
 
 
     @supports (transform: scale(0)) {
     @supports (transform: scale(0)) {
+        .suggestion-box > ul,
         div.awesomplete > ul {
         div.awesomplete > ul {
             transition: .3s cubic-bezier(.4,.2,.5,1.4);
             transition: .3s cubic-bezier(.4,.2,.5,1.4);
             transform-origin: 1.43em -.43em;
             transform-origin: 1.43em -.43em;
         }
         }
         
         
+        .suggestion-box > ul[hidden],
+        .suggestion-box > ul:empty,
         div.awesomplete > ul[hidden],
         div.awesomplete > ul[hidden],
         div.awesomplete > ul:empty {
         div.awesomplete > ul:empty {
             opacity: 0;
             opacity: 0;
@@ -84,21 +113,26 @@
         }
         }
     }
     }
     
     
+    .suggestion-box > ul > li:hover,
     div.awesomplete > ul > li:hover {
     div.awesomplete > ul > li:hover {
+        z-index: 2;
         background: $red;
         background: $red;
         color: $inverse-link-color;
         color: $inverse-link-color;
     }
     }
     
     
+    .suggestion-box > ul > li[aria-selected="true"],
     div.awesomplete > ul > li[aria-selected="true"] {
     div.awesomplete > ul > li[aria-selected="true"] {
         background: hsl(205, 40%, 40%);
         background: hsl(205, 40%, 40%);
         color: white;
         color: white;
     }
     }
     
     
+    .suggestion-box li:hover mark,
     div.awesomplete li:hover mark {
     div.awesomplete li:hover mark {
         background: $darkest-red;
         background: $darkest-red;
         color: $inverse-link-color;
         color: $inverse-link-color;
     }
     }
     
     
+    .suggestion-box li[aria-selected="true"] mark,
     div.awesomplete li[aria-selected="true"] mark {
     div.awesomplete li[aria-selected="true"] mark {
         background: hsl(86, 100%, 21%);
         background: hsl(86, 100%, 21%);
         color: inherit;
         color: inherit;

+ 5 - 0
sass/_chatbox.scss

@@ -605,6 +605,11 @@
             ul {
             ul {
                 width: 100%;
                 width: 100%;
             }
             }
+            .suggestion-box__results {
+                &:after {
+                    display: none;
+                }
+            }
             .toggle-smiley {
             .toggle-smiley {
                 ul {
                 ul {
                     &.emoji-toolbar {
                     &.emoji-toolbar {

+ 0 - 1
sass/_chatrooms.scss

@@ -271,7 +271,6 @@
             }
             }
             .chat-textarea {
             .chat-textarea {
                 border-bottom-right-radius: 0;
                 border-bottom-right-radius: 0;
-                resize: none;
                 &.correcting {
                 &.correcting {
                     background-color: lighten($chatroom-head-color, 30%);
                     background-color: lighten($chatroom-head-color, 30%);
                 }
                 }

+ 465 - 0
src/converse-autocomplete.js

@@ -0,0 +1,465 @@
+// Converse.js
+// http://conversejs.org
+//
+// Copyright (c) 2013-2018, the Converse.js developers
+// Licensed under the Mozilla Public License (MPLv2)
+
+// This plugin started as a fork of Lea Verou's Awesomplete
+// https://leaverou.github.io/awesomplete/
+
+(function (root, factory) {
+    define(["converse-core"], factory);
+}(this, function (converse) {
+
+    const { _, Backbone } = converse.env,
+          u = converse.env.utils;
+
+
+    converse.plugins.add("converse-autocomplete", {
+        initialize () {
+            const { _converse } = this;
+
+            _converse.FILTER_CONTAINS = function (text, input) {
+                return RegExp($.regExpEscape(input.trim()), "i").test(text);
+            };
+
+            _converse.FILTER_STARTSWITH = function (text, input) {
+                return RegExp("^" + $.regExpEscape(input.trim()), "i").test(text);
+            };
+
+            const _ac = function (el, o) {
+                const me = this;
+
+                this.is_opened = false;
+
+                if (u.hasClass('.suggestion-box', el)) {
+                    this.container = el;
+                } else {
+                    this.container = el.querySelector('.suggestion-box');
+                }
+                this.input = $(this.container.querySelector('.suggestion-box__input'));
+                this.input.setAttribute("autocomplete", "off");
+                this.input.setAttribute("aria-autocomplete", "list");
+
+                this.ul = $(this.container.querySelector('.suggestion-box__results'));
+                this.status = $(this.container.querySelector('.suggestion-box__additions'));
+
+                o = o || {};
+
+                configure(this, {
+                    'match_current_word': false, // Match only the current word, otherwise all input is matched
+                    'match_on_tab': false, // Whether matching should only start when tab's pressed
+                    'min_chars': 2,
+                    'max_items': 10,
+                    'auto_evaluate': true,
+                    'auto_first': false,
+                    'data': _ac.DATA,
+                    'filter': _ac.FILTER_CONTAINS,
+                    'sort': o.sort === false ? false : _ac.SORT_BYLENGTH,
+                    'item': _ac.ITEM,
+                    'replace': _ac.REPLACE
+                }, o);
+
+                this.index = -1;
+
+                const input = {
+                    "blur": this.close.bind(this, { reason: "blur" }),
+                    "keydown": function(evt) {
+                        const c = evt.keyCode;
+
+                        // If the dropdown `ul` is in view, then act on keydown for the following keys:
+                        // Enter / Esc / Up / Down
+                        if(me.opened) {
+                            if (c === _converse.keycodes.ENTER && me.selected) {
+                                evt.preventDefault();
+                                me.select();
+                            } else if (c === _converse.keycodes.ESCAPE) {
+                                me.close({ reason: "esc" });
+                            } else if (c === _converse.keycodes.UP_ARROW || c === _converse.keycodes.DOWN_ARROW) {
+                                evt.preventDefault();
+                                me[c === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
+                            }
+                        }
+                    }
+                }
+                if (this.auto_evaluate) {
+                    input["input"] = this.evaluate.bind(this);
+                }
+
+                // Bind events
+                this._events = {
+                    'input': input,
+                    'form': {
+                        "submit": this.close.bind(this, { reason: "submit" })
+                    },
+                    'ul': {
+                        "mousedown": function(evt) {
+                            let li = evt.target;
+                            if (li !== this) {
+                                while (li && !(/li/i).test(li.nodeName)) {
+                                    li = li.parentNode;
+                                }
+
+                                if (li && evt.button === 0) {  // Only select on left click
+                                    evt.preventDefault();
+                                    me.select(li, evt.target);
+                                }
+                            }
+                        }
+                    }
+                };
+
+                $.bind(this.input, this._events.input);
+                $.bind(this.input.form, this._events.form);
+                $.bind(this.ul, this._events.ul);
+
+                if (this.input.hasAttribute("list")) {
+                    this.list = "#" + this.input.getAttribute("list");
+                    this.input.removeAttribute("list");
+                }
+                else {
+                    this.list = this.input.getAttribute("data-list") || o.list || [];
+                }
+
+                _ac.all.push(this);
+            }
+
+            _ac.prototype = {
+                set list (list) {
+                    if (Array.isArray(list)) {
+                        this._list = list;
+                    }
+                    else if (typeof list === "string" && _.includes(list, ",")) {
+                        this._list = list.split(/\s*,\s*/);
+                    }
+                    else { // Element or CSS selector
+                        list = $(list);
+                        if (list && list.children) {
+                            const items = [];
+                            slice.apply(list.children).forEach(function (el) {
+                                if (!el.disabled) {
+                                    const text = el.textContent.trim(),
+                                        value = el.value || text,
+                                        label = el.label || text;
+                                    if (value !== "") {
+                                        items.push({ label: label, value: value });
+                                    }
+                                }
+                            });
+                            this._list = items;
+                        }
+                    }
+
+                    if (document.activeElement === this.input) {
+                        this.evaluate();
+                    }
+                },
+
+                get selected() {
+                    return this.index > -1;
+                },
+
+                get opened() {
+                    return this.is_opened;
+                },
+
+                close (o) {
+                    if (!this.opened) {
+                        return;
+                    }
+
+                    this.ul.setAttribute("hidden", "");
+                    this.is_opened = false;
+                    this.index = -1;
+
+                    $.fire(this.input, "suggestion-box-close", o || {});
+                },
+
+                open () {
+                    this.ul.removeAttribute("hidden");
+                    this.is_opened = true;
+
+                    if (this.auto_first && this.index === -1) {
+                        this.goto(0);
+                    }
+
+                    $.fire(this.input, "suggestion-box-open");
+                },
+
+                destroy () {
+                    //remove events from the input and its form
+                    $.unbind(this.input, this._events.input);
+                    $.unbind(this.input.form, this._events.form);
+
+                    //move the input out of the suggestion-box container and remove the container and its children
+                    const parentNode = this.container.parentNode;
+
+                    parentNode.insertBefore(this.input, this.container);
+                    parentNode.removeChild(this.container);
+
+                    //remove autocomplete and aria-autocomplete attributes
+                    this.input.removeAttribute("autocomplete");
+                    this.input.removeAttribute("aria-autocomplete");
+
+                    //remove this awesomeplete instance from the global array of instances
+                    var indexOfAutoComplete = _ac.all.indexOf(this);
+
+                    if (indexOfAutoComplete !== -1) {
+                        _ac.all.splice(indexOfAutoComplete, 1);
+                    }
+                },
+
+                next () {
+                    var count = this.ul.children.length;
+                    this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
+                },
+
+                previous () {
+                    var count = this.ul.children.length;
+                    var pos = this.index - 1;
+
+                    this.goto(this.selected && pos !== -1 ? pos : count - 1);
+                },
+
+                // Should not be used, highlights specific item without any checks!
+                goto (i) {
+                    var lis = this.ul.children;
+
+                    if (this.selected) {
+                        lis[this.index].setAttribute("aria-selected", "false");
+                    }
+
+                    this.index = i;
+
+                    if (i > -1 && lis.length > 0) {
+                        lis[i].setAttribute("aria-selected", "true");
+                        this.status.textContent = lis[i].textContent;
+
+                        // scroll to highlighted element in case parent's height is fixed
+                        this.ul.scrollTop = lis[i].offsetTop - this.ul.clientHeight + lis[i].clientHeight;
+
+                        $.fire(this.input, "suggestion-box-highlight", {
+                            text: this.suggestions[this.index]
+                        });
+                    }
+                },
+
+                select (selected, origin) {
+                    if (selected) {
+                        this.index = u.siblingIndex(selected);
+                    } else {
+                        selected = this.ul.children[this.index];
+                    }
+
+                    if (selected) {
+                        const suggestion = this.suggestions[this.index],
+                            allowed = $.fire(this.input, "suggestion-box-select", {
+                                'text': suggestion,
+                                'origin': origin || selected
+                            });
+
+                        if (allowed) {
+                            this.replace(suggestion);
+                            this.close({'reason': 'select'});
+                            this.auto_completing = false;
+                            this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
+                        }
+                    }
+                },
+
+                keyPressed (ev) {
+                    if (_.includes([
+                                _converse.keycodes.SHIFT,
+                                _converse.keycodes.META,
+                                _converse.keycodes.META_RIGHT,
+                                _converse.keycodes.ESCAPE,
+                                _converse.keycodes.ALT]
+                            , ev.keyCode)) {
+                        return;
+                    }
+                    if (this.match_on_tab && ev.keyCode === _converse.keycodes.TAB) {
+                        ev.preventDefault();
+                        this.auto_completing = true;
+                    }
+                    if (this.auto_completing) {
+                        this.evaluate();
+                    }
+                },
+
+                evaluate (ev) {
+                    let value = this.input.value;
+                    if (this.match_current_word) {
+                        value = u.getCurrentWord(this.input);
+                    }
+
+                    if (value.length >= this.min_chars && this._list.length > 0) {
+                        this.index = -1;
+                        // Populate list with options that match
+                        this.ul.innerHTML = "";
+
+                        this.suggestions = this._list
+                            .map(item => new Suggestion(this.data(item, value)))
+                            .filter(item => this.filter(item, value));
+
+                        if (this.sort !== false) {
+                            this.suggestions = this.suggestions.sort(this.sort);
+                        }
+                        this.suggestions = this.suggestions.slice(0, this.max_items);
+                        this.suggestions.forEach((text) => this.ul.appendChild(this.item(text, value)));
+
+                        if (this.ul.children.length === 0) {
+                            this.close({'reason': 'nomatches'});
+                        } else {
+                            this.open();
+                        }
+                    } else {
+                        this.close({'reason': 'nomatches'});
+                        this.auto_completing = false;
+                    }
+                }
+            };
+
+            // Make it an event emitter
+            _.extend(_ac.prototype, Backbone.Events);
+
+            // Static methods/properties
+            _ac.all = [];
+
+            _ac.SORT_BYLENGTH = function (a, b) {
+                if (a.length !== b.length) {
+                    return a.length - b.length;
+                }
+
+                return a < b? -1 : 1;
+            };
+
+            _ac.ITEM = function (text, input) {
+                input = input.trim();
+                var element = document.createElement("li");
+                element.setAttribute("aria-selected", "false");
+
+                var regex = new RegExp("("+input+")", "ig");
+                var parts = input ? text.split(regex) : [text];
+                parts.forEach(function (txt) {
+                    if (input && txt.match(regex)) {
+                        var match = document.createElement("mark");
+                        match.textContent = txt;
+                        element.appendChild(match);
+                    } else {
+                        element.appendChild(document.createTextNode(txt));
+                    }
+                });
+                return element;
+            };
+
+            _ac.REPLACE = function (text) {
+                this.input.value = text.value;
+            };
+
+            _ac.DATA = function (item/*, input*/) { return item; };
+
+            // Private functions
+
+            function Suggestion(data) {
+                const o = Array.isArray(data)
+                    ? { label: data[0], value: data[1] }
+                    : typeof data === "object" && "label" in data && "value" in data ? data : { label: data, value: data };
+
+                this.label = o.label || o.value;
+                this.value = o.value;
+            }
+
+            Object.defineProperty(Suggestion.prototype = Object.create(String.prototype), "length", {
+                get: function() { return this.label.length; }
+            });
+
+            Suggestion.prototype.toString = Suggestion.prototype.valueOf = function () {
+                return "" + this.label;
+            };
+
+            function configure (instance, properties, o) {
+                for (var i in properties) {
+                    if (!Object.prototype.hasOwnProperty.call(properties, i)) {
+                        continue;
+                    }
+
+                    const initial = properties[i],
+                          attr_value = instance.input.getAttribute("data-" + i.toLowerCase());
+
+                    if (typeof initial === "number") {
+                        instance[i] = parseInt(attr_value, 10);
+                    } else if (initial === false) { // Boolean options must be false by default anyway
+                        instance[i] = attr_value !== null;
+                    } else if (initial instanceof Function) {
+                        instance[i] = null;
+                    } else {
+                        instance[i] = attr_value;
+                    }
+
+                    if (!instance[i] && instance[i] !== 0) {
+                        instance[i] = (i in o)? o[i] : initial;
+                    }
+                }
+            }
+
+            // Helpers
+            var slice = Array.prototype.slice;
+
+            function $(expr, con) {
+                return typeof expr === "string"? (con || document).querySelector(expr) : expr || null;
+            }
+
+            function $$(expr, con) {
+                return slice.call((con || document).querySelectorAll(expr));
+            }
+
+            $.bind = function(element, o) {
+                if (element) {
+                    for (var event in o) {
+                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                            continue;
+                        }
+                        const callback = o[event];
+                        event.split(/\s+/).forEach(event => element.addEventListener(event, callback));
+                    }
+                }
+            };
+
+            $.unbind = function(element, o) {
+                if (element) {
+                    for (var event in o) {
+                        if (!Object.prototype.hasOwnProperty.call(o, event)) {
+                            continue;
+                        }
+                        const callback = o[event];
+                        event.split(/\s+/).forEach(event => element.removeEventListener(event, callback));
+                    }
+                }
+            };
+
+            $.fire = function(target, type, properties) {
+                var evt = document.createEvent("HTMLEvents");
+
+                evt.initEvent(type, true, true );
+
+                for (var j in properties) {
+                    if (!Object.prototype.hasOwnProperty.call(properties, j)) {
+                        continue;
+                    }
+                    evt[j] = properties[j];
+                }
+
+                return target.dispatchEvent(evt);
+            };
+
+            $.regExpEscape = function (s) {
+                return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
+            };
+
+            _ac.$ = $;
+            _ac.$$ = $$;
+
+            _converse.AutoComplete = _ac;
+        }
+    });
+}));

+ 12 - 18
src/converse-chatview.js

@@ -50,18 +50,6 @@
     "use strict";
     "use strict";
     const { $msg, Backbone, Promise, Strophe, _, b64_sha1, f, sizzle, moment } = converse.env;
     const { $msg, Backbone, Promise, Strophe, _, b64_sha1, f, sizzle, moment } = converse.env;
     const u = converse.env.utils;
     const u = converse.env.utils;
-    const KEY = {
-        ENTER: 13,
-        SHIFT: 17,
-        CTRL: 17,
-        ALT: 18,
-        ESCAPE: 27,
-        UP_ARROW: 38,
-        DOWN_ARROW: 40,
-        FORWARD_SLASH: 47,
-        META: 91,
-        META_RIGHT: 93
-    };
 
 
     converse.plugins.add('converse-chatview', {
     converse.plugins.add('converse-chatview', {
         /* Plugin dependencies are other plugins which might be
         /* Plugin dependencies are other plugins which might be
@@ -926,20 +914,26 @@
                         return;
                         return;
                     }
                     }
                     if (!ev.shiftKey && !ev.altKey) {
                     if (!ev.shiftKey && !ev.altKey) {
-                        if (ev.keyCode === KEY.FORWARD_SLASH) {
+                        if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
                             // Forward slash is used to run commands. Nothing to do here.
                             // Forward slash is used to run commands. Nothing to do here.
                             return;
                             return;
-                        } else if (ev.keyCode === KEY.ESCAPE) {
+                        } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
                             return this.onEscapePressed(ev);
                             return this.onEscapePressed(ev);
-                        } else if (ev.keyCode === KEY.ENTER) {
+                        } else if (ev.keyCode === _converse.keycodes.ENTER) {
                             return this.onFormSubmitted(ev);
                             return this.onFormSubmitted(ev);
-                        } else if (ev.keyCode === KEY.UP_ARROW && !ev.target.selectionEnd) {
+                        } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
                             return this.editEarlierMessage();
                             return this.editEarlierMessage();
-                        } else if (ev.keyCode === KEY.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
+                        } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
                             return this.editLaterMessage();
                             return this.editLaterMessage();
                         }
                         }
                     } 
                     } 
-                    if (_.includes([KEY.SHIFT, KEY.META, KEY.META_RIGHT, KEY.ESCAPE, KEY.ALT], ev.keyCode)) {
+                    if (_.includes([
+                                _converse.keycodes.SHIFT,
+                                _converse.keycodes.META,
+                                _converse.keycodes.META_RIGHT,
+                                _converse.keycodes.ESCAPE,
+                                _converse.keycodes.ALT]
+                            , ev.keyCode)) {
                         return;
                         return;
                     }
                     }
                     if (this.model.get('chat_state') !== _converse.COMPOSING) {
                     if (this.model.get('chat_state') !== _converse.COMPOSING) {

+ 16 - 0
src/converse-core.js

@@ -67,6 +67,7 @@
 
 
     // Core plugins are whitelisted automatically
     // Core plugins are whitelisted automatically
     _converse.core_plugins = [
     _converse.core_plugins = [
+        'converse-autocomplete',
         'converse-bookmarks',
         'converse-bookmarks',
         'converse-caps',
         'converse-caps',
         'converse-chatboxes',
         'converse-chatboxes',
@@ -106,6 +107,21 @@
     // Make converse pluggable
     // Make converse pluggable
     pluggable.enable(_converse, '_converse', 'pluggable');
     pluggable.enable(_converse, '_converse', 'pluggable');
 
 
+    _converse.keycodes = {
+        TAB: 9,
+        ENTER: 13,
+        SHIFT: 16,
+        CTRL: 17,
+        ALT: 18,
+        ESCAPE: 27,
+        UP_ARROW: 38,
+        DOWN_ARROW: 40,
+        FORWARD_SLASH: 47,
+        META: 91,
+        META_RIGHT: 93
+    };
+
+
     // Module-level constants
     // Module-level constants
     _converse.STATUS_WEIGHTS = {
     _converse.STATUS_WEIGHTS = {
         'offline':      6,
         'offline':      6,

+ 21 - 4
src/converse-muc-views.js

@@ -1,7 +1,7 @@
 // Converse.js
 // Converse.js
 // http://conversejs.org
 // http://conversejs.org
 //
 //
-// Copyright (c) 2012-2018, the Converse.js developers
+// Copyright (c) 2013-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
 // Licensed under the Mozilla Public License (MPLv2)
 
 
 (function (root, factory) {
 (function (root, factory) {
@@ -93,7 +93,7 @@
          * If the setting "strict_plugin_dependencies" is set to true,
          * If the setting "strict_plugin_dependencies" is set to true,
          * an error will be raised if the plugin is not found.
          * an error will be raised if the plugin is not found.
          */
          */
-        dependencies: ["converse-modal", "converse-controlbox", "converse-chatview"],
+        dependencies: ["converse-autocomplete", "converse-modal", "converse-controlbox", "converse-chatview"],
 
 
         overrides: {
         overrides: {
 
 
@@ -584,6 +584,7 @@
                     this.renderHeading();
                     this.renderHeading();
                     this.renderChatArea();
                     this.renderChatArea();
                     this.renderMessageForm();
                     this.renderMessageForm();
+                    this.initAutoComplete();
                     if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
                     if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
                         this.showSpinner();
                         this.showSpinner();
                     }
                     }
@@ -610,6 +611,23 @@
                     return this;
                     return this;
                 },
                 },
 
 
+                initAutoComplete () {
+                    this.auto_complete = new _converse.AutoComplete(this.el, {
+                        'auto_evaluate': false,
+                        'min_chars': 1,
+                        'match_current_word': true,
+                        'match_on_tab': true,
+                        'list': this.model.occupants.map(o => ({'label': o.getDisplayName(), 'value': o.get('jid')})),
+                        'filter': _converse.FILTER_STARTSWITH
+                    });
+                    this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+                },
+
+                keyPressed (ev) {
+                    this.auto_complete.keyPressed(ev);
+                    return _converse.ChatBoxView.prototype.keyPressed.apply(this, arguments);
+                },
+
                 showRoomDetailsModal (ev) {
                 showRoomDetailsModal (ev) {
                     ev.preventDefault();
                     ev.preventDefault();
                     if (_.isUndefined(this.model.room_details_modal)) {
                     if (_.isUndefined(this.model.room_details_modal)) {
@@ -834,8 +852,7 @@
                 },
                 },
 
 
                 parseMessageForCommands (text) {
                 parseMessageForCommands (text) {
-                    const _super_ = _converse.ChatBoxView.prototype;
-                    if (_super_.parseMessageForCommands.apply(this, arguments)) {
+                    if (_converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments)) {
                         return true;
                         return true;
                     }
                     }
                     if (_converse.muc_disable_moderator_commands) {
                     if (_converse.muc_disable_moderator_commands) {

+ 1 - 0
src/converse.js

@@ -7,6 +7,7 @@ if (typeof define !== 'undefined') {
          * --------------------
          * --------------------
          * Any of the following components may be removed if they're not needed.
          * Any of the following components may be removed if they're not needed.
          */
          */
+        "converse-autocomplete",
         "converse-bookmarks",       // XEP-0048 Bookmarks
         "converse-bookmarks",       // XEP-0048 Bookmarks
         "converse-caps",            // XEP-0115 Entity Capabilities
         "converse-caps",            // XEP-0115 Entity Capabilities
         "converse-chatview",        // Renders standalone chat boxes for single user chat
         "converse-chatview",        // Renders standalone chat boxes for single user chat

+ 17 - 9
src/templates/chatbox_message_form.html

@@ -6,14 +6,22 @@
     {[ } ]}
     {[ } ]}
     <input type="text" placeholder="{{o.label_spoiler_hint}}" value="{{ o.hint_value }}"
     <input type="text" placeholder="{{o.label_spoiler_hint}}" value="{{ o.hint_value }}"
            class="{[ if (!o.composing_spoiler) { ]} hidden {[ } ]} spoiler-hint"/>
            class="{[ if (!o.composing_spoiler) { ]} hidden {[ } ]} spoiler-hint"/>
-    <textarea
-        type="text"
-        class="chat-textarea
-            {[ if (o.show_send_button) { ]} chat-textarea-send-button {[ } ]}
-            {[ if (o.composing_spoiler) { ]} spoiler {[ } ]}"
-        placeholder="{{{o.label_personal_message}}}">{{ o.message_value }}</textarea>
-    {[ if (o.show_send_button) { ]}
-        <button type="submit" class="pure-button send-button">{{{ o.label_send }}}</button>
-    {[ } ]}
+
+    <div class="suggestion-box">
+        <ul class="suggestion-box__results suggestion-box__results--above" hidden></ul>
+        <textarea
+            type="text"
+            class="chat-textarea suggestion-box__input
+                {[ if (o.show_send_button) { ]} chat-textarea-send-button {[ } ]}
+                {[ if (o.composing_spoiler) { ]} spoiler {[ } ]}"
+            placeholder="{{{o.label_personal_message}}}">{{ o.message_value }}</textarea>
+        <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+
+
+
+        {[ if (o.show_send_button) { ]}
+            <button type="submit" class="pure-button send-button">{{{ o.label_send }}}</button>
+        {[ } ]}
+    </div>
 </form>
 </form>
 </div>
 </div>

+ 12 - 1
src/utils/core.js

@@ -808,7 +808,18 @@
         } else {
         } else {
             model.set(attributes);
             model.set(attributes);
         }
         }
-    }
+    };
+
+    u.siblingIndex = function (el) {
+        /* eslint-disable no-cond-assign */
+        for (var i = 0; el = el.previousElementSibling; i++);
+        return i;
+    };
+
+    u.getCurrentWord = function (input) {
+        const cursor = input.selectionEnd || undefined;
+        return _.last(input.value.slice(0, cursor).split(' '));
+    };
 
 
     u.isVisible = function (el) {
     u.isVisible = function (el) {
         if (u.hasClass('hidden', el)) {
         if (u.hasClass('hidden', el)) {

Some files were not shown because too many files changed in this diff