Browse Source

Merge branch 'master' into converse-omemo

JC Brand 6 years ago
parent
commit
b4110dc162

+ 2 - 5
.eslintrc.json

@@ -20,7 +20,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"
             ]
             ]
         }],
         }],
@@ -216,10 +216,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",

+ 2 - 1
CHANGES.md

@@ -21,8 +21,9 @@
 - Add a checkbox to indicate whether a trusted device is being used or not.
 - Add a checkbox to indicate whether a trusted device is being used or not.
   If the device is not trusted, sessionStorage is used and all user data is deleted from the browser cache upon logout.
   If the device is not trusted, sessionStorage is used and all user data is deleted from the browser cache upon logout.
   If the device is trusted, localStorage is used and user data is cached indefinitely.
   If the device is trusted, localStorage is used and user data is cached indefinitely.
-- Initial support for XEP-0357 Push Notifications, specifically registering an "App Server".
+- Initial support for [XEP-0357 Push Notifications](https://xmpp.org/extensions/xep-0357.html), specifically registering an "App Server".
 - Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configurations.html#oauth-providers) setting)
 - Add support for logging in via OAuth (see the [oauth_providers](https://conversejs.org/docs/html/configurations.html#oauth-providers) setting)
+- Add support for [XEP-0372 References](https://xmpp.org/extensions/xep-0372.html), specifically section "3.2 Mentions".
 
 
 ### Bugfixes
 ### Bugfixes
 
 

+ 94 - 32
css/converse.css

@@ -7490,6 +7490,8 @@ body.reset {
     @media screen and (max-width: 480px) {
     @media screen and (max-width: 480px) {
       #conversejs .chatbox .sendXMPPMessage {
       #conversejs .chatbox .sendXMPPMessage {
         width: 100%; } }
         width: 100%; } }
+    #conversejs .chatbox .sendXMPPMessage .suggestion-box__results:after {
+      display: none; }
     #conversejs .chatbox .sendXMPPMessage .spoiler-hint {
     #conversejs .chatbox .sendXMPPMessage .spoiler-hint {
       width: 100%; }
       width: 100%; }
     #conversejs .chatbox .sendXMPPMessage .chat-textarea {
     #conversejs .chatbox .sendXMPPMessage .chat-textarea {
@@ -7501,9 +7503,12 @@ body.reset {
       width: 100%;
       width: 100%;
       border: none;
       border: none;
       min-height: 60px;
       min-height: 60px;
-      margin-bottom: -4px; }
+      margin-bottom: -4px;
+      resize: none; }
       #conversejs .chatbox .sendXMPPMessage .chat-textarea.spoiler {
       #conversejs .chatbox .sendXMPPMessage .chat-textarea.spoiler {
         height: 42px; }
         height: 42px; }
+      #conversejs .chatbox .sendXMPPMessage .chat-textarea.correcting {
+        background-color: #e7f7ee; }
     #conversejs .chatbox .sendXMPPMessage .send-button {
     #conversejs .chatbox .sendXMPPMessage .send-button {
       position: absolute;
       position: absolute;
       left: 3px;
       left: 3px;
@@ -7519,7 +7524,7 @@ body.reset {
       margin: 0;
       margin: 0;
       padding: 0.25em;
       padding: 0.25em;
       display: block;
       display: block;
-      border-top: 8px solid #3AA569;
+      border-top: 4px solid #3AA569;
       background-color: white;
       background-color: white;
       color: #3AA569; }
       color: #3AA569; }
       #conversejs .chatbox .sendXMPPMessage .chat-toolbar .fa, #conversejs .chatbox .sendXMPPMessage .chat-toolbar .fa:hover {
       #conversejs .chatbox .sendXMPPMessage .chat-toolbar .fa, #conversejs .chatbox .sendXMPPMessage .chat-toolbar .fa:hover {
@@ -7632,6 +7637,9 @@ body.reset {
       left: 0; }
       left: 0; }
 
 
 /* ******************* Overlay and embedded styles *************************** */
 /* ******************* Overlay and embedded styles *************************** */
+#conversejs.converse-embedded .chat-textarea {
+  max-height: 15em; }
+
 #conversejs.converse-embedded .chat-head,
 #conversejs.converse-embedded .chat-head,
 #conversejs.converse-overlayed .chat-head {
 #conversejs.converse-overlayed .chat-head {
   border-top-left-radius: 4px;
   border-top-left-radius: 4px;
@@ -7713,7 +7721,7 @@ body.reset {
     flex: 0 0 16.6666666667%;
     flex: 0 0 16.6666666667%;
     max-width: 16.6666666667%; }
     max-width: 16.6666666667%; }
 #conversejs.converse-fullscreen .chat-textarea {
 #conversejs.converse-fullscreen .chat-textarea {
-  max-height: 400px; }
+  max-height: 15em; }
 #conversejs.converse-fullscreen .emoji-picker {
 #conversejs.converse-fullscreen .emoji-picker {
   height: 150px; }
   height: 150px; }
 #conversejs.converse-fullscreen .chatbox {
 #conversejs.converse-fullscreen .chatbox {
@@ -7753,7 +7761,7 @@ body.reset {
     border-top-right-radius: 4px; }
     border-top-right-radius: 4px; }
   #conversejs.converse-fullscreen .chatbox .chat-title {
   #conversejs.converse-fullscreen .chatbox .chat-title {
     font-size: 20px;
     font-size: 20px;
-    line-height: 24px; }
+    line-height: 27px; }
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage ul {
   #conversejs.converse-fullscreen .chatbox .sendXMPPMessage ul {
     width: 100%; }
     width: 100%; }
   #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 {
@@ -8120,7 +8128,7 @@ body.reset {
       padding: 3em 2em 3em; }
       padding: 3em 2em 3em; }
   #conversejs.converse-fullscreen #controlbox .toggle-register-login,
   #conversejs.converse-fullscreen #controlbox .toggle-register-login,
   #conversejs.converse-mobile #controlbox .toggle-register-login {
   #conversejs.converse-mobile #controlbox .toggle-register-login {
-    line-height: 24px; }
+    line-height: 27px; }
   #conversejs.converse-fullscreen #controlbox .brand-heading-container,
   #conversejs.converse-fullscreen #controlbox .brand-heading-container,
   #conversejs.converse-mobile #controlbox .brand-heading-container {
   #conversejs.converse-mobile #controlbox .brand-heading-container {
     flex: 0 0 100%;
     flex: 0 0 100%;
@@ -8545,9 +8553,6 @@ body.reset {
         #conversejs.converse-embedded .chatroom .box-flyout .chatroom-body .chat-info.badge,
         #conversejs.converse-embedded .chatroom .box-flyout .chatroom-body .chat-info.badge,
         #conversejs .chatroom .box-flyout .chatroom-body .chat-info.badge {
         #conversejs .chatroom .box-flyout .chatroom-body .chat-info.badge {
           color: white; }
           color: white; }
-      #conversejs.converse-embedded .chatroom .box-flyout .chatroom-body .mentioned,
-      #conversejs .chatroom .box-flyout .chatroom-body .mentioned {
-        font-weight: bold; }
       #conversejs.converse-embedded .chatroom .box-flyout .chatroom-body .disconnect-container,
       #conversejs.converse-embedded .chatroom .box-flyout .chatroom-body .disconnect-container,
       #conversejs .chatroom .box-flyout .chatroom-body .disconnect-container {
       #conversejs .chatroom .box-flyout .chatroom-body .disconnect-container {
         margin: 1em;
         margin: 1em;
@@ -8681,7 +8686,7 @@ body.reset {
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar,
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar,
   #conversejs .chatroom .sendXMPPMessage .chat-toolbar {
   #conversejs .chatroom .sendXMPPMessage .chat-toolbar {
     background-color: white;
     background-color: white;
-    border-top: 8px solid #E77051;
+    border-top: 4px solid #E77051;
     color: #E77051; }
     color: #E77051; }
     #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar .fa, #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar .fa:hover,
     #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar .fa, #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-toolbar .fa:hover,
     #conversejs .chatroom .sendXMPPMessage .chat-toolbar .fa,
     #conversejs .chatroom .sendXMPPMessage .chat-toolbar .fa,
@@ -8690,14 +8695,20 @@ body.reset {
   #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; }
     border-bottom-right-radius: 0; }
+    #conversejs.converse-embedded .chatroom .sendXMPPMessage .chat-textarea.correcting,
+    #conversejs .chatroom .sendXMPPMessage .chat-textarea.correcting {
+      background-color: #fadfd7; }
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .send-button,
   #conversejs.converse-embedded .chatroom .sendXMPPMessage .send-button,
   #conversejs .chatroom .sendXMPPMessage .send-button {
   #conversejs .chatroom .sendXMPPMessage .send-button {
     background-color: #E77051; }
     background-color: #E77051; }
-  #conversejs.converse-embedded .chatroom .room-invite .invited-contact,
-  #conversejs .chatroom .room-invite .invited-contact {
-    margin: -1px 0 0 -1px;
-    width: 100%;
-    border: 1px solid #999; }
+  #conversejs.converse-embedded .chatroom .room-invite,
+  #conversejs .chatroom .room-invite {
+    padding-bottom: 1em; }
+    #conversejs.converse-embedded .chatroom .room-invite .invited-contact,
+    #conversejs .chatroom .room-invite .invited-contact {
+      margin: -1px 0 0 -1px;
+      width: 100%;
+      border: 1px solid #999; }
 
 
 /* ******************* Overlay  styles *************************** */
 /* ******************* Overlay  styles *************************** */
 #conversejs.converse-overlayed .chatbox.chatroom {
 #conversejs.converse-overlayed .chatbox.chatroom {
@@ -8798,6 +8809,10 @@ body.reset {
   border: 1.2em solid #E7A151;
   border: 1.2em solid #E7A151;
   border-top: 0.8em solid #E7A151; }
   border-top: 0.8em solid #E7A151; }
 
 
+#conversejs .message .mention {
+  font-weight: bold; }
+#conversejs .message .mention--self {
+  font-weight: normal; }
 #conversejs .message.date-separator {
 #conversejs .message.date-separator {
   height: 2em;
   height: 2em;
   margin: 0;
   margin: 0;
@@ -8854,7 +8869,7 @@ body.reset {
     #conversejs .message.chat-msg:hover .chat-msg__actions .chat-msg__action {
     #conversejs .message.chat-msg:hover .chat-msg__actions .chat-msg__action {
       opacity: 1; }
       opacity: 1; }
   #conversejs .message.chat-msg.correcting.groupchat {
   #conversejs .message.chat-msg.correcting.groupchat {
-    background-color: #fdf1ee; }
+    background-color: #fadfd7; }
   #conversejs .message.chat-msg.correcting:not(.groupchat) {
   #conversejs .message.chat-msg.correcting:not(.groupchat) {
     background-color: #e7f7ee; }
     background-color: #e7f7ee; }
   #conversejs .message.chat-msg .spoiler {
   #conversejs .message.chat-msg .spoiler {
@@ -9044,20 +9059,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;
@@ -9065,55 +9086,96 @@ 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 div.awesomplete > ul > li:hover {
-  background: #E77051;
-  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: #D24E2B;
   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: #FFB9A7;
   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: #E77051;
   color: inherit; }
   color: inherit; }
 
 
+#conversejs.converse-fullscreen .suggestion-box__results--above {
+  bottom: 4.5em; }
+
+#conversejs.converse-overlayed .suggestion-box__results--above {
+  bottom: 5.5em; }
+
 #conversejs.converse-embedded {
 #conversejs.converse-embedded {
   -webkit-box-sizing: border-box;
   -webkit-box-sizing: border-box;
   -moz-box-sizing: border-box;
   -moz-box-sizing: border-box;

File diff suppressed because it is too large
+ 583 - 44
dist/converse.js


+ 12 - 0
docs/source/api/index.rst

@@ -0,0 +1,12 @@
+.. raw:: html
+
+    <div id="banner"><a href="https://github.com/jcbrand/converse.js/blob/master/docs/source/theming.rst">Edit me on GitHub</a></div>
+
+=========================
+The new API documentation
+=========================
+
+This document is a stub. It shouldn't show at all, instead it's a hack in order
+to link to the JSDoc output.
+
+See https://stackoverflow.com/questions/27979803/external-relative-link-in-sphinx-toctree-directive

+ 0 - 5
fullscreen.html

@@ -12,11 +12,6 @@
     <script src="dist/converse.js"></script>
     <script src="dist/converse.js"></script>
 </head>
 </head>
 <body class="reset">
 <body class="reset">
-    <div class="content">
-        <div class="inner-content">
-            <h1 class="brand-heading"><i class="icon-conversejs"></i> Converse</h1>
-        </div>
-    </div>
 <script>
 <script>
     /*
     /*
     @licstart
     @licstart

+ 4 - 0
mockup/chatroom.html

@@ -283,6 +283,10 @@
                                     </div>
                                     </div>
 								</div>
 								</div>
 
 
+                                <div class="message chat-info chat-state-notification"
+                                    data-isodate="2018-04-36T18:21:36+02:00" 
+                                    data-csn="romeo@capulet.lit">Romeo Montague is typing</div>
+
                             </div>
                             </div>
                             <div class="new-msgs-indicator">▼ You have unread messages ▼</div>
                             <div class="new-msgs-indicator">▼ You have unread messages ▼</div>
                             <form class="sendXMPPMessage">
                             <form class="sendXMPPMessage">

+ 54 - 15
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,23 +113,33 @@
         }
         }
     }
     }
     
     
-    div.awesomplete > ul > li:hover {
-        background: $red;
-        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%);
-        color: white;
+        background: $dark-red;
+        color: $inverse-link-color;
     }
     }
     
     
+    .suggestion-box li:hover mark,
     div.awesomplete li:hover mark {
     div.awesomplete li:hover mark {
-        background: $darkest-red;
+        background: $lightest-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: $red;
         color: inherit;
         color: inherit;
     }
     }
 }
 }
+
+#conversejs.converse-fullscreen {
+    .suggestion-box__results--above {
+        bottom: 4.5em;
+    }
+}
+
+#conversejs.converse-overlayed {
+    .suggestion-box__results--above {
+        bottom: 5.5em;
+    }
+}

+ 18 - 1
sass/_chatbox.scss

@@ -237,11 +237,18 @@
                 width: 100%;
                 width: 100%;
             }
             }
 
 
+            .suggestion-box__results {
+                &:after {
+                    display: none;
+                }
+            }
+
             .spoiler-hint {
             .spoiler-hint {
                 width: 100%;
                 width: 100%;
             }
             }
 
 
             .chat-textarea {
             .chat-textarea {
+                color: $chat-textarea-color;
                 border-top-left-radius: 0;
                 border-top-left-radius: 0;
                 border-top-right-radius: 0;
                 border-top-right-radius: 0;
                 @include border-bottom-radius($chatbox-border-radius);
                 @include border-bottom-radius($chatbox-border-radius);
@@ -250,9 +257,13 @@
                 border: none;
                 border: none;
                 min-height: $chat-textarea-height;
                 min-height: $chat-textarea-height;
                 margin-bottom: -4px; // Not clear why this is necessar :(
                 margin-bottom: -4px; // Not clear why this is necessar :(
+                resize: none;
                 &.spoiler {
                 &.spoiler {
                     height: 42px;
                     height: 42px;
                 }
                 }
+                &.correcting {
+                    background-color: lighten($chat-head-color, 50%);
+                }
             }
             }
 
 
             .send-button {
             .send-button {
@@ -271,7 +282,7 @@
                 margin: 0;
                 margin: 0;
                 padding: 0.25em;
                 padding: 0.25em;
                 display: block;
                 display: block;
-                border-top: 8px solid $chat-head-color;
+                border-top: 4px solid $chat-head-color;
                 background-color: white;
                 background-color: white;
                 color: $chat-head-color;
                 color: $chat-head-color;
                 .fa, .fa:hover {
                 .fa, .fa:hover {
@@ -437,6 +448,12 @@
 
 
 /* ******************* Overlay and embedded styles *************************** */
 /* ******************* Overlay and embedded styles *************************** */
 
 
+#conversejs.converse-embedded {
+    .chat-textarea {
+        max-height: $fullpage-max-chat-textarea-height;
+    }
+}
+
 #conversejs.converse-embedded,
 #conversejs.converse-embedded,
 #conversejs.converse-overlayed {
 #conversejs.converse-overlayed {
     .chat-head {
     .chat-head {

+ 5 - 4
sass/_chatrooms.scss

@@ -116,9 +116,6 @@
                         color: $chat-head-text-color;
                         color: $chat-head-text-color;
                     }
                     }
                 }
                 }
-                .mentioned {
-                    font-weight: bold;
-                }
                 .disconnect-container {
                 .disconnect-container {
                     margin: 1em;
                     margin: 1em;
                     width: 100%;
                     width: 100%;
@@ -263,7 +260,7 @@
         .sendXMPPMessage {
         .sendXMPPMessage {
             .chat-toolbar {
             .chat-toolbar {
                 background-color: white;
                 background-color: white;
-                border-top: 8px solid $chatroom-head-color;
+                border-top: 4px solid $chatroom-head-color;
                 color: $chatroom-head-color;
                 color: $chatroom-head-color;
                 .fa, .fa:hover {
                 .fa, .fa:hover {
                     color: $chatroom-head-color;
                     color: $chatroom-head-color;
@@ -271,6 +268,9 @@
             }
             }
             .chat-textarea {
             .chat-textarea {
                 border-bottom-right-radius: 0;
                 border-bottom-right-radius: 0;
+                &.correcting {
+                    background-color: lighten($chatroom-head-color, 30%);
+                }
             }
             }
             .send-button {
             .send-button {
                 background-color: $chatroom-head-color;
                 background-color: $chatroom-head-color;
@@ -278,6 +278,7 @@
         }
         }
 
 
         .room-invite {
         .room-invite {
+            padding-bottom: 1em;
             .invited-contact {
             .invited-contact {
                 margin: -1px 0 0 -1px;
                 margin: -1px 0 0 -1px;
                 width: 100%;
                 width: 100%;

+ 7 - 1
sass/_messages.scss

@@ -1,6 +1,12 @@
 #conversejs {
 #conversejs {
     .message {
     .message {
 
 
+        .mention {
+            font-weight: bold;
+        }
+        .mention--self {
+            font-weight: normal;
+        }
         &.date-separator {
         &.date-separator {
             height: 2em;
             height: 2em;
             margin: 0;
             margin: 0;
@@ -80,7 +86,7 @@
             }
             }
             &.correcting {
             &.correcting {
                 &.groupchat  {
                 &.groupchat  {
-                    background-color: lighten($chatroom-head-color, 35%);
+                    background-color: lighten($chatroom-head-color, 30%);
                 }
                 }
                 &:not(.groupchat) {
                 &:not(.groupchat) {
                     background-color: lighten($chat-head-color, 50%);
                     background-color: lighten($chat-head-color, 50%);

+ 3 - 2
sass/_variables.scss

@@ -56,6 +56,7 @@ $border-color: #CCC !default;
 $icon-color: $blue !default;
 $icon-color: $blue !default;
 $save-button-color: $green !default;
 $save-button-color: $green !default;
 
 
+$chat-textarea-color: #666 !default;
 $chat-textarea-height: 60px !default;
 $chat-textarea-height: 60px !default;
 
 
 $send-button-height: 27px !default;
 $send-button-height: 27px !default;
@@ -140,7 +141,7 @@ $legend-font-size: 16px !default;
 $line-height-small:  14px !default;
 $line-height-small:  14px !default;
 $line-height:  16px !default;
 $line-height:  16px !default;
 $line-height-large:  20px !default;
 $line-height-large:  20px !default;
-$line-height-huge:  24px !default;
+$line-height-huge:  27px !default;
 
 
 $occupants-padding: 1em;
 $occupants-padding: 1em;
 
 
@@ -148,7 +149,7 @@ $fullpage-chat-head-height: 62px !default;
 $fullpage-chat-height: 100vh;
 $fullpage-chat-height: 100vh;
 $fullpage-chat-width: 100%;
 $fullpage-chat-width: 100%;
 $fullpage-emoji-picker-height: 150px !default;
 $fullpage-emoji-picker-height: 150px !default;
-$fullpage-max-chat-textarea-height: 400px !default;
+$fullpage-max-chat-textarea-height: 15em!default;
 
 
 $overlayed-chat-head-height: 55px !default;
 $overlayed-chat-head-height: 55px !default;
 $overlayed-chat-height: 450px !default;
 $overlayed-chat-height: 450px !default;

+ 175 - 0
spec/autocomplete.js

@@ -0,0 +1,175 @@
+(function (root, factory) {
+    define([
+        "jasmine",
+        "mock",
+        "test-utils"
+        ], factory);
+} (this, function (jasmine, mock, test_utils) {
+    "use strict";
+    const _ = converse.env._;
+    const $iq = converse.env.$iq;
+    const $msg = converse.env.$msg;
+    const $pres = converse.env.$pres;
+    const Strophe = converse.env.Strophe;
+    const u = converse.env.utils;
+
+    describe("The nickname autocomplete feature", function () {
+
+        it("shows all autocompletion options when the user presses @",
+            mock.initConverseWithPromises(
+                null, ['rosterGroupsFetched'], {},
+                    function (done, _converse) {
+
+            test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'tom')
+            .then(() => {
+                const view = _converse.chatboxviews.get('lounge@localhost');
+
+                ['dick', 'harry'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@localhost/resource',
+                            'from': `lounge@localhost/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick}@localhost/resource`,
+                            'role': 'participant'
+                        })));
+                });
+
+                // Test that pressing @ brings up all options
+                const textarea = view.el.querySelector('textarea.chat-textarea');
+                const at_event = {
+                    'target': textarea,
+                    'preventDefault': _.noop,
+                    'stopPropagation': _.noop,
+                    'keyCode': 50
+                };
+                view.keyPressed(at_event);
+                textarea.value = '@';
+                view.keyUp(at_event);
+
+                expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(3);
+                expect(view.el.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('tom');
+                expect(view.el.querySelector('.suggestion-box__results li:first-child').textContent).toBe('tom');
+                expect(view.el.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('dick');
+                expect(view.el.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('harry');
+                done();
+            }).catch(_.partial(console.error, _));
+        }));
+
+        it("autocompletes when the user presses tab",
+            mock.initConverseWithPromises(
+                null, ['rosterGroupsFetched'], {},
+                    function (done, _converse) {
+
+            test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy')
+            .then(() => {
+                const view = _converse.chatboxviews.get('lounge@localhost');
+                expect(view.model.occupants.length).toBe(1);
+                let presence = $pres({
+                        'to': 'dummy@localhost/resource',
+                        'from': 'lounge@localhost/some1'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'some1@localhost/resource',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                expect(view.model.occupants.length).toBe(2);
+
+                const textarea = view.el.querySelector('textarea.chat-textarea');
+                textarea.value = "hello som";
+
+                // Press tab
+                const tab_event = {
+                    'target': textarea,
+                    'preventDefault': _.noop,
+                    'stopPropagation': _.noop,
+                    'keyCode': 9
+                }
+                view.keyPressed(tab_event);
+                view.keyUp(tab_event);
+                expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
+                expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(1);
+                expect(view.el.querySelector('.suggestion-box__results li').textContent).toBe('some1');
+
+                const backspace_event = {
+                    'target': textarea,
+                    'preventDefault': _.noop,
+                    'keyCode': 8
+                }
+                for (var i=0; i<3; i++) {
+                    // Press backspace 3 times to remove "som"
+                    view.keyPressed(backspace_event);
+                    textarea.value = textarea.value.slice(0, textarea.value.length-1)
+                    view.keyUp(backspace_event);
+                }
+                expect(view.el.querySelector('.suggestion-box__results').hidden).toBeTruthy();
+
+                presence = $pres({
+                        'to': 'dummy@localhost/resource',
+                        'from': 'lounge@localhost/some2'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'some2@localhost/resource',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(test_utils.createRequest(presence));
+
+                textarea.value = "hello s s";
+                view.keyPressed(tab_event);
+                view.keyUp(tab_event);
+                expect(view.el.querySelector('.suggestion-box__results').hidden).toBeFalsy();
+                expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+
+                const up_arrow_event = {
+                    'target': textarea,
+                    'preventDefault': () => (up_arrow_event.defaultPrevented = true),
+                    'stopPropagation': _.noop,
+                    'keyCode': 38
+                }
+                view.keyPressed(up_arrow_event);
+                view.keyUp(up_arrow_event);
+                expect(view.el.querySelectorAll('.suggestion-box__results li').length).toBe(2);
+                expect(view.el.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1');
+                expect(view.el.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
+
+                view.keyPressed({
+                    'target': textarea,
+                    'preventDefault': _.noop,
+                    'stopPropagation': _.noop,
+                    'keyCode': 13 // Enter
+                });
+                expect(textarea.value).toBe('hello s @some2 ');
+
+                // Test that pressing tab twice selects
+                presence = $pres({
+                        'to': 'dummy@localhost/resource',
+                        'from': 'lounge@localhost/z3r0'
+                    })
+                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    .c('item', {
+                        'affiliation': 'none',
+                        'jid': 'z3r0@localhost/resource',
+                        'role': 'participant'
+                    });
+                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                textarea.value = "hello z";
+                view.keyPressed(tab_event);
+                view.keyUp(tab_event);
+
+                view.keyPressed(tab_event);
+                view.keyUp(tab_event);
+                expect(textarea.value).toBe('hello @z3r0 ');
+
+                done();
+            }).catch(_.partial(console.error, _));
+        }));
+    });
+}));

+ 12 - 12
spec/chatbox.js

@@ -707,7 +707,7 @@
                         .then(function () {
                         .then(function () {
                             var view = _converse.chatboxviews.get(sender_jid);
                             var view = _converse.chatboxviews.get(sender_jid);
                             // Check that the notification appears inside the chatbox in the DOM
                             // Check that the notification appears inside the chatbox in the DOM
-                            var events = view.el.querySelectorAll('.chat-state-notification');
+                            let events = view.el.querySelectorAll('.chat-state-notification');
                             expect(events.length).toBe(1);
                             expect(events.length).toBe(1);
                             expect(events[0].textContent).toEqual(mock.cur_names[1] + ' is typing');
                             expect(events[0].textContent).toEqual(mock.cur_names[1] + ' is typing');
 
 
@@ -1491,7 +1491,7 @@
                     var chatbox = _converse.chatboxes.get(sender_jid);
                     var chatbox = _converse.chatboxes.get(sender_jid);
                     var chatboxview = _converse.chatboxviews.get(sender_jid);
                     var chatboxview = _converse.chatboxviews.get(sender_jid);
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator';
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator';
-                    var selectMsgsIndicator = function () { return $($(_converse.rosterview.el).find(msgsIndicatorSelector)); };
+                    var selectMsgsIndicator = () => $(_converse.rosterview.el).find(msgsIndicatorSelector);
                     var msgFactory = function () {
                     var msgFactory = function () {
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                     };
                     };
@@ -1527,7 +1527,7 @@
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                     };
                     };
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator',
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator',
-                        selectMsgsIndicator = function () { return $($(_converse.rosterview.el).find(msgsIndicatorSelector)); };
+                        selectMsgsIndicator = () => $(_converse.rosterview.el).find(msgsIndicatorSelector);
 
 
                     chatbox.save('scrolled', true);
                     chatbox.save('scrolled', true);
 
 
@@ -1559,7 +1559,7 @@
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                         return test_utils.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
                     };
                     };
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator',
                     var msgsIndicatorSelector = 'a.open-chat:contains("' + chatbox.get('fullname') + '") .msgs-indicator',
-                        selectMsgsIndicator = function () { return $($(_converse.rosterview.el).find(msgsIndicatorSelector)); };
+                        selectMsgsIndicator = () => $(_converse.rosterview.el).find(msgsIndicatorSelector);
 
 
                     chatbox.save('scrolled', true);
                     chatbox.save('scrolled', true);
 
 
@@ -1591,7 +1591,7 @@
                     };
                     };
                     const selectUnreadMsgCount = function () {
                     const selectUnreadMsgCount = function () {
                         const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
                         const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
-                        return $(minimizedChatBoxView.el).find('.message-count');
+                        return minimizedChatBoxView.el.querySelector('.message-count');
                     };
                     };
 
 
                     const chatbox = _converse.chatboxes.get(sender_jid);
                     const chatbox = _converse.chatboxes.get(sender_jid);
@@ -1601,9 +1601,9 @@
                     const chatboxview = _converse.chatboxviews.get(sender_jid);
                     const chatboxview = _converse.chatboxviews.get(sender_jid);
                     chatboxview.minimize();
                     chatboxview.minimize();
 
 
-                    const $unreadMsgCount = selectUnreadMsgCount();
-                    expect(u.isVisible($unreadMsgCount[0])).toBeTruthy();
-                    expect($unreadMsgCount.html()).toBe('1');
+                    const unread_count = selectUnreadMsgCount();
+                    expect(u.isVisible(unread_count)).toBeTruthy();
+                    expect(unread_count.innerHTML).toBe('1');
                     done();
                     done();
                 });
                 });
             }));
             }));
@@ -1625,7 +1625,7 @@
                     };
                     };
                     const selectUnreadMsgCount = function () {
                     const selectUnreadMsgCount = function () {
                         const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
                         const minimizedChatBoxView = _converse.minimized_chats.get(sender_jid);
-                        return $(minimizedChatBoxView.el).find('.message-count');
+                        return minimizedChatBoxView.el.querySelector('.message-count');
                     };
                     };
 
 
                     const chatboxview = _converse.chatboxviews.get(sender_jid);
                     const chatboxview = _converse.chatboxviews.get(sender_jid);
@@ -1633,9 +1633,9 @@
 
 
                     _converse.chatboxes.onMessage(msgFactory());
                     _converse.chatboxes.onMessage(msgFactory());
 
 
-                    const $unreadMsgCount = selectUnreadMsgCount();
-                    expect(u.isVisible($unreadMsgCount[0])).toBeTruthy();
-                    expect($unreadMsgCount.html()).toBe('1');
+                    const unread_count = selectUnreadMsgCount();
+                    expect(u.isVisible(unread_count)).toBeTruthy();
+                    expect(unread_count.innerHTML).toBe('1');
                     done();
                     done();
                 });
                 });
             }));
             }));

+ 271 - 126
spec/chatroom.js

@@ -60,49 +60,49 @@
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
 
 
+                let jid, room, chatroomview;
+
                 test_utils.createContacts(_converse, 'current');
                 test_utils.createContacts(_converse, 'current');
-                test_utils.waitUntil(function () {
-                    return $(_converse.rosterview.el).find('.roster-group .group-toggle').length;
-                }, 300).then(function () {
-                    test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy').then(function () {
-                        var jid = 'lounge@localhost';
-                        var room = _converse.api.rooms.get(jid);
-                        expect(room instanceof Object).toBeTruthy();
-
-                        var chatroomview = _converse.chatboxviews.get(jid);
-                        expect(chatroomview.is_chatroom).toBeTruthy();
-
-                        expect(u.isVisible(chatroomview.el)).toBeTruthy();
-                        chatroomview.close();
-
-                        // Test with mixed case
-                        test_utils.openAndEnterChatRoom(_converse, 'Leisure', 'localhost', 'dummy').then(function () {
-                            jid = 'Leisure@localhost';
-                            room = _converse.api.rooms.get(jid);
-                            expect(room instanceof Object).toBeTruthy();
-                            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                            expect(u.isVisible(chatroomview.el)).toBeTruthy();
-
-                            jid = 'leisure@localhost';
-                            room = _converse.api.rooms.get(jid);
-                            expect(room instanceof Object).toBeTruthy();
-                            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                            expect(u.isVisible(chatroomview.el)).toBeTruthy();
-
-                            jid = 'leiSure@localhost';
-                            room = _converse.api.rooms.get(jid);
-                            expect(room instanceof Object).toBeTruthy();
-                            chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
-                            expect(u.isVisible(chatroomview.el)).toBeTruthy();
-                            chatroomview.close();
-
-                            // Non-existing room
-                            jid = 'lounge2@localhost';
-                            room = _converse.api.rooms.get(jid);
-                            expect(typeof room === 'undefined').toBeTruthy();
-                            done();
-                        });
-                    });
+                test_utils.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group .group-toggle').length, 300)
+                .then(() => test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy'))
+                .then(() => {
+                    jid = 'lounge@localhost';
+                    room = _converse.api.rooms.get(jid);
+                    expect(room instanceof Object).toBeTruthy();
+
+                    chatroomview = _converse.chatboxviews.get(jid);
+                    expect(chatroomview.is_chatroom).toBeTruthy();
+
+                    expect(u.isVisible(chatroomview.el)).toBeTruthy();
+                    chatroomview.close();
+
+                    // Test with mixed case
+                    return test_utils.openAndEnterChatRoom(_converse, 'Leisure', 'localhost', 'dummy');
+                }).then(() => {
+                    jid = 'Leisure@localhost';
+                    room = _converse.api.rooms.get(jid);
+                    expect(room instanceof Object).toBeTruthy();
+                    chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+                    expect(u.isVisible(chatroomview.el)).toBeTruthy();
+
+                    jid = 'leisure@localhost';
+                    room = _converse.api.rooms.get(jid);
+                    expect(room instanceof Object).toBeTruthy();
+                    chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+                    expect(u.isVisible(chatroomview.el)).toBeTruthy();
+
+                    jid = 'leiSure@localhost';
+                    room = _converse.api.rooms.get(jid);
+                    expect(room instanceof Object).toBeTruthy();
+                    chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
+                    expect(u.isVisible(chatroomview.el)).toBeTruthy();
+                    chatroomview.close();
+
+                    // Non-existing room
+                    jid = 'lounge2@localhost';
+                    room = _converse.api.rooms.get(jid);
+                    expect(typeof room === 'undefined').toBeTruthy();
+                    done();
                 });
                 });
             }));
             }));
 
 
@@ -1240,8 +1240,9 @@
                     var occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
                     var occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
                     expect(occupants.length).toBe(1);
                     expect(occupants.length).toBe(1);
                     expect($(occupants).first().find('.occupant-nick').text().trim()).toBe("dummy");
                     expect($(occupants).first().find('.occupant-nick').text().trim()).toBe("dummy");
-                    expect($(occupants).first().find('.badge').length).toBe(1);
-                    expect($(occupants).first().find('.badge').first().text()).toBe('Member');
+                    expect($(occupants).first().find('.badge').length).toBe(2);
+                    expect($(occupants).first().find('.badge').first().text()).toBe('Owner');
+                    expect($(occupants).first().find('.badge').last().text()).toBe('Moderator');
 
 
                     var presence = $pres({
                     var presence = $pres({
                             to:'dummy@localhost/pda',
                             to:'dummy@localhost/pda',
@@ -1255,15 +1256,15 @@
                     .c('status').attrs({code:'110'}).nodeTree;
                     .c('status').attrs({code:'110'}).nodeTree;
 
 
                     _converse.connection._dataRecv(test_utils.createRequest(presence));
                     _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    occupants = view.el.querySelector('.occupant-list').querySelectorAll('li');
+                    occupants = view.el.querySelectorAll('.occupant-list li');
                     expect(occupants.length).toBe(2);
                     expect(occupants.length).toBe(2);
-                    expect($(occupants).first().find('.occupant-nick').text().trim()).toBe("moderatorman");
-                    expect($(occupants).last().find('.occupant-nick').text().trim()).toBe("dummy");
-                    expect($(occupants).first().find('.badge').length).toBe(2);
-                    expect($(occupants).first().find('.badge').first().text()).toBe('Admin');
-                    expect($(occupants).first().find('.badge').last().text()).toBe('Moderator');
+                    expect($(occupants).first().find('.occupant-nick').text().trim()).toBe("dummy");
+                    expect($(occupants).last().find('.occupant-nick').text().trim()).toBe("moderatorman");
+                    expect($(occupants).last().find('.badge').length).toBe(2);
+                    expect($(occupants).last().find('.badge').first().text()).toBe('Admin');
+                    expect($(occupants).last().find('.badge').last().text()).toBe('Moderator');
 
 
-                    expect($(occupants).first().attr('title')).toBe(
+                    expect($(occupants).last().attr('title')).toBe(
                         contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
                         contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.'
                     );
                     );
 
 
@@ -1706,10 +1707,10 @@
                         })
                         })
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                         .c('item').attrs({
                         .c('item').attrs({
-                            affiliation: 'member',
+                            affiliation: 'owner',
                             jid: 'dummy@localhost/pda',
                             jid: 'dummy@localhost/pda',
                             nick: 'newnick',
                             nick: 'newnick',
-                            role: 'participant'
+                            role: 'moderator'
                         }).up()
                         }).up()
                         .c('status').attrs({code:'303'}).up()
                         .c('status').attrs({code:'303'}).up()
                         .c('status').attrs({code:'110'}).nodeTree;
                         .c('status').attrs({code:'110'}).nodeTree;
@@ -1730,9 +1731,9 @@
                         })
                         })
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                         .c('item').attrs({
                         .c('item').attrs({
-                            affiliation: 'member',
+                            affiliation: 'owner',
                             jid: 'dummy@localhost/pda',
                             jid: 'dummy@localhost/pda',
-                            role: 'participant'
+                            role: 'moderator'
                         }).up()
                         }).up()
                         .c('status').attrs({code:'110'}).nodeTree;
                         .c('status').attrs({code:'110'}).nodeTree;
 
 
@@ -2047,7 +2048,7 @@
                     expect(view.model.get('minimized')).toBeFalsy();
                     expect(view.model.get('minimized')).toBeFalsy();
                     expect(_converse.emit.calls.count(), 3);
                     expect(_converse.emit.calls.count(), 3);
                     done();
                     done();
-                    
+
                 });
                 });
             }));
             }));
 
 
@@ -2075,7 +2076,7 @@
 
 
         describe("Each chat groupchat can take special commands", function () {
         describe("Each chat groupchat can take special commands", function () {
 
 
-            it("/help to show the available commands",
+            it("takes /help to show the available commands",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2111,10 +2112,176 @@
                     expect(info_messages.pop().textContent).toBe('/ban: Ban user from groupchat');
                     expect(info_messages.pop().textContent).toBe('/ban: Ban user from groupchat');
                     expect(info_messages.pop().textContent).toBe('/admin: Change user\'s affiliation to admin');
                     expect(info_messages.pop().textContent).toBe('/admin: Change user\'s affiliation to admin');
                     done();
                     done();
-                });
+                }).catch(_.partial(console.error, _));
+            }));
+
+            it("takes /member to make an occupant a member",
+                mock.initConverseWithPromises(
+                    null, ['rosterGroupsFetched'], {},
+                    function (done, _converse) {
+
+                let iq_stanza, view;
+
+                test_utils.openAndEnterChatRoom(_converse, 'lounge', 'muc.localhost', 'dummy')
+                .then(() => {
+
+                    view = _converse.chatboxviews.get('lounge@muc.localhost');
+                    /* We don't show join/leave messages for existing occupants. We
+                     * know about them because we receive their presences before we
+                     * receive our own.
+                     */
+                    const presence = $pres({
+                            to: 'dummy@localhost/resource',
+                            from: 'lounge@muc.localhost/marc'
+                        }).c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': 'marc@localhost/_converse.js-290929789',
+                            'role': 'participant'
+                        });
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
+                    expect(view.model.occupants.length).toBe(2);
+
+                    const textarea = view.el.querySelector('.chat-textarea');
+                    let sent_stanza;
+                    spyOn(_converse.connection, 'send').and.callFake((stanza) => {
+                        sent_stanza = stanza;
+                    });
+
+                    // First check that an error message appears when a
+                    // non-existent nick is used.
+                    textarea.value = '/member chris Welcome to the club!';
+                    view.keyPressed({
+                        target: textarea,
+                        preventDefault: _.noop,
+                        keyCode: 13
+                    });
+                    expect(_converse.connection.send).not.toHaveBeenCalled();
+                    expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
+                    expect(view.el.querySelector('.chat-error').textContent.trim())
+                        .toBe(`Error: Can't find a groupchat participant with the nickname "chris"`)
+
+                    // Now test with an existing nick
+                    textarea.value = '/member marc Welcome to the club!';
+                    view.keyPressed({
+                        target: textarea,
+                        preventDefault: _.noop,
+                        keyCode: 13
+                    });
+                    expect(_converse.connection.send).toHaveBeenCalled();
+                    expect(sent_stanza.outerHTML).toBe(
+                        `<iq to="lounge@muc.localhost" type="set" xmlns="jabber:client" id="${sent_stanza.getAttribute('id')}">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                                `<item affiliation="member" jid="marc@localhost">`+
+                                    `<reason>Welcome to the club!</reason>`+
+                                `</item>`+
+                            `</query>`+
+                        `</iq>`);
+
+                    const result = $iq({
+                        "xmlns": "jabber:client",
+                        "type": "result",
+                        "to": "dummy@localhost/resource",
+                        "from": "lounge@muc.localhost",
+                        "id": sent_stanza.getAttribute('id')
+                    });
+                    _converse.connection.IQ_stanzas = [];
+                    _converse.connection._dataRecv(test_utils.createRequest(result));
+
+                    return test_utils.waitUntil(() => {
+                        return _.filter(
+                            _converse.connection.IQ_stanzas,
+                            (iq) => {
+                                const node = iq.nodeTree.querySelector('iq[to="lounge@muc.localhost"][type="get"] item[affiliation="member"]');
+                                if (node) { iq_stanza = iq.nodeTree;}
+                                return node;
+                            }).length;
+                    });
+                }).then(() => {
+                    expect(iq_stanza.outerHTML).toBe(
+                        `<iq to="lounge@muc.localhost" type="get" xmlns="jabber:client" id="${iq_stanza.getAttribute('id')}">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                                `<item affiliation="member"/>`+
+                            `</query>`+
+                        `</iq>`)
+                    expect(view.model.occupants.length).toBe(2);
+
+                    const result = $iq({
+                        "xmlns": "jabber:client",
+                        "type": "result",
+                        "to": "dummy@localhost/resource",
+                        "from": "lounge@muc.localhost",
+                        "id": iq_stanza.getAttribute("id")
+                    }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+                        .c("item", {"jid": "marc", "affiliation": "member"});
+                    _converse.connection._dataRecv(test_utils.createRequest(result));
+
+                    expect(view.model.occupants.length).toBe(2);
+                    return test_utils.waitUntil(() => {
+                        return _.filter(
+                            _converse.connection.IQ_stanzas,
+                            (iq) => {
+                                const node = iq.nodeTree.querySelector('iq[to="lounge@muc.localhost"][type="get"] item[affiliation="owner"]');
+                                if (node) { iq_stanza = iq.nodeTree;}
+                                return node;
+                            }).length;
+                    });
+                }).then(() => {
+                    expect(iq_stanza.outerHTML).toBe(
+                        `<iq to="lounge@muc.localhost" type="get" xmlns="jabber:client" id="${iq_stanza.getAttribute('id')}">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                                `<item affiliation="owner"/>`+
+                            `</query>`+
+                        `</iq>`)
+                    expect(view.model.occupants.length).toBe(2);
+
+                    const result = $iq({
+                        "xmlns": "jabber:client",
+                        "type": "result",
+                        "to": "dummy@localhost/resource",
+                        "from": "lounge@muc.localhost",
+                        "id": iq_stanza.getAttribute("id")
+                    }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+                        .c("item", {"jid": "dummy@localhost", "affiliation": "owner"});
+                    _converse.connection._dataRecv(test_utils.createRequest(result));
+
+                    expect(view.model.occupants.length).toBe(2);
+                    return test_utils.waitUntil(() => {
+                        return _.filter(
+                            _converse.connection.IQ_stanzas,
+                            (iq) => {
+                                const node = iq.nodeTree.querySelector('iq[to="lounge@muc.localhost"][type="get"] item[affiliation="admin"]');
+                                if (node) { iq_stanza = iq.nodeTree;}
+                                return node;
+                            }).length;
+                    });
+                }).then(() => {
+                    expect(iq_stanza.outerHTML).toBe(
+                        `<iq to="lounge@muc.localhost" type="get" xmlns="jabber:client" id="${iq_stanza.getAttribute('id')}">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                                `<item affiliation="admin"/>`+
+                            `</query>`+
+                        `</iq>`)
+                    expect(view.model.occupants.length).toBe(2);
+
+                    const result = $iq({
+                        "xmlns": "jabber:client",
+                        "type": "result",
+                        "to": "dummy@localhost/resource",
+                        "from": "lounge@muc.localhost",
+                        "id": iq_stanza.getAttribute("id")
+                    }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"})
+                    _converse.connection._dataRecv(test_utils.createRequest(result));
+
+                    return test_utils.waitUntil(() => view.el.querySelectorAll('.badge').length > 1);
+                }).then(() => {
+                    expect(view.model.occupants.length).toBe(2);
+                    expect(view.el.querySelectorAll('.occupant').length).toBe(2);
+                    done();
+                }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/topic to set the groupchat topic",
+            it("takes /topic to set the groupchat topic",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2169,7 +2336,7 @@
                 }).catch(_.partial(console.error, _));
                 }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/clear to clear messages",
+            it("takes /clear to clear messages",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2192,7 +2359,7 @@
                 }).catch(_.partial(console.error, _));
                 }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/owner to make a user an owner",
+            it("takes /owner to make a user an owner",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2219,9 +2386,7 @@
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
-                        "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.",
-                        true
-                    );
+                        "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.");
                     expect(view.model.setAffiliation).not.toHaveBeenCalled();
                     expect(view.model.setAffiliation).not.toHaveBeenCalled();
 
 
                     // Call now with the correct amount of arguments.
                     // Call now with the correct amount of arguments.
@@ -2245,7 +2410,7 @@
                 }).catch(_.partial(console.error, _));
                 }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/ban to ban a user",
+            it("takes /ban to ban a user",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2272,9 +2437,7 @@
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
-                        "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.",
-                        true
-                    );
+                        "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.");
                     expect(view.model.setAffiliation).not.toHaveBeenCalled();
                     expect(view.model.setAffiliation).not.toHaveBeenCalled();
                     // Call now with the correct amount of arguments.
                     // Call now with the correct amount of arguments.
                     // XXX: Calling onMessageSubmitted directly, trying
                     // XXX: Calling onMessageSubmitted directly, trying
@@ -2297,7 +2460,7 @@
                 }).catch(_.partial(console.error, _));
                 }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/kick to kick a user",
+            it("takes /kick to kick a user",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2325,9 +2488,7 @@
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
-                        "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.",
-                        true
-                    );
+                        "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.");
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     // Call now with the correct amount of arguments.
                     // Call now with the correct amount of arguments.
                     // XXX: Calling onMessageSubmitted directly, trying
                     // XXX: Calling onMessageSubmitted directly, trying
@@ -2376,7 +2537,7 @@
             }));
             }));
 
 
 
 
-            it("/op and /deop to make a user a moderator or not",
+            it("takes /op and /deop to make a user a moderator or not",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
@@ -2431,9 +2592,7 @@
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
-                        "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.",
-                        true
-                    );
+                        "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.");
 
 
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     // Call now with the correct amount of arguments.
                     // Call now with the correct amount of arguments.
@@ -2516,12 +2675,13 @@
                 }).catch(_.partial(console.error, _));
                 }).catch(_.partial(console.error, _));
             }));
             }));
 
 
-            it("/mute and /voice to mute and unmute a user",
+            it("takes /mute and /voice to mute and unmute a user",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched'], {},
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
                     function (done, _converse) {
 
 
-                test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy').then(function () {
+                test_utils.openAndEnterChatRoom(_converse, 'lounge', 'localhost', 'dummy')
+                .then(() => {
                     var sent_IQ, IQ_id;
                     var sent_IQ, IQ_id;
                     var sendIQ = _converse.connection.sendIQ;
                     var sendIQ = _converse.connection.sendIQ;
                     spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
                     spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
@@ -2571,9 +2731,7 @@
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.onMessageSubmitted).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.validateRoleChangeCommand).toHaveBeenCalled();
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
                     expect(view.showErrorMessage).toHaveBeenCalledWith(
-                        "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.",
-                        true
-                    );
+                        "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.");
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     expect(view.modifyRole).not.toHaveBeenCalled();
                     // Call now with the correct amount of arguments.
                     // Call now with the correct amount of arguments.
                     // XXX: Calling onMessageSubmitted directly, trying
                     // XXX: Calling onMessageSubmitted directly, trying
@@ -2659,6 +2817,19 @@
 
 
         describe("When attempting to enter a groupchat", function () {
         describe("When attempting to enter a groupchat", function () {
 
 
+            it("will use the nickname set in the global settings if the user doesn't have a VCard nickname",
+                mock.initConverseWithPromises(
+                    null, ['rosterGroupsFetched', 'chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'},
+                    function (done, _converse) {
+
+                test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost')
+                .then(function () {
+                    const view = _converse.chatboxviews.get('problematic@muc.localhost');
+                    expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch');
+                    done();
+                }).catch(_.partial(console.error, _));
+            }));
+
             it("will show an error message if the groupchat requires a password",
             it("will show an error message if the groupchat requires a password",
                 mock.initConverseWithPromises(
                 mock.initConverseWithPromises(
                     null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
@@ -3324,30 +3495,9 @@
                             var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
                             var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
                             var $chat_content = $(view.el).find('.chat-content');
                             var $chat_content = $(view.el).find('.chat-content');
 
 
-                            /* <presence to="dummy@localhost/_converse.js-29092160"
-                            *           from="coven@chat.shakespeare.lit/some1">
-                            *      <x xmlns="http://jabber.org/protocol/muc#user">
-                            *          <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
-                            *          <status code="110"/>
-                            *      </x>
-                            *  </presence></body>
-                            */
-                            var presence = $pres({
-                                    to: 'dummy@localhost/_converse.js-29092160',
-                                    from: 'coven@chat.shakespeare.lit/some1'
-                                }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                                .c('item', {
-                                    'affiliation': 'owner',
-                                    'jid': 'dummy@localhost/_converse.js-29092160',
-                                    'role': 'moderator'
-                                }).up()
-                                .c('status', {code: '110'});
-                            _converse.connection._dataRecv(test_utils.createRequest(presence));
-                            expect($chat_content[0].querySelectorAll('div.chat-info').length).toBe(2);
                             expect($chat_content.find('div.chat-info:first').html()).toBe("some1 has entered the groupchat");
                             expect($chat_content.find('div.chat-info:first').html()).toBe("some1 has entered the groupchat");
-                            expect($chat_content.find('div.chat-info:last').html()).toBe("some1 is now a moderator");
 
 
-                            presence = $pres({
+                            let presence = $pres({
                                     to: 'dummy@localhost/_converse.js-29092160',
                                     to: 'dummy@localhost/_converse.js-29092160',
                                     from: 'coven@chat.shakespeare.lit/newguy'
                                     from: 'coven@chat.shakespeare.lit/newguy'
                                 })
                                 })
@@ -3358,7 +3508,7 @@
                                     'role': 'participant'
                                     'role': 'participant'
                                 });
                                 });
                             _converse.connection._dataRecv(test_utils.createRequest(presence));
                             _converse.connection._dataRecv(test_utils.createRequest(presence));
-                            expect($chat_content[0].querySelectorAll('div.chat-info').length).toBe(3);
+                            expect($chat_content[0].querySelectorAll('div.chat-info').length).toBe(2);
                             expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has entered the groupchat");
                             expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has entered the groupchat");
 
 
                             presence = $pres({
                             presence = $pres({
@@ -3372,7 +3522,7 @@
                                     'role': 'participant'
                                     'role': 'participant'
                                 });
                                 });
                             _converse.connection._dataRecv(test_utils.createRequest(presence));
                             _converse.connection._dataRecv(test_utils.createRequest(presence));
-                            expect($chat_content[0].querySelectorAll('div.chat-info').length).toBe(4);
+                            expect($chat_content[0].querySelectorAll('div.chat-info').length).toBe(3);
                             expect($chat_content.find('div.chat-info:last').html()).toBe("nomorenicks has entered the groupchat");
                             expect($chat_content.find('div.chat-info:last').html()).toBe("nomorenicks has entered the groupchat");
 
 
                             // See XEP-0085 http://xmpp.org/extensions/xep-0085.html#definitions
                             // See XEP-0085 http://xmpp.org/extensions/xep-0085.html#definitions
@@ -3389,11 +3539,10 @@
 
 
                             // Check that the notification appears inside the chatbox in the DOM
                             // Check that the notification appears inside the chatbox in the DOM
                             var events = view.el.querySelectorAll('.chat-event');
                             var events = view.el.querySelectorAll('.chat-event');
-                            expect(events.length).toBe(4);
+                            expect(events.length).toBe(3);
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
-                            expect(events[1].textContent).toEqual('some1 is now a moderator');
-                            expect(events[2].textContent).toEqual('newguy has entered the groupchat');
-                            expect(events[3].textContent).toEqual('nomorenicks has entered the groupchat');
+                            expect(events[1].textContent).toEqual('newguy has entered the groupchat');
+                            expect(events[2].textContent).toEqual('nomorenicks has entered the groupchat');
 
 
                             var notifications = view.el.querySelectorAll('.chat-state-notification');
                             var notifications = view.el.querySelectorAll('.chat-state-notification');
                             expect(notifications.length).toBe(1);
                             expect(notifications.length).toBe(1);
@@ -3414,11 +3563,10 @@
                             view.model.onMessage(msg);
                             view.model.onMessage(msg);
 
 
                             events = view.el.querySelectorAll('.chat-event');
                             events = view.el.querySelectorAll('.chat-event');
-                            expect(events.length).toBe(4);
+                            expect(events.length).toBe(3);
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
-                            expect(events[1].textContent).toEqual('some1 is now a moderator');
-                            expect(events[2].textContent).toEqual('newguy has entered the groupchat');
-                            expect(events[3].textContent).toEqual('nomorenicks has entered the groupchat');
+                            expect(events[1].textContent).toEqual('newguy has entered the groupchat');
+                            expect(events[2].textContent).toEqual('nomorenicks has entered the groupchat');
 
 
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             expect(notifications.length).toBe(1);
                             expect(notifications.length).toBe(1);
@@ -3435,11 +3583,10 @@
                                 }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                                 }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                             view.model.onMessage(msg);
                             view.model.onMessage(msg);
                             events = view.el.querySelectorAll('.chat-event');
                             events = view.el.querySelectorAll('.chat-event');
-                            expect(events.length).toBe(4);
+                            expect(events.length).toBe(3);
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
-                            expect(events[1].textContent).toEqual('some1 is now a moderator');
-                            expect(events[2].textContent).toEqual('newguy has entered the groupchat');
-                            expect(events[3].textContent).toEqual('nomorenicks has entered the groupchat');
+                            expect(events[1].textContent).toEqual('newguy has entered the groupchat');
+                            expect(events[2].textContent).toEqual('nomorenicks has entered the groupchat');
 
 
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             expect(notifications.length).toBe(2);
                             expect(notifications.length).toBe(2);
@@ -3458,7 +3605,7 @@
                             view.model.onMessage(msg);
                             view.model.onMessage(msg);
 
 
                             var messages = view.el.querySelectorAll('.message');
                             var messages = view.el.querySelectorAll('.message');
-                            expect(messages.length).toBe(8);
+                            expect(messages.length).toBe(7);
                             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
                             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
                             expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent).toBe('hello world');
                             expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent).toBe('hello world');
 
 
@@ -3466,11 +3613,10 @@
                             // via timeout.
                             // via timeout.
                             timeout_functions[0]();
                             timeout_functions[0]();
                             events = view.el.querySelectorAll('.chat-event');
                             events = view.el.querySelectorAll('.chat-event');
-                            expect(events.length).toBe(4);
+                            expect(events.length).toBe(3);
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
-                            expect(events[1].textContent).toEqual('some1 is now a moderator');
-                            expect(events[2].textContent).toEqual('newguy has entered the groupchat');
-                            expect(events[3].textContent).toEqual('nomorenicks has entered the groupchat');
+                            expect(events[1].textContent).toEqual('newguy has entered the groupchat');
+                            expect(events[2].textContent).toEqual('nomorenicks has entered the groupchat');
 
 
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             expect(notifications.length).toBe(1);
                             expect(notifications.length).toBe(1);
@@ -3478,11 +3624,10 @@
 
 
                             timeout_functions[1]();
                             timeout_functions[1]();
                             events = view.el.querySelectorAll('.chat-event');
                             events = view.el.querySelectorAll('.chat-event');
-                            expect(events.length).toBe(4);
+                            expect(events.length).toBe(3);
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
                             expect(events[0].textContent).toEqual('some1 has entered the groupchat');
-                            expect(events[1].textContent).toEqual('some1 is now a moderator');
-                            expect(events[2].textContent).toEqual('newguy has entered the groupchat');
-                            expect(events[3].textContent).toEqual('nomorenicks has entered the groupchat');
+                            expect(events[1].textContent).toEqual('newguy has entered the groupchat');
+                            expect(events[2].textContent).toEqual('nomorenicks has entered the groupchat');
 
 
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             notifications = view.el.querySelectorAll('.chat-state-notification');
                             expect(notifications.length).toBe(0);
                             expect(notifications.length).toBe(0);

File diff suppressed because it is too large
+ 939 - 934
spec/messages.js


+ 4 - 3
spec/notification.js

@@ -2,8 +2,9 @@
     define(["jquery", "jasmine", "mock", "test-utils"], factory);
     define(["jquery", "jasmine", "mock", "test-utils"], factory);
 } (this, function ($, jasmine, mock, test_utils) {
 } (this, function ($, jasmine, mock, test_utils) {
     "use strict";
     "use strict";
-    var _ = converse.env._;
-    var $msg = converse.env.$msg;
+    const Strophe = converse.env.Strophe,
+          _ = converse.env._,
+          $msg = converse.env.$msg;
 
 
     describe("Notifications", function () {
     describe("Notifications", function () {
         // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
         // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
@@ -74,7 +75,7 @@
                                 delete window.Notification;
                                 delete window.Notification;
                             }
                             }
                             done();
                             done();
-                        });
+                        }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
                     }));
                     }));
 
 
                     it("is shown for headline messages",
                     it("is shown for headline messages",

+ 53 - 58
spec/roomslist.js

@@ -224,69 +224,64 @@
         }));
         }));
 
 
         it("shows unread messages directed at the user", mock.initConverseWithAsync(
         it("shows unread messages directed at the user", mock.initConverseWithAsync(
-            { whitelisted_plugins: ['converse-roomslist'],
-              allow_bookmarks: false // Makes testing easier, otherwise we
-                                     // have to mock stanza traffic.
-            }, function (done, _converse) {
+                { whitelisted_plugins: ['converse-roomslist'],
+                  allow_bookmarks: false // Makes testing easier, otherwise we
+                                         // have to mock stanza traffic.
+                }, function (done, _converse) {
 
 
-            test_utils.waitUntil(function () {
-                    return !_.isUndefined(_converse.rooms_list_view)
-                }, 500)
-            .then(function () {
-                var room_jid = 'kitchen@conference.shakespeare.lit';
-                test_utils.openAndEnterChatRoom(
-                    _converse, 'kitchen', 'conference.shakespeare.lit', 'romeo').then(function () {
-
-                    var view = _converse.chatboxviews.get(room_jid);
-                    view.model.set({'minimized': true});
-                    var contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    var nick = mock.chatroom_names[0];
-                    view.model.onMessage(
-                        $msg({
-                            from: room_jid+'/'+nick,
-                            id: (new Date()).getTime(),
-                            to: 'dummy@localhost',
-                            type: 'groupchat'
-                        }).c('body').t('foo').tree());
+            test_utils.waitUntil(() => !_.isUndefined(_converse.rooms_list_view), 500)
+            .then(() => test_utils.openAndEnterChatRoom(_converse, 'kitchen', 'conference.shakespeare.lit', 'romeo'))
+            .then(() => {
+                const room_jid = 'kitchen@conference.shakespeare.lit';
+                const view = _converse.chatboxviews.get(room_jid);
+                view.model.set({'minimized': true});
+                const contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@localhost';
+                const nick = mock.chatroom_names[0];
+                view.model.onMessage(
+                    $msg({
+                        from: room_jid+'/'+nick,
+                        id: (new Date()).getTime(),
+                        to: 'dummy@localhost',
+                        type: 'groupchat'
+                    }).c('body').t('foo').tree());
 
 
-                    // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold
-                    var room_el = _converse.rooms_list_view.el.querySelector(
-                        ".available-chatroom"
-                    );
-                    expect(_.includes(room_el.classList, 'unread-msgs'));
+                // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold
+                var room_el = _converse.rooms_list_view.el.querySelector(
+                    ".available-chatroom"
+                );
+                expect(_.includes(room_el.classList, 'unread-msgs'));
 
 
-                    // If the user is mentioned, the counter also gets updated
-                    view.model.onMessage(
-                        $msg({
-                            from: room_jid+'/'+nick,
-                            id: (new Date()).getTime(),
-                            to: 'dummy@localhost',
-                            type: 'groupchat'
-                        }).c('body').t('romeo: Your attention is required').tree()
-                    );
-                    var indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
-                    expect(indicator_el.textContent).toBe('1');
+                // If the user is mentioned, the counter also gets updated
+                view.model.onMessage(
+                    $msg({
+                        from: room_jid+'/'+nick,
+                        id: (new Date()).getTime(),
+                        to: 'dummy@localhost',
+                        type: 'groupchat'
+                    }).c('body').t('romeo: Your attention is required').tree()
+                );
+                var indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
+                expect(indicator_el.textContent).toBe('1');
 
 
-                    view.model.onMessage(
-                        $msg({
-                            from: room_jid+'/'+nick,
-                            id: (new Date()).getTime(),
-                            to: 'dummy@localhost',
-                            type: 'groupchat'
-                        }).c('body').t('romeo: and another thing...').tree()
-                    );
-                    indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
-                    expect(indicator_el.textContent).toBe('2');
+                view.model.onMessage(
+                    $msg({
+                        from: room_jid+'/'+nick,
+                        id: (new Date()).getTime(),
+                        to: 'dummy@localhost',
+                        type: 'groupchat'
+                    }).c('body').t('romeo: and another thing...').tree()
+                );
+                indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
+                expect(indicator_el.textContent).toBe('2');
 
 
-                    // When the chat gets maximized again, the unread indicators are removed
-                    view.model.set({'minimized': false});
-                    indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
-                    expect(_.isNull(indicator_el));
-                    room_el = _converse.rooms_list_view.el.querySelector(".available-chatroom");
-                    expect(_.includes(room_el.classList, 'unread-msgs')).toBeFalsy();
-                    done();
-                });
-            });
+                // When the chat gets maximized again, the unread indicators are removed
+                view.model.set({'minimized': false});
+                indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
+                expect(_.isNull(indicator_el));
+                room_el = _converse.rooms_list_view.el.querySelector(".available-chatroom");
+                expect(_.includes(room_el.classList, 'unread-msgs')).toBeFalsy();
+                done();
+            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
         }));
         }));
     });
     });
 }));
 }));

+ 415 - 0
src/converse-autocomplete.js

@@ -0,0 +1,415 @@
+// 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(helpers.regExpEscape(input.trim()), "i").test(text);
+            };
+
+            _converse.FILTER_STARTSWITH = function (text, input) {
+                return RegExp("^" + helpers.regExpEscape(input.trim()), "i").test(text);
+            };
+
+            const SORT_BYLENGTH = function (a, b) {
+                if (a.length !== b.length) {
+                    return a.length - b.length;
+                }
+                return a < b? -1 : 1;
+            };
+
+            const ITEM = (text, input) => {
+                input = input.trim();
+                const element = document.createElement("li");
+                element.setAttribute("aria-selected", "false");
+
+                const regex = new RegExp("("+input+")", "ig");
+                const parts = input ? text.split(regex) : [text];
+                parts.forEach((txt) => {
+                    if (input && txt.match(regex)) {
+                        const match = document.createElement("mark");
+                        match.textContent = txt;
+                        element.appendChild(match);
+                    } else {
+                        element.appendChild(document.createTextNode(txt));
+                    }
+                });
+                return element;
+            };
+
+
+            class AutoComplete {
+
+                constructor (el, config={}) {
+                    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');
+
+                    _.assignIn(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
+                        'trigger_on_at': false, // Whether @ should trigger autocomplete
+                        'min_chars': 2,
+                        'max_items': 10,
+                        'auto_evaluate': true,
+                        'auto_first': false,
+                        'data': _.identity,
+                        'filter': _converse.FILTER_CONTAINS,
+                        'sort': config.sort === false ? false : SORT_BYLENGTH,
+                        'item': ITEM
+                    }, config);
+
+                    this.index = -1;
+
+                    this.bindEvents()
+
+                    if (this.input.hasAttribute("list")) {
+                        this.list = "#" + this.input.getAttribute("list");
+                        this.input.removeAttribute("list");
+                    } else {
+                        this.list = this.input.getAttribute("data-list") || config.list || [];
+                    }
+                }
+
+                bindEvents () {
+                    // Bind events
+                    const input = {
+                        "blur": () => this.close({'reason': 'blur'})
+                    }
+                    if (this.auto_evaluate) {
+                        input["input"] = () => this.evaluate();
+                    }
+
+                    this._events = {
+                        'input': input,
+                        'form': {
+                            "submit": () => this.close({'reason': 'submit'})
+                        },
+                        'ul': {
+                            "mousedown": (ev) => this.onMouseDown(ev),
+                            "mouseover": (ev) => this.onMouseOver(ev)
+                        }
+                    };
+                    helpers.bind(this.input, this._events.input);
+                    helpers.bind(this.input.form, this._events.form);
+                    helpers.bind(this.ul, this._events.ul);
+                }
+
+                set list (list) {
+                    if (Array.isArray(list) || typeof list === "function") {
+                        this._list = list;
+                    } else if (typeof list === "string" && _.includes(list, ",")) {
+                        this._list = list.split(/\s*,\s*/);
+                    } else { // Element or CSS selector
+                        list = helpers.getElement(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;
+                    this.trigger("suggestion-box-close", o || {});
+                }
+
+                insertValue (suggestion) {
+                    let value;
+                    if (this.match_current_word) {
+                        u.replaceCurrentWord(this.input, suggestion.value);
+                    } else {
+                        this.input.value = suggestion.value;
+                    }
+                }
+
+                open () {
+                    this.ul.removeAttribute("hidden");
+                    this.is_opened = true;
+
+                    if (this.auto_first && this.index === -1) {
+                        this.goto(0);
+                    }
+                    this.trigger("suggestion-box-open");
+                }
+
+                destroy () {
+                    //remove events from the input and its form
+                    helpers.unbind(this.input, this._events.input);
+                    helpers.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");
+                }
+
+                next () {
+                    const count = this.ul.children.length;
+                    this.goto(this.index < count - 1 ? this.index + 1 : (count ? 0 : -1) );
+                }
+
+                previous () {
+                    const count = this.ul.children.length,
+                          pos = this.index - 1;
+                    this.goto(this.selected && pos !== -1 ? pos : count - 1);
+                }
+
+                goto (i) {
+                    // Should not be used directly, highlights specific item without any checks!
+                    const list = this.ul.children;
+                    if (this.selected) {
+                        list[this.index].setAttribute("aria-selected", "false");
+                    }
+                    this.index = i;
+
+                    if (i > -1 && list.length > 0) {
+                        list[i].setAttribute("aria-selected", "true");
+                        list[i].focus();
+                        this.status.textContent = list[i].textContent;
+                        // scroll to highlighted element in case parent's height is fixed
+                        this.ul.scrollTop = list[i].offsetTop - this.ul.clientHeight + list[i].clientHeight;
+                        this.trigger("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];
+                        this.insertValue(suggestion);
+                        this.close({'reason': 'select'});
+                        this.auto_completing = false;
+                        this.trigger("suggestion-box-selectcomplete", {'text': suggestion});
+                    }
+                }
+
+                onMouseOver (ev) {
+                    const li = u.ancestor(ev.target, 'li');
+                    if (li) {
+                        this.goto(Array.prototype.slice.call(this.ul.children).indexOf(li))
+                    }
+                }
+
+                onMouseDown (ev) {
+                    if (ev.button !== 0) {
+                        return; // Only select on left click
+                    }
+                    const li = u.ancestor(ev.target, 'li');
+                    if (li) {
+                        ev.preventDefault();
+                        this.select(li, ev.target);
+                    }
+                }
+
+                keyPressed (ev) {
+                    if (this.opened) {
+                        if (_.includes([_converse.keycodes.ENTER, _converse.keycodes.TAB], ev.keyCode) && this.selected) {
+                            ev.preventDefault();
+                            ev.stopPropagation();
+                            this.select();
+                            return true;
+                        } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                            this.close({'reason': 'esc'});
+                            return true;
+                        } else if (_.includes([_converse.keycodes.UP_ARROW, _converse.keycodes.DOWN_ARROW], ev.keyCode)) {
+                            ev.preventDefault();
+                            ev.stopPropagation();
+                            this[ev.keyCode === _converse.keycodes.UP_ARROW ? "previous" : "next"]();
+                            return true;
+                        }
+                    }
+
+                    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;
+                    } else if (this.trigger_on_at && ev.keyCode === _converse.keycodes.AT) {
+                        this.auto_completing = true;
+                    }
+                }
+
+                evaluate (ev) {
+                    const arrow_pressed = (
+                        ev.keyCode === _converse.keycodes.UP_ARROW ||
+                        ev.keyCode === _converse.keycodes.DOWN_ARROW
+                    );
+                    if (!this.auto_completing || (this.selected && arrow_pressed)) {
+                        return;
+                    }
+
+                    const list = typeof this._list === "function" ? this._list() : this._list;
+                    if (list.length === 0) {
+                        return;
+                    }
+
+                    let value = this.match_current_word ? u.getCurrentWord(this.input) : this.input.value;
+
+                    let ignore_min_chars = false;
+                    if (this.trigger_on_at && value.startsWith('@')) {
+                        ignore_min_chars = true;
+                        value = value.slice('1');
+                    }
+
+                    if ((value.length >= this.min_chars) || ignore_min_chars) {
+                        this.index = -1;
+                        // Populate list with options that match
+                        this.ul.innerHTML = "";
+
+                        this.suggestions = 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(AutoComplete.prototype, Backbone.Events);
+
+
+            // 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;
+            };
+
+            // Helpers
+            var slice = Array.prototype.slice;
+
+            const helpers = {
+
+                getElement (expr, el) {
+                    return typeof expr === "string"? (el || document).querySelector(expr) : expr || null;
+                },
+
+                bind (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 (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));
+                        }
+                    }
+                },
+
+                regExpEscape (s) {
+                    return s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&");
+                }
+            }
+
+            _converse.AutoComplete = AutoComplete;
+        }
+    });
+}));

+ 53 - 17
src/converse-chatboxes.js

@@ -20,6 +20,7 @@
     const u = converse.env.utils;
     const u = converse.env.utils;
 
 
     Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
     Strophe.addNamespace('MESSAGE_CORRECT', 'urn:xmpp:message-correct:0');
+    Strophe.addNamespace('REFERENCE', 'urn:xmpp:reference:0');
 
 
 
 
     converse.plugins.add('converse-chatboxes', {
     converse.plugins.add('converse-chatboxes', {
@@ -225,7 +226,7 @@
                         });
                         });
                     };
                     };
                     xhr.open('PUT', this.get('put'), true);
                     xhr.open('PUT', this.get('put'), true);
-                    xhr.setRequestHeader("Content-type", 'application/octet-stream');
+                    xhr.setRequestHeader("Content-type", this.get('file').type);
                     xhr.send(this.get('file'));
                     xhr.send(this.get('file'));
                 }
                 }
             });
             });
@@ -298,6 +299,7 @@
                         older_versions.push(message.get('message'));
                         older_versions.push(message.get('message'));
                         message.save({
                         message.save({
                             'message': _converse.chatboxes.getMessageBody(stanza),
                             'message': _converse.chatboxes.getMessageBody(stanza),
+                            'references': this.getReferencesFromStanza(stanza),
                             'older_versions': older_versions,
                             'older_versions': older_versions,
                             'edited': true
                             'edited': true
                         });
                         });
@@ -323,11 +325,23 @@
 
 
                     if (message.get('is_spoiler')) {
                     if (message.get('is_spoiler')) {
                         if (message.get('spoiler_hint')) {
                         if (message.get('spoiler_hint')) {
-                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER }, message.get('spoiler_hint')).up();
+                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).up();
                         } else {
                         } else {
-                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER }).up();
+                            stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).up();
                         }
                         }
                     }
                     }
+                    (message.get('references') || []).forEach(reference => {
+                        const attrs = {
+                            'xmlns': Strophe.NS.REFERENCE,
+                            'begin': reference.begin,
+                            'end': reference.end,
+                            'type': reference.type,
+                        }
+                        if (reference.uri) {
+                            attrs.uri = reference.uri;
+                        }
+                        stanza.c('reference', attrs).up();
+                    });
                     if (message.get('file')) {
                     if (message.get('file')) {
                         stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
                         stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('message')).up();
                     }
                     }
@@ -384,10 +398,11 @@
                         const older_versions = message.get('older_versions') || [];
                         const older_versions = message.get('older_versions') || [];
                         older_versions.push(message.get('message'));
                         older_versions.push(message.get('message'));
                         message.save({
                         message.save({
+                            'correcting': false,
+                            'edited': true,
                             'message': attrs.message,
                             'message': attrs.message,
                             'older_versions': older_versions,
                             'older_versions': older_versions,
-                            'edited': true,
-                            'correcting': false
+                            'references': attrs.references
                         });
                         });
                     } else {
                     } else {
                         message = this.messages.create(attrs);
                         message = this.messages.create(attrs);
@@ -444,6 +459,21 @@
                     }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
                     }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
                 },
                 },
 
 
+                getReferencesFromStanza (stanza) {
+                    const text = _.propertyOf(stanza.querySelector('body'))('textContent');
+                    return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
+                        const begin = ref.getAttribute('begin'),
+                              end = ref.getAttribute('end');
+                        return  {
+                            'begin': begin,
+                            'end': end,
+                            'type': ref.getAttribute('type'),
+                            'value': text.slice(begin, end),
+                            'uri': ref.getAttribute('uri')
+                        };
+                    });
+                },
+
                 getMessageAttributesFromStanza (stanza, original_stanza) {
                 getMessageAttributesFromStanza (stanza, original_stanza) {
                     /* Parses a passed in message stanza and returns an object
                     /* Parses a passed in message stanza and returns an object
                      * of attributes.
                      * of attributes.
@@ -467,12 +497,15 @@
                                 stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
                                 stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
                                 stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
                                 stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
 
 
+
+
                     const attrs = {
                     const attrs = {
                         'chat_state': chat_state,
                         'chat_state': chat_state,
                         'is_archived': !_.isNil(archive),
                         'is_archived': !_.isNil(archive),
                         'is_delayed': !_.isNil(delay),
                         'is_delayed': !_.isNil(delay),
                         'is_spoiler': !_.isNil(spoiler),
                         'is_spoiler': !_.isNil(spoiler),
                         'message': _converse.chatboxes.getMessageBody(stanza) || undefined,
                         'message': _converse.chatboxes.getMessageBody(stanza) || undefined,
+                        'references': this.getReferencesFromStanza(stanza),
                         'msgid': stanza.getAttribute('id'),
                         'msgid': stanza.getAttribute('id'),
                         'time': delay ? delay.getAttribute('stamp') : moment().format(),
                         'time': delay ? delay.getAttribute('stamp') : moment().format(),
                         'type': stanza.getAttribute('type')
                         'type': stanza.getAttribute('type')
@@ -533,14 +566,13 @@
                         _converse.windowState === 'hidden';
                         _converse.windowState === 'hidden';
                 },
                 },
 
 
-                incrementUnreadMsgCounter (stanza) {
+                incrementUnreadMsgCounter (message) {
                     /* Given a newly received message, update the unread counter if
                     /* Given a newly received message, update the unread counter if
                      * necessary.
                      * necessary.
                      */
                      */
-                    if (_.isNull(stanza.querySelector('body'))) {
-                        return; // The message has no text
-                    }
-                    if (utils.isNewMessage(stanza) && this.isHidden()) {
+                    if (!message) { return; }
+                    if (_.isNil(message.get('message'))) { return; }
+                    if (utils.isNewMessage(message) && this.isHidden()) {
                         this.save({'num_unread': this.get('num_unread') + 1});
                         this.save({'num_unread': this.get('num_unread') + 1});
                         _converse.incrementMsgCounter();
                         _converse.incrementMsgCounter();
                     }
                     }
@@ -633,8 +665,7 @@
                      * Parameters:
                      * Parameters:
                      *    (XMLElement) stanza - The incoming message stanza
                      *    (XMLElement) stanza - The incoming message stanza
                      */
                      */
-                    let from_jid = stanza.getAttribute('from'),
-                        to_jid = stanza.getAttribute('to');
+                    let to_jid = stanza.getAttribute('to');
                     const to_resource = Strophe.getResourceFromJid(to_jid);
                     const to_resource = Strophe.getResourceFromJid(to_jid);
 
 
                     if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
                     if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
@@ -648,12 +679,13 @@
                         // messages, but Prosody sends headline messages with the
                         // messages, but Prosody sends headline messages with the
                         // wrong type ('chat'), so we need to filter them out here.
                         // wrong type ('chat'), so we need to filter them out here.
                         _converse.log(
                         _converse.log(
-                            `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${from_jid}`,
+                            `onMessage: Ignoring incoming headline message sent with type 'chat' from JID: ${stanza.getAttribute('from')}`,
                             Strophe.LogLevel.INFO
                             Strophe.LogLevel.INFO
                         );
                         );
                         return true;
                         return true;
                     }
                     }
 
 
+                    let from_jid = stanza.getAttribute('from');
                     const forwarded = stanza.querySelector('forwarded'),
                     const forwarded = stanza.querySelector('forwarded'),
                           original_stanza = stanza;
                           original_stanza = stanza;
 
 
@@ -679,6 +711,12 @@
                     let contact_jid;
                     let contact_jid;
                     if (is_me) {
                     if (is_me) {
                         // I am the sender, so this must be a forwarded message...
                         // I am the sender, so this must be a forwarded message...
+                        if (_.isNull(to_jid)) {
+                            return _converse.log(
+                                `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
+                                Strophe.LogLevel.ERROR
+                            );
+                        }
                         contact_jid = Strophe.getBareJidFromJid(to_jid);
                         contact_jid = Strophe.getBareJidFromJid(to_jid);
                     } else {
                     } else {
                         contact_jid = from_bare_jid;
                         contact_jid = from_bare_jid;
@@ -691,10 +729,8 @@
                     if (chatbox && !chatbox.handleMessageCorrection(stanza)) {
                     if (chatbox && !chatbox.handleMessageCorrection(stanza)) {
                         const msgid = stanza.getAttribute('id'),
                         const msgid = stanza.getAttribute('id'),
                               message = msgid && chatbox.messages.findWhere({msgid});
                               message = msgid && chatbox.messages.findWhere({msgid});
-                        if (!message) {
-                            // Only create the message when we're sure it's not a duplicate
-                            chatbox.incrementUnreadMsgCounter(original_stanza);
-                            chatbox.createMessage(stanza, original_stanza);
+                        if (!message) { // Only create the message when we're sure it's not a duplicate
+                            chatbox.incrementUnreadMsgCounter(chatbox.createMessage(stanza, original_stanza));
                         }
                         }
                     }
                     }
                     _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});
                     _converse.emit('message', {'stanza': original_stanza, 'chatbox': chatbox});

+ 58 - 25
src/converse-chatview.js

@@ -50,12 +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,
-        UP_ARROW: 38,
-        DOWN_ARROW: 40,
-        FORWARD_SLASH: 47
-    };
 
 
     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
@@ -396,13 +390,13 @@
                     if (this.model.get('composing_spoiler')) {
                     if (this.model.get('composing_spoiler')) {
                         placeholder = __('Hidden message');
                         placeholder = __('Hidden message');
                     } else {
                     } else {
-                        placeholder = __('Personal message');
+                        placeholder = __('Message');
                     }
                     }
                     const form_container = this.el.querySelector('.message-form-container');
                     const form_container = this.el.querySelector('.message-form-container');
                     form_container.innerHTML = tpl_chatbox_message_form(
                     form_container.innerHTML = tpl_chatbox_message_form(
                         _.extend(this.model.toJSON(), {
                         _.extend(this.model.toJSON(), {
                             'hint_value': _.get(this.el.querySelector('.spoiler-hint'), 'value'),
                             'hint_value': _.get(this.el.querySelector('.spoiler-hint'), 'value'),
-                            'label_personal_message': placeholder,
+                            'label_message': placeholder,
                             'label_send': __('Send'),
                             'label_send': __('Send'),
                             'label_spoiler_hint': __('Optional hint'),
                             'label_spoiler_hint': __('Optional hint'),
                             'message_value': _.get(this.el.querySelector('.chat-textarea'), 'value'),
                             'message_value': _.get(this.el.querySelector('.chat-textarea'), 'value'),
@@ -801,7 +795,7 @@
                      */
                      */
                     this.showMessage(message);
                     this.showMessage(message);
                     if (message.get('correcting')) {
                     if (message.get('correcting')) {
-                        this.insertIntoTextArea(message.get('message'), true);
+                        this.insertIntoTextArea(message.get('message'), true, true);
                     }
                     }
                     _converse.emit('messageAdded', {
                     _converse.emit('messageAdded', {
                         'message': message,
                         'message': message,
@@ -898,6 +892,7 @@
                         hint_el.value = '';
                         hint_el.value = '';
                     }
                     }
                     textarea.value = '';
                     textarea.value = '';
+                    u.removeClass('correcting', textarea);
                     textarea.focus();
                     textarea.focus();
                     // Trigger input event, so that the textarea resizes
                     // Trigger input event, so that the textarea resizes
                     const event = document.createEvent('Event');
                     const event = document.createEvent('Event');
@@ -912,15 +907,34 @@
                 keyPressed (ev) {
                 keyPressed (ev) {
                     /* Event handler for when a key is pressed in a chat box textarea.
                     /* Event handler for when a key is pressed in a chat box textarea.
                      */
                      */
-                    if (ev.shiftKey) { return; }
-
-                    if (ev.keyCode === KEY.ENTER) {
-                        this.onFormSubmitted(ev);
-                    } else if (ev.keyCode === KEY.UP_ARROW && !ev.target.selectionEnd) {
-                        this.editEarlierMessage();
-                    } else if (ev.keyCode === KEY.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
-                        this.editLaterMessage();
-                    } else if (ev.keyCode !== KEY.FORWARD_SLASH && this.model.get('chat_state') !== _converse.COMPOSING) {
+                    if (ev.ctrlKey) {
+                        // When ctrl is pressed, no chars are entered into the textarea.
+                        return;
+                    }
+                    if (!ev.shiftKey && !ev.altKey) {
+                        if (ev.keyCode === _converse.keycodes.FORWARD_SLASH) {
+                            // Forward slash is used to run commands. Nothing to do here.
+                            return;
+                        } else if (ev.keyCode === _converse.keycodes.ESCAPE) {
+                            return this.onEscapePressed(ev);
+                        } else if (ev.keyCode === _converse.keycodes.ENTER) {
+                            return this.onFormSubmitted(ev);
+                        } else if (ev.keyCode === _converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
+                            return this.editEarlierMessage();
+                        } else if (ev.keyCode === _converse.keycodes.DOWN_ARROW && ev.target.selectionEnd === ev.target.value.length) {
+                            return this.editLaterMessage();
+                        }
+                    } 
+                    if (_.includes([
+                                _converse.keycodes.SHIFT,
+                                _converse.keycodes.META,
+                                _converse.keycodes.META_RIGHT,
+                                _converse.keycodes.ESCAPE,
+                                _converse.keycodes.ALT]
+                            , ev.keyCode)) {
+                        return;
+                    }
+                    if (this.model.get('chat_state') !== _converse.COMPOSING) {
                         // Set chat state to composing if keyCode is not a forward-slash
                         // Set chat state to composing if keyCode is not a forward-slash
                         // (which would imply an internal command and not a message).
                         // (which would imply an internal command and not a message).
                         this.setChatState(_converse.COMPOSING);
                         this.setChatState(_converse.COMPOSING);
@@ -931,7 +945,19 @@
                     return f(this.model.messages.filter({'sender': 'me'}));
                     return f(this.model.messages.filter({'sender': 'me'}));
                 },
                 },
 
 
+                onEscapePressed (ev) {
+                    ev.preventDefault();
+                    const idx = this.model.messages.findLastIndex('correcting'),
+                          message = idx >=0 ? this.model.messages.at(idx) : null;
+
+                    if (message) {
+                        message.save('correcting', false);
+                    }
+                    this.insertIntoTextArea('', true, false);
+                },
+
                 onMessageEditButtonClicked (ev) {
                 onMessageEditButtonClicked (ev) {
+                    ev.preventDefault();
                     const idx = this.model.messages.findLastIndex('correcting'),
                     const idx = this.model.messages.findLastIndex('correcting'),
                           currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
                           currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
                           message_el = u.ancestor(ev.target, '.chat-msg'),
                           message_el = u.ancestor(ev.target, '.chat-msg'),
@@ -942,10 +968,10 @@
                             currently_correcting.save('correcting', false);
                             currently_correcting.save('correcting', false);
                         }
                         }
                         message.save('correcting', true);
                         message.save('correcting', true);
-                        this.insertIntoTextArea(message.get('message'), true);
+                        this.insertIntoTextArea(u.prefixMentions(message), true, true);
                     } else {
                     } else {
                         message.save('correcting', false);
                         message.save('correcting', false);
-                        this.insertIntoTextArea('', true);
+                        this.insertIntoTextArea('', true, false);
                     }
                     }
                 },
                 },
 
 
@@ -964,10 +990,10 @@
                         }
                         }
                     }
                     }
                     if (message) {
                     if (message) {
-                        this.insertIntoTextArea(message.get('message'), true);
+                        this.insertIntoTextArea(message.get('message'), true, true);
                         message.save('correcting', true);
                         message.save('correcting', true);
                     } else {
                     } else {
-                        this.insertIntoTextArea('', true);
+                        this.insertIntoTextArea('', true, false);
                     }
                     }
                 },
                 },
 
 
@@ -987,7 +1013,7 @@
                     }
                     }
                     message = message || this.getOwnMessages().findLast((msg) => msg.get('message'));
                     message = message || this.getOwnMessages().findLast((msg) => msg.get('message'));
                     if (message) {
                     if (message) {
-                        this.insertIntoTextArea(message.get('message'), true);
+                        this.insertIntoTextArea(message.get('message'), true, true);
                         message.save('correcting', true);
                         message.save('correcting', true);
                     }
                     }
                 },
                 },
@@ -1008,18 +1034,25 @@
                     return this;
                     return this;
                 },
                 },
 
 
-                insertIntoTextArea (value, replace=false) {
+                insertIntoTextArea (value, replace=false, correcting=false) {
                     const textarea = this.el.querySelector('.chat-textarea');
                     const textarea = this.el.querySelector('.chat-textarea');
+                    if (correcting) {
+                        u.addClass('correcting', textarea);
+                    } else {
+                        u.removeClass('correcting', textarea);
+                    }
                     if (replace) {
                     if (replace) {
+                        textarea.value = '';
                         textarea.value = value;
                         textarea.value = value;
                     } else {
                     } else {
                         let existing = textarea.value;
                         let existing = textarea.value;
                         if (existing && (existing[existing.length-1] !== ' ')) {
                         if (existing && (existing[existing.length-1] !== ' ')) {
                             existing = existing + ' ';
                             existing = existing + ' ';
                         }
                         }
+                        textarea.value = '';
                         textarea.value = existing+value+' ';
                         textarea.value = existing+value+' ';
                     }
                     }
-                    textarea.focus()
+                    u.putCurserAtEnd(textarea);
                 },
                 },
 
 
                 createEmojiPicker () {
                 createEmojiPicker () {

+ 19 - 2
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',
@@ -107,6 +108,22 @@
     // 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,
+        AT: 50,
+        META: 91,
+        META_RIGHT: 93
+    };
+
+
     // Module-level constants
     // Module-level constants
     _converse.STATUS_WEIGHTS = {
     _converse.STATUS_WEIGHTS = {
         'offline':      6,
         'offline':      6,
@@ -813,7 +830,7 @@
             defaults () {
             defaults () {
                 return {
                 return {
                     "jid": _converse.bare_jid,
                     "jid": _converse.bare_jid,
-                    "status":  _converse.default_state,
+                    "status":  _converse.default_state
                 }
                 }
             },
             },
 
 
@@ -1173,7 +1190,7 @@
                 _converse.locale,
                 _converse.locale,
                 _converse.locales,
                 _converse.locales,
                 u.interpolate(_converse.locales_url, {'locale': _converse.locale}))
                 u.interpolate(_converse.locales_url, {'locale': _converse.locale}))
-            .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL))
+            .catch(e => _converse.log(e.message, Strophe.LogLevel.FATAL))
             .then(finishInitialization)
             .then(finishInitialization)
             .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
             .catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
         }
         }

+ 1 - 1
src/converse-mam.js

@@ -324,7 +324,7 @@
                 message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
                 message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
             });
             });
 
 
-            _converse.onMAMError = function (iq) {
+            _converse.onMAMError = function (model, iq) {
                 if (iq.querySelectorAll('feature-not-implemented').length) {
                 if (iq.querySelectorAll('feature-not-implemented').length) {
                     _converse.log(
                     _converse.log(
                         "Message Archive Management (XEP-0313) not supported by this server",
                         "Message Archive Management (XEP-0313) not supported by this server",

+ 2 - 1
src/converse-message-view.js

@@ -168,6 +168,7 @@
                         text = xss.filterXSS(text, {'whiteList': {}});
                         text = xss.filterXSS(text, {'whiteList': {}});
                         msg_content.innerHTML = _.flow(
                         msg_content.innerHTML = _.flow(
                             _.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
                             _.partial(u.geoUriToHttp, _, _converse.geouri_replacement),
+                            _.partial(u.addMentionsMarkup, _, this.model.get('references'), this.model.collection.chatbox),
                             u.addHyperlinks,
                             u.addHyperlinks,
                             u.renderNewLines,
                             u.renderNewLines,
                             _.partial(u.addEmoji, _converse, emojione, _)
                             _.partial(u.addEmoji, _converse, emojione, _)
@@ -260,7 +261,7 @@
                 getExtraMessageClasses () {
                 getExtraMessageClasses () {
                     let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
                     let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
                     if (this.model.get('type') === 'groupchat' && this.model.get('sender') === 'them') {
                     if (this.model.get('type') === 'groupchat' && this.model.get('sender') === 'them') {
-                        if (this.model.collection.chatbox.isUserMentioned(this.model.get('message'))) {
+                        if (this.model.collection.chatbox.isUserMentioned(this.model)) {
                             // Add special class to mark groupchat messages
                             // Add special class to mark groupchat messages
                             // in which we are mentioned.
                             // in which we are mentioned.
                             extra_classes += ' mentioned';
                             extra_classes += ' mentioned';

+ 106 - 40
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) {
@@ -21,7 +21,6 @@
         "templates/chatroom_nickname_form.html",
         "templates/chatroom_nickname_form.html",
         "templates/chatroom_password_form.html",
         "templates/chatroom_password_form.html",
         "templates/chatroom_sidebar.html",
         "templates/chatroom_sidebar.html",
-        "templates/chatroom_toolbar.html",
         "templates/info.html",
         "templates/info.html",
         "templates/list_chatrooms_modal.html",
         "templates/list_chatrooms_modal.html",
         "templates/occupant.html",
         "templates/occupant.html",
@@ -49,7 +48,6 @@
     tpl_chatroom_nickname_form,
     tpl_chatroom_nickname_form,
     tpl_chatroom_password_form,
     tpl_chatroom_password_form,
     tpl_chatroom_sidebar,
     tpl_chatroom_sidebar,
-    tpl_chatroom_toolbar,
     tpl_info,
     tpl_info,
     tpl_list_chatrooms_modal,
     tpl_list_chatrooms_modal,
     tpl_occupant,
     tpl_occupant,
@@ -93,7 +91,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: {
 
 
@@ -154,10 +152,10 @@
             // Refer to docs/source/configuration.rst for explanations of these
             // Refer to docs/source/configuration.rst for explanations of these
             // configuration settings.
             // configuration settings.
             _converse.api.settings.update({
             _converse.api.settings.update({
-                auto_list_rooms: false,
-                hide_muc_server: false, // TODO: no longer implemented...
-                muc_disable_moderator_commands: false,
-                visible_toolbar_buttons: {
+                'auto_list_rooms': false,
+                'hide_muc_server': false, // TODO: no longer implemented...
+                'muc_disable_moderator_commands': false,
+                'visible_toolbar_buttons': {
                     'toggle_occupants': true
                     'toggle_occupants': true
                 }
                 }
             });
             });
@@ -215,7 +213,7 @@
                     307: __('You have been kicked from this groupchat'),
                     307: __('You have been kicked from this groupchat'),
                     321: __("You have been removed from this groupchat because of an affiliation change"),
                     321: __("You have been removed from this groupchat because of an affiliation change"),
                     322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
                     322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
-                    332: __("You have been removed from this groupchat because the MUC (Multi-user chat) service is being shut down")
+                    332: __("You have been removed from this groupchat because the service hosting it is being shut down")
                 },
                 },
 
 
                 action_info_messages: {
                 action_info_messages: {
@@ -477,6 +475,10 @@
                 openChatRoom (ev) {
                 openChatRoom (ev) {
                     ev.preventDefault();
                     ev.preventDefault();
                     const data = this.parseRoomDataFromEvent(ev.target);
                     const data = this.parseRoomDataFromEvent(ev.target);
+                    if (data.nick === "") {
+                        // Make sure defaults apply if no nick is provided.
+                        data.nick = undefined;
+                    }
                     _converse.api.rooms.open(data.jid, data);
                     _converse.api.rooms.open(data.jid, data);
                     this.modal.hide();
                     this.modal.hide();
                     ev.target.reset();
                     ev.target.reset();
@@ -516,6 +518,7 @@
                 is_chatroom: true,
                 is_chatroom: true,
                 events: {
                 events: {
                     'change input.fileupload': 'onFileSelection',
                     'change input.fileupload': 'onFileSelection',
+                    'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
                     'click .chatbox-navback': 'showControlBox',
                     'click .chatbox-navback': 'showControlBox',
                     'click .close-chatbox-button': 'close',
                     'click .close-chatbox-button': 'close',
                     'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
                     'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
@@ -530,6 +533,7 @@
                     'click .toggle-smiley': 'toggleEmojiMenu',
                     'click .toggle-smiley': 'toggleEmojiMenu',
                     'click .upload-file': 'toggleFileUpload',
                     'click .upload-file': 'toggleFileUpload',
                     'keydown .chat-textarea': 'keyPressed',
                     'keydown .chat-textarea': 'keyPressed',
+                    'keyup .chat-textarea': 'keyUp',
                     'input .chat-textarea': 'inputChanged'
                     'input .chat-textarea': 'inputChanged'
                 },
                 },
 
 
@@ -579,6 +583,8 @@
                     this.el.innerHTML = tpl_chatroom();
                     this.el.innerHTML = tpl_chatroom();
                     this.renderHeading();
                     this.renderHeading();
                     this.renderChatArea();
                     this.renderChatArea();
+                    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();
                     }
                     }
@@ -596,20 +602,40 @@
                     if (_.isNull(this.el.querySelector('.chat-area'))) {
                     if (_.isNull(this.el.querySelector('.chat-area'))) {
                         const container_el = this.el.querySelector('.chatroom-body');
                         const container_el = this.el.querySelector('.chatroom-body');
                         container_el.insertAdjacentHTML('beforeend', tpl_chatarea({
                         container_el.insertAdjacentHTML('beforeend', tpl_chatarea({
-                            'label_message': __('Message'),
-                            'label_send': __('Send'),
-                            'show_send_button': _converse.show_send_button,
-                            'show_toolbar': _converse.show_toolbar,
-                            'unread_msgs': __('You have unread messages')
+                            'show_send_button': _converse.show_send_button
                         }));
                         }));
                         container_el.insertAdjacentElement('beforeend', this.occupantsview.el);
                         container_el.insertAdjacentElement('beforeend', this.occupantsview.el);
-                        this.renderToolbar(tpl_chatroom_toolbar);
                         this.content = this.el.querySelector('.chat-content');
                         this.content = this.el.querySelector('.chat-content');
                         this.toggleOccupants(null, true);
                         this.toggleOccupants(null, true);
                     }
                     }
                     return this;
                     return this;
                 },
                 },
 
 
+                initAutoComplete () {
+                    this.auto_complete = new _converse.AutoComplete(this.el, {
+                        'auto_first': true,
+                        '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.getDisplayName()}`})),
+                        'filter': _converse.FILTER_STARTSWITH,
+                        'trigger_on_at': true
+                    });
+                    this.auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+                },
+
+                keyPressed (ev) {
+                    if (this.auto_complete.keyPressed(ev)) {
+                        return;
+                    }
+                    return _converse.ChatBoxView.prototype.keyPressed.apply(this, arguments);
+                },
+
+                keyUp (ev) {
+                    this.auto_complete.evaluate(ev);
+                },
+
                 showRoomDetailsModal (ev) {
                 showRoomDetailsModal (ev) {
                     ev.preventDefault();
                     ev.preventDefault();
                     if (_.isUndefined(this.model.room_details_modal)) {
                     if (_.isUndefined(this.model.room_details_modal)) {
@@ -702,8 +728,8 @@
                     return _.extend(
                     return _.extend(
                         _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
                         _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
                         {
                         {
-                          label_hide_occupants: __('Hide the list of participants'),
-                          show_occupants_toggle: this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants
+                          'label_hide_occupants': __('Hide the list of participants'),
+                          'show_occupants_toggle': this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants
                         }
                         }
                     );
                     );
                 },
                 },
@@ -789,13 +815,31 @@
                     }
                     }
                 },
                 },
 
 
-                modifyRole(groupchat, nick, role, reason, onSuccess, onError) {
+                modifyRole (groupchat, nick, role, reason, onSuccess, onError) {
                     const item = $build("item", {nick, role});
                     const item = $build("item", {nick, role});
                     const iq = $iq({to: groupchat, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
                     const iq = $iq({to: groupchat, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
                     if (reason !== null) { iq.c("reason", reason); }
                     if (reason !== null) { iq.c("reason", reason); }
                     return _converse.connection.sendIQ(iq, onSuccess, onError);
                     return _converse.connection.sendIQ(iq, onSuccess, onError);
                 },
                 },
 
 
+                verifyRoles (roles) {
+                    const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
+                    if (!_.includes(roles, me.get('role'))) {
+                        this.showErrorMessage(__(`Forbidden: you do not have the necessary role in order to do that.`))
+                        return false;
+                    }
+                    return true;
+                },
+
+                verifyAffiliations (affiliations) {
+                    const me = this.model.occupants.findWhere({'jid': _converse.bare_jid});
+                    if (!_.includes(affiliations, me.get('affiliation'))) {
+                        this.showErrorMessage(__(`Forbidden: you do not have the necessary affiliation in order to do that.`))
+                        return false;
+                    }
+                    return true;
+                },
+
                 validateRoleChangeCommand (command, args) {
                 validateRoleChangeCommand (command, args) {
                     /* Check that a command to change a groupchat user's role or
                     /* Check that a command to change a groupchat user's role or
                      * affiliation has anough arguments.
                      * affiliation has anough arguments.
@@ -803,9 +847,7 @@
                     // TODO check if first argument is valid
                     // TODO check if first argument is valid
                     if (args.length < 1 || args.length > 2) {
                     if (args.length < 1 || args.length > 2) {
                         this.showErrorMessage(
                         this.showErrorMessage(
-                            __('Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
-                                command),
-                            true
+                            __('Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', command)
                         );
                         );
                         return false;
                         return false;
                     }
                     }
@@ -814,15 +856,11 @@
 
 
                 onCommandError (err) {
                 onCommandError (err) {
                     _converse.log(err, Strophe.LogLevel.FATAL);
                     _converse.log(err, Strophe.LogLevel.FATAL);
-                    this.showErrorMessage(
-                        __("Sorry, an error happened while running the command. Check your browser's developer console for details."),
-                        true
-                    );
+                    this.showErrorMessage(__("Sorry, an error happened while running the command. Check your browser's developer console for details."));
                 },
                 },
 
 
                 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) {
@@ -833,7 +871,9 @@
                         command = match[1].toLowerCase();
                         command = match[1].toLowerCase();
                     switch (command) {
                     switch (command) {
                         case 'admin':
                         case 'admin':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.model.setAffiliation('admin',
                             this.model.setAffiliation('admin',
                                     [{ 'jid': args[0],
                                     [{ 'jid': args[0],
                                        'reason': args[1]
                                        'reason': args[1]
@@ -843,7 +883,9 @@
                                     );
                                     );
                             break;
                             break;
                         case 'ban':
                         case 'ban':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['owner', 'admin']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.model.setAffiliation('outcast',
                             this.model.setAffiliation('outcast',
                                     [{ 'jid': args[0],
                                     [{ 'jid': args[0],
                                        'reason': args[1]
                                        'reason': args[1]
@@ -853,7 +895,9 @@
                                     );
                                     );
                             break;
                             break;
                         case 'deop':
                         case 'deop':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.modifyRole(
                             this.modifyRole(
                                     this.model.get('jid'), args[0], 'participant', args[1],
                                     this.model.get('jid'), args[0], 'participant', args[1],
                                     undefined, this.onCommandError.bind(this));
                                     undefined, this.onCommandError.bind(this));
@@ -879,28 +923,42 @@
                             ]);
                             ]);
                             break;
                             break;
                         case 'kick':
                         case 'kick':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.modifyRole(
                             this.modifyRole(
                                     this.model.get('jid'), args[0], 'none', args[1],
                                     this.model.get('jid'), args[0], 'none', args[1],
                                     undefined, this.onCommandError.bind(this));
                                     undefined, this.onCommandError.bind(this));
                             break;
                             break;
                         case 'mute':
                         case 'mute':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.modifyRole(
                             this.modifyRole(
                                     this.model.get('jid'), args[0], 'visitor', args[1],
                                     this.model.get('jid'), args[0], 'visitor', args[1],
                                     undefined, this.onCommandError.bind(this));
                                     undefined, this.onCommandError.bind(this));
                             break;
                             break;
-                        case 'member':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                        case 'member': {
+                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
+                            const occupant = this.model.occupants.findWhere({'nick': args[0]});
+                            if (!occupant) {
+                                this.showErrorMessage(__(`Error: Can't find a groupchat participant with the nickname "${args[0]}"`));
+                                break;
+                            }
                             this.model.setAffiliation('member',
                             this.model.setAffiliation('member',
-                                    [{ 'jid': args[0],
+                                    [{ 'jid': occupant.get('jid'),
                                        'reason': args[1]
                                        'reason': args[1]
                                     }]).then(
                                     }]).then(
                                         () => this.model.occupants.fetchMembers(),
                                         () => this.model.occupants.fetchMembers(),
                                         (err) => this.onCommandError(err)
                                         (err) => this.onCommandError(err)
                                     );
                                     );
                             break;
                             break;
-                        case 'nick':
+                        } case 'nick':
+                            if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) {
+                                break;
+                            }
                             _converse.connection.send($pres({
                             _converse.connection.send($pres({
                                 from: _converse.connection.jid,
                                 from: _converse.connection.jid,
                                 to: this.model.getRoomJIDAndNick(match[2]),
                                 to: this.model.getRoomJIDAndNick(match[2]),
@@ -908,7 +966,9 @@
                             }).tree());
                             }).tree());
                             break;
                             break;
                         case 'owner':
                         case 'owner':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.model.setAffiliation('owner',
                             this.model.setAffiliation('owner',
                                     [{ 'jid': args[0],
                                     [{ 'jid': args[0],
                                        'reason': args[1]
                                        'reason': args[1]
@@ -918,13 +978,17 @@
                                     );
                                     );
                             break;
                             break;
                         case 'op':
                         case 'op':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.modifyRole(
                             this.modifyRole(
                                     this.model.get('jid'), args[0], 'moderator', args[1],
                                     this.model.get('jid'), args[0], 'moderator', args[1],
                                     undefined, this.onCommandError.bind(this));
                                     undefined, this.onCommandError.bind(this));
                             break;
                             break;
                         case 'revoke':
                         case 'revoke':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyAffiliations(['admin', 'owner']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.model.setAffiliation('none',
                             this.model.setAffiliation('none',
                                     [{ 'jid': args[0],
                                     [{ 'jid': args[0],
                                        'reason': args[1]
                                        'reason': args[1]
@@ -944,7 +1008,9 @@
                             );
                             );
                             break;
                             break;
                         case 'voice':
                         case 'voice':
-                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            if (!this.verifyRoles(['moderator']) || !this.validateRoleChangeCommand(command, args)) {
+                                break;
+                            }
                             this.modifyRole(
                             this.modifyRole(
                                     this.model.get('jid'), args[0], 'participant', args[1],
                                     this.model.get('jid'), args[0], 'participant', args[1],
                                     undefined, this.onCommandError.bind(this));
                                     undefined, this.onCommandError.bind(this));

+ 114 - 45
src/converse-muc.js

@@ -25,7 +25,7 @@
         'none':         2,
         'none':         2,
     };
     };
 
 
-    const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, _, moment } = converse.env;
+    const { Strophe, Backbone, Promise, $iq, $build, $msg, $pres, b64_sha1, sizzle, f, moment, _ } = converse.env;
 
 
     // Add Strophe Namespaces
     // Add Strophe Namespaces
     Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
     Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
@@ -171,7 +171,7 @@
                           'affiliation': null,
                           'affiliation': null,
                           'connection_status': converse.ROOMSTATUS.DISCONNECTED,
                           'connection_status': converse.ROOMSTATUS.DISCONNECTED,
                           'name': '',
                           'name': '',
-                          'nick': _converse.xmppstatus.get('nickname'),
+                          'nick': _converse.xmppstatus.get('nickname') || _converse.nickname,
                           'description': '',
                           'description': '',
                           'features_fetched': false,
                           'features_fetched': false,
                           'roomconfig': {},
                           'roomconfig': {},
@@ -308,14 +308,80 @@
                     _converse.connection.sendPresence(presence);
                     _converse.connection.sendPresence(presence);
                 },
                 },
 
 
+                getReferenceForMention (mention, index) {
+                    const longest_match = u.getLongestSubstring(
+                        mention,
+                        this.occupants.map(o => o.getDisplayName())
+                    );
+                    if (!longest_match) {
+                        return null;
+                    }
+                    if ((mention[longest_match.length] || '').match(/[A-Za-zäëïöüâêîôûáéíóúàèìòùÄËÏÖÜÂÊÎÔÛÁÉÍÓÚÀÈÌÒÙ]/i)) {
+                        // avoid false positives, i.e. mentions that have
+                        // further alphabetical characters than our longest
+                        // match.
+                        return null;
+                    }
+                    const occupant = this.occupants.findOccupant({'nick': longest_match}) ||
+                            this.occupants.findOccupant({'jid': longest_match});
+                    if (!occupant) {
+                        return null;
+                    }
+                    const obj = {
+                        'begin': index,
+                        'end': index + longest_match.length,
+                        'value': longest_match,
+                        'type': 'mention'
+                    };
+                    if (occupant.get('jid')) {
+                        obj.uri = `xmpp:${occupant.get('jid')}`
+                    }
+                    return obj;
+                },
+
+                extractReference (text, index) {
+                    for (let i=index; i<text.length; i++) {
+                        if (text[i] !== '@') {
+                            continue
+                        } else {
+                            const match = text.slice(i+1),
+                                  ref = this.getReferenceForMention(match, i);
+                            if (ref) {
+                                return [text.slice(0, i) + match, ref, i]
+                            }
+                        }
+                    }
+                    return;
+                },
+
+                parseTextForReferences (text) {
+                    const refs = [];
+                    let index = 0;
+                    while (index < (text || '').length) {
+                        const result = this.extractReference(text, index);
+                        if (result) {
+                            text = result[0]; // @ gets filtered out
+                            refs.push(result[1]);
+                            index = result[2];
+                        } else {
+                            break;
+                        }
+                    }
+                    return [text, refs];
+                },
+
                 getOutgoingMessageAttributes (text, spoiler_hint) {
                 getOutgoingMessageAttributes (text, spoiler_hint) {
                     const is_spoiler = this.get('composing_spoiler');
                     const is_spoiler = this.get('composing_spoiler');
+                    var references;
+                    [text, references] = this.parseTextForReferences(text);
+
                     return {
                     return {
-                        'nick': this.get('nick'),
                         'from': `${this.get('jid')}/${this.get('nick')}`,
                         'from': `${this.get('jid')}/${this.get('nick')}`,
                         'fullname': this.get('nick'),
                         'fullname': this.get('nick'),
                         'is_spoiler': is_spoiler,
                         'is_spoiler': is_spoiler,
                         'message': text ? u.httpToGeoUri(emojione.shortnameToUnicode(text), _converse) : undefined,
                         'message': text ? u.httpToGeoUri(emojione.shortnameToUnicode(text), _converse) : undefined,
+                        'nick': this.get('nick'),
+                        'references': references,
                         'sender': 'me',
                         'sender': 'me',
                         'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
                         'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
                         'type': 'groupchat'
                         'type': 'groupchat'
@@ -471,13 +537,11 @@
                      *  A promise which resolves once the list has been
                      *  A promise which resolves once the list has been
                      *  retrieved.
                      *  retrieved.
                      */
                      */
-                    return new Promise((resolve, reject) => {
-                        affiliation = affiliation || 'member';
-                        const iq = $iq({to: this.get('jid'), type: "get"})
-                            .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                                .c("item", {'affiliation': affiliation});
-                        _converse.connection.sendIQ(iq, resolve, reject);
-                    });
+                    affiliation = affiliation || 'member';
+                    const iq = $iq({to: this.get('jid'), type: "get"})
+                        .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                            .c("item", {'affiliation': affiliation});
+                    return _converse.api.sendIQ(iq);
                 },
                 },
 
 
                 setAffiliation (affiliation, members) {
                 setAffiliation (affiliation, members) {
@@ -678,16 +742,14 @@
                     if (_.isString(affiliations)) {
                     if (_.isString(affiliations)) {
                         affiliations = [affiliations];
                         affiliations = [affiliations];
                     }
                     }
-                    return new Promise((resolve, reject) => {
-                        const promises = _.map(
-                            affiliations,
-                            _.partial(this.requestMemberList.bind(this))
-                        );
-                        Promise.all(promises).then(
-                            _.flow(u.marshallAffiliationIQs, resolve),
-                            _.flow(u.marshallAffiliationIQs, resolve)
-                        );
-                    });
+                    const promises = _.map(
+                        affiliations,
+                        _.partial(this.requestMemberList.bind(this))
+                    );
+                    return Promise.all(promises).then(
+                        (iq) => u.marshallAffiliationIQs(iq),
+                        (iq) => u.marshallAffiliationIQs(iq)
+                    );
                 },
                 },
 
 
                 updateMemberLists (members, affiliations, deltaFunc) {
                 updateMemberLists (members, affiliations, deltaFunc) {
@@ -864,8 +926,7 @@
                         if (sender === '') {
                         if (sender === '') {
                             return;
                             return;
                         }
                         }
-                        this.incrementUnreadMsgCounter(original_stanza);
-                        this.createMessage(stanza, original_stanza);
+                        this.incrementUnreadMsgCounter(this.createMessage(stanza, original_stanza));
                     }
                     }
                     if (sender !== this.get('nick')) {
                     if (sender !== this.get('nick')) {
                         // We only emit an event if it's not our own message
                         // We only emit an event if it's not our own message
@@ -944,23 +1005,28 @@
                      * Parameters:
                      * Parameters:
                      *  (String): The text message
                      *  (String): The text message
                      */
                      */
-                    return (new RegExp(`\\b${this.get('nick')}\\b`)).test(message);
+                    const nick = this.get('nick');
+                    if (message.get('references').length) {
+                        const mentions = message.get('references').filter(ref => (ref.type === 'mention')).map(ref => ref.value);
+                        return _.includes(mentions, nick);
+                    } else {
+                        return (new RegExp(`\\b${nick}\\b`)).test(message.get('message'));
+                    }
                 },
                 },
 
 
-                incrementUnreadMsgCounter (stanza) {
+                incrementUnreadMsgCounter (message) {
                     /* Given a newly received message, update the unread counter if
                     /* Given a newly received message, update the unread counter if
                      * necessary.
                      * necessary.
                      *
                      *
                      * Parameters:
                      * Parameters:
                      *  (XMLElement): The <messsage> stanza
                      *  (XMLElement): The <messsage> stanza
                      */
                      */
-                    const body = stanza.querySelector('body');
-                    if (_.isNull(body)) {
-                        return; // The message has no text
-                    }
-                    if (u.isNewMessage(stanza) && this.isHidden()) {
+                    if (!message) { return; }
+                    const body = message.get('message');
+                    if (_.isNil(body)) { return; }
+                    if (u.isNewMessage(message) && this.isHidden()) {
                         const settings = {'num_unread_general': this.get('num_unread_general') + 1};
                         const settings = {'num_unread_general': this.get('num_unread_general') + 1};
-                        if (this.isUserMentioned(body.textContent)) {
+                        if (this.isUserMentioned(message)) {
                             settings.num_unread = this.get('num_unread') + 1;
                             settings.num_unread = this.get('num_unread') + 1;
                             _converse.incrementMsgCounter();
                             _converse.incrementMsgCounter();
                         }
                         }
@@ -1032,26 +1098,29 @@
                 },
                 },
 
 
                 fetchMembers () {
                 fetchMembers () {
-                    const old_jids = _.uniq(_.concat(
-                        _.map(this.where({'affiliation': 'admin'}), (item) => item.get('jid')),
-                        _.map(this.where({'affiliation': 'member'}), (item) => item.get('jid')),
-                        _.map(this.where({'affiliation': 'owner'}), (item) => item.get('jid'))
-                    ));
-
                     this.chatroom.getJidsWithAffiliations(['member', 'owner', 'admin'])
                     this.chatroom.getJidsWithAffiliations(['member', 'owner', 'admin'])
-                    .then((jids) => {
-                        _.each(_.difference(old_jids, jids), (removed_jid) => {
-                            // Remove absent occupants who've been removed from
-                            // the members lists.
-                            if (removed_jid === _converse.bare_jid) { return; }
-                            const occupant = this.findOccupant({'jid': removed_jid});
-                            if (!occupant) { return; }
+                    .then((new_members) => {
+                        const new_jids = new_members.map(m => m.jid).filter(m => !_.isUndefined(m)),
+                              new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => !_.isUndefined(m)),
+                              removed_members = this.filter(m => {
+                                  return f.includes(m.get('affiliation'), ['admin', 'member', 'owner']) &&
+                                      !f.includes(m.get('nick'), new_nicks) &&
+                                        !f.includes(m.get('jid'), new_jids);
+                              });
+
+                        _.each(removed_members, (occupant) => {
+                            if (occupant.get('jid') === _converse.bare_jid) { return; }
                             if (occupant.get('show') === 'offline') {
                             if (occupant.get('show') === 'offline') {
                                 occupant.destroy();
                                 occupant.destroy();
                             }
                             }
                         });
                         });
-                        _.each(jids, (attrs) => {
-                            const occupant = this.findOccupant({'jid': attrs.jid});
+                        _.each(new_members, (attrs) => {
+                            let occupant;
+                            if (attrs.jid) {
+                                occupant = this.findOccupant({'jid': attrs.jid});
+                            } else {
+                                occupant = this.findOccupant({'nick': attrs.nick});
+                            }
                             if (occupant) {
                             if (occupant) {
                                 occupant.save(attrs);
                                 occupant.save(attrs);
                             } else {
                             } else {

+ 1 - 1
src/converse-push.js

@@ -97,7 +97,7 @@
                                     .c('value').t(push_app_server.secret);
                                     .c('value').t(push_app_server.secret);
                         }
                         }
                         _converse.api.sendIQ(stanza)
                         _converse.api.sendIQ(stanza)
-                            .then(() => _converse.session.set('push_enabled', true))
+                            .then(() => _converse.session.save('push_enabled', true))
                             .catch((e) => {
                             .catch((e) => {
                                 _converse.log(`Could not enable push app server for ${push_app_server.jid}`, Strophe.LogLevel.ERROR);
                                 _converse.log(`Could not enable push app server for ${push_app_server.jid}`, Strophe.LogLevel.ERROR);
                                 _converse.log(e, Strophe.LogLevel.ERROR);
                                 _converse.log(e, Strophe.LogLevel.ERROR);

+ 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

+ 4 - 3
src/i18n.js

@@ -159,9 +159,10 @@
                         xhr.onerror();
                         xhr.onerror();
                     }
                     }
                 };
                 };
-                xhr.onerror = function () {
-                    reject(xhr.statusText);
-                };
+                xhr.onerror = (e) => {
+                    const err_message = e ? ` Error: ${e.message}` : '';
+                    reject(new Error(`Could not fetch translations. Status: ${xhr.statusText}. ${err_message}`));
+                }
                 xhr.send();
                 xhr.send();
             });
             });
         }
         }

+ 1 - 11
src/templates/chatarea.html

@@ -1,14 +1,4 @@
 <div class="chat-area col">
 <div class="chat-area col">
     <div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}"></div>
     <div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}"></div>
-    <div class="new-msgs-indicator hidden">▼ {{{ o.unread_msgs }}} ▼</div>
-    <form class="sendXMPPMessage">
-        {[ if (o.show_toolbar) { ]}
-            <ul class="chat-toolbar no-text-select"></ul>
-        {[ } ]}
-        <textarea type="text" class="chat-textarea {[ if (o.show_send_button) { ]}chat-textarea-send-button{[ } ]}"
-                  placeholder="{{{o.label_message}}}"></textarea>
-    {[ if (o.show_send_button) { ]}
-        <button type="submit" class="pure-button send-button">{{{ o.label_send }}}</button>
-    {[ } ]}
-    </form>
+    <div class="message-form-container"/>
 </div>
 </div>

+ 15 - 9
src/templates/chatbox_message_form.html

@@ -6,14 +6,20 @@
     {[ } ]}
     {[ } ]}
     <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_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>

+ 0 - 12
src/templates/chatroom_toolbar.html

@@ -1,12 +0,0 @@
-{[ if (o.use_emoji)  { ]}
-<li class="toggle-toolbar-menu toggle-smiley dropup">
-    <a class="toggle-smiley fa fa-smile-o" title="{{{o.label_insert_smiley}}}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></a> 
-    <div class="emoji-picker dropdown-menu toolbar-menu"></div>
-</li>
-{[ } ]}
-{[ if (o.show_call_button)  { ]}
-<li class="toggle-call fa fa-phone" title="{{{o.label_start_call}}}"></li>
-{[ } ]}
-{[ if (o.show_occupants_toggle)  { ]}
-<li class="toggle-occupants fa fa-angle-double-right" title="{{{o.label_hide_occupants}}}"></li>
-{[ } ]}

+ 3 - 0
src/templates/toolbar.html

@@ -7,3 +7,6 @@
 {[ if (o.show_call_button)  { ]}
 {[ if (o.show_call_button)  { ]}
 <li class="toggle-call fa fa-phone" title="{{{o.label_start_call}}}"></li>
 <li class="toggle-call fa fa-phone" title="{{{o.label_start_call}}}"></li>
 {[ } ]}
 {[ } ]}
+{[ if (o.show_occupants_toggle)  { ]}
+<li class="toggle-occupants fa fa-angle-double-right" title="{{{o.label_hide_occupants}}}"></li>
+{[ } ]}

+ 80 - 1
src/utils/core.js

@@ -98,6 +98,21 @@
 
 
     var u = {};
     var u = {};
 
 
+    u.getLongestSubstring = function (string, candidates) {
+        function reducer (accumulator, current_value) {
+            if (string.startsWith(current_value)) {
+                if (current_value.length > accumulator.length) {
+                    return current_value;
+                } else {
+                    return accumulator;
+                }
+            } else {
+                return accumulator;
+            }
+        }
+        return candidates.reduce(reducer, '');
+    }
+
     u.getNextElement = function (el, selector='*') {
     u.getNextElement = function (el, selector='*') {
         let next_el = el.nextElementSibling;
         let next_el = el.nextElementSibling;
         while (!_.isNull(next_el) && !sizzle.matchesSelector(next_el, selector)) {
         while (!_.isNull(next_el) && !sizzle.matchesSelector(next_el, selector)) {
@@ -214,6 +229,38 @@
         return encodeURI(decodeURI(url)).replace(/[!'()]/g, escape).replace(/\*/g, "%2A");
         return encodeURI(decodeURI(url)).replace(/[!'()]/g, escape).replace(/\*/g, "%2A");
     };
     };
 
 
+    u.prefixMentions = function (message) {
+        /* Given a message object, return its text with @ chars
+         * inserted before the mentioned nicknames.
+         */
+        let text = message.get('message');
+        (message.get('references') || [])
+            .sort((a, b) => b.begin - a.begin)
+            .forEach(ref => {
+                text = `${text.slice(0, ref.begin)}@${text.slice(ref.begin)}`
+            });
+        return text;
+    };
+
+    u.addMentionsMarkup = function (text, references, chatbox) {
+        if (chatbox.get('message_type') !== 'groupchat') {
+            return text;
+        }
+        const nick = chatbox.get('nick');
+        references
+            .sort((a, b) => b.begin - a.begin)
+            .forEach(ref => {
+                const mention = text.slice(ref.begin, ref.end)
+                chatbox;
+                if (mention === nick) {
+                    text = text.slice(0, ref.begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(ref.end);
+                } else {
+                    text = text.slice(0, ref.begin) + `<span class="mention">${mention}</span>` + text.slice(ref.end);
+                }
+            });
+        return text;
+    };
+
     u.addHyperlinks = function (text) {
     u.addHyperlinks = function (text) {
         return URI.withinString(text, function (url) {
         return URI.withinString(text, function (url) {
             var uri = new URI(url);
             var uri = new URI(url);
@@ -808,7 +855,26 @@
         } 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.replaceCurrentWord = function (input, new_value) {
+        const cursor = input.selectionEnd || undefined,
+              current_word = _.last(input.value.slice(0, cursor).split(' ')),
+              value = input.value;
+        input.value = value.slice(0, cursor - current_word.length) + `${new_value} ` + value.slice(cursor);
+        input.selectionEnd = cursor - current_word.length + new_value.length + 1;
+    };
 
 
     u.isVisible = function (el) {
     u.isVisible = function (el) {
         if (u.hasClass('hidden', el)) {
         if (u.hasClass('hidden', el)) {
@@ -892,6 +958,19 @@
         return Math.floor(Math.random() * Math.floor(max));
         return Math.floor(Math.random() * Math.floor(max));
     };
     };
 
 
+    u.putCurserAtEnd = function (textarea) {
+        if (textarea !== document.activeElement) {
+            textarea.focus();
+        }
+        // Double the length because Opera is inconsistent about whether a carriage return is one character or two.
+        const len = textarea.value.length * 2;
+        // Timeout seems to be required for Blink
+        setTimeout(() => textarea.setSelectionRange(len, len), 1);
+        // Scroll to the bottom, in case we're in a tall textarea
+        // (Necessary for Firefox and Chrome)
+        this.scrollTop = 999999;
+    };
+
     u.getUniqueId = function () {
     u.getUniqueId = function () {
         return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
         return 'xxxxxxxx-xxxx'.replace(/[x]/g, function(c) {
             var r = Math.random() * 16 | 0,
             var r = Math.random() * 16 | 0,

+ 1 - 0
tests/runner.js

@@ -202,6 +202,7 @@ var specs = [
     "spec/user-details-modal",
     "spec/user-details-modal",
     "spec/messages",
     "spec/messages",
     "spec/chatroom",
     "spec/chatroom",
+    "spec/autocomplete",
     "spec/minchats",
     "spec/minchats",
     "spec/notification",
     "spec/notification",
     "spec/login",
     "spec/login",

+ 7 - 7
tests/utils.js

@@ -103,18 +103,18 @@
         return utils.waitUntil(() => _converse.chatboxviews.get(jid));
         return utils.waitUntil(() => _converse.chatboxviews.get(jid));
     };
     };
 
 
-    utils.openChatRoomViaModal = function (_converse, jid, nick) {
+    utils.openChatRoomViaModal = function (_converse, jid, nick='') {
         // Opens a new chatroom
         // Opens a new chatroom
         return new Promise(function (resolve, reject) {
         return new Promise(function (resolve, reject) {
             utils.openControlBox(_converse);
             utils.openControlBox(_converse);
-            var roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
+            const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             utils.closeControlBox(_converse);
             utils.closeControlBox(_converse);
             const modal = roomspanel.add_room_modal;
             const modal = roomspanel.add_room_modal;
-            utils.waitUntil(function () {
-                return u.isVisible(modal.el);
-            }, 1000).then(function () {
+            utils.waitUntil(() => u.isVisible(modal.el), 1000)
+            .then(() => {
                 modal.el.querySelector('input[name="chatroom"]').value = jid;
                 modal.el.querySelector('input[name="chatroom"]').value = jid;
+                modal.el.querySelector('input[name="nickname"]').value = nick;
                 modal.el.querySelector('form input[type="submit"]').click();
                 modal.el.querySelector('form input[type="submit"]').click();
                 resolve();
                 resolve();
             }).catch(_.partial(console.error, _));
             }).catch(_.partial(console.error, _));
@@ -172,9 +172,9 @@
                             id: 'DC352437-C019-40EC-B590-AF29E879AF97'
                             id: 'DC352437-C019-40EC-B590-AF29E879AF97'
                     }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                     }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
                         .c('item').attrs({
                         .c('item').attrs({
-                            affiliation: 'member',
+                            affiliation: 'owner',
                             jid: _converse.bare_jid,
                             jid: _converse.bare_jid,
-                            role: 'participant'
+                            role: 'moderator'
                         }).up()
                         }).up()
                         .c('status').attrs({code:'110'});
                         .c('status').attrs({code:'110'});
                     _converse.connection._dataRecv(utils.createRequest(presence));
                     _converse.connection._dataRecv(utils.createRequest(presence));

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