Jelajahi Sumber

(WIP) Wait for promises before opening chats in API methods

JC Brand 7 tahun lalu
induk
melakukan
1443fdd447
10 mengubah file dengan 431 tambahan dan 376 penghapusan
  1. 144 128
      dist/converse.js
  2. 32 43
      docs/source/developer_api.rst
  3. 3 3
      package-lock.json
  4. 53 40
      spec/chatbox.js
  5. 38 35
      spec/controlbox.js
  6. 57 44
      spec/converse.js
  7. 62 53
      spec/minchats.js
  8. 10 9
      spec/user-details-modal.js
  9. 17 15
      src/converse-chatboxes.js
  10. 15 6
      src/converse-muc.js

+ 144 - 128
dist/converse.js

@@ -36,34 +36,19 @@
 /******/ 	// define getter function for harmony exports
 /******/ 	__webpack_require__.d = function(exports, name, getter) {
 /******/ 		if(!__webpack_require__.o(exports, name)) {
-/******/ 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ 			Object.defineProperty(exports, name, {
+/******/ 				configurable: false,
+/******/ 				enumerable: true,
+/******/ 				get: getter
+/******/ 			});
 /******/ 		}
 /******/ 	};
 /******/
 /******/ 	// define __esModule on exports
 /******/ 	__webpack_require__.r = function(exports) {
-/******/ 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
-/******/ 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
-/******/ 		}
 /******/ 		Object.defineProperty(exports, '__esModule', { value: true });
 /******/ 	};
 /******/
-/******/ 	// create a fake namespace object
-/******/ 	// mode & 1: value is a module id, require it
-/******/ 	// mode & 2: merge all properties of value into the ns
-/******/ 	// mode & 4: return value when already ns object
-/******/ 	// mode & 8|1: behave like require
-/******/ 	__webpack_require__.t = function(value, mode) {
-/******/ 		if(mode & 1) value = __webpack_require__(value);
-/******/ 		if(mode & 8) return value;
-/******/ 		if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
-/******/ 		var ns = Object.create(null);
-/******/ 		__webpack_require__.r(ns);
-/******/ 		Object.defineProperty(ns, 'default', { enumerable: true, value: value });
-/******/ 		if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
-/******/ 		return ns;
-/******/ 	};
-/******/
 /******/ 	// getDefaultExport function for compatibility with non-harmony modules
 /******/ 	__webpack_require__.n = function(module) {
 /******/ 		var getter = module && module.__esModule ?
@@ -2575,7 +2560,13 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
             if (_.isFunction(this.beforeRender)) {
                 this.beforeRender();
             }
-            const new_vnode = tovnode.toVNode(parseHTMLToDOM(this.toHTML()));
+            let new_vnode;
+            if (!_.isNil(this.toHTML)) {
+                new_vnode = tovnode.toVNode(parseHTMLToDOM(this.toHTML()));
+            } else {
+                new_vnode = tovnode.toVNode(this.toDOM());
+            }
+
             new_vnode.data.hook = _.extend({
                create: this.updateEventListeners.bind(this),
                update: this.updateEventListeners.bind(this)
@@ -4529,7 +4520,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
 /*! no static exports found */
 /***/ (function(module, exports, __webpack_require__) {
 
-/* WEBPACK VAR INJECTION */(function(global) {var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;// Native Javascript for Bootstrap 4 v2.0.22 | © dnp_theme | MIT-License
+/* WEBPACK VAR INJECTION */(function(global) {var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;// Native Javascript for Bootstrap 4 v2.0.23 | © dnp_theme | MIT-License
 (function (root, factory) {
   if (true) {
     // AMD support:
@@ -4612,7 +4603,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     clickEvent    = 'click',
     hoverEvent    = 'hover',
     keydownEvent  = 'keydown',
-    keyupEvent    = 'keyup', 
+    keyupEvent    = 'keyup',
     resizeEvent   = 'resize',
     scrollEvent   = 'scroll',
     // originalEvents
@@ -4632,18 +4623,20 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     hasAttribute           = 'hasAttribute',
     createElement          = 'createElement',
     appendChild            = 'appendChild',
-    innerHTML              = 'innerHTML',  
+    innerHTML              = 'innerHTML',
     getElementsByTagName   = 'getElementsByTagName',
     preventDefault         = 'preventDefault',
     getBoundingClientRect  = 'getBoundingClientRect',
     querySelectorAll       = 'querySelectorAll',
     getElementsByCLASSNAME = 'getElementsByClassName',
+    getComputedStyle       = 'getComputedStyle',  
   
     indexOf      = 'indexOf',
     parentNode   = 'parentNode',
     length       = 'length',
     toLowerCase  = 'toLowerCase',
     Transition   = 'Transition',
+    Duration     = 'Duration',
     Webkit       = 'Webkit',
     style        = 'style',
     push         = 'push',
@@ -4663,15 +4656,16 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     // tooltip / popover
     mouseHover = ('onmouseleave' in DOC) ? [ 'mouseenter', 'mouseleave'] : [ 'mouseover', 'mouseout' ],
     tipPositions = /\b(top|bottom|left|right)+/,
-    
+  
     // modal
     modalOverlay = 0,
     fixedTop = 'fixed-top',
     fixedBottom = 'fixed-bottom',
-    
+  
     // transitionEnd since 2.0.4
     supportTransitions = Webkit+Transition in HTML[style] || Transition[toLowerCase]() in HTML[style],
     transitionEndEvent = Webkit+Transition in HTML[style] ? Webkit[toLowerCase]()+Transition+'End' : Transition[toLowerCase]()+'end',
+    transitionDuration = Webkit+Duration in HTML[style] ? Webkit[toLowerCase]()+Transition+Duration : Transition[toLowerCase]()+Duration,
   
     // set new focus element since 2.0.3
     setFocus = function(element){
@@ -4725,9 +4719,16 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
         off(element, event, handlerWrapper);
       });
     },
+    getTransitionDurationFromElement = function(element) {
+      var duration = globalObject[getComputedStyle](element)[transitionDuration];
+      duration = parseFloat(duration);
+      duration = typeof duration === 'number' && !isNaN(duration) ? duration * 1000 : 0;
+      return duration + 50; // we take a short offset to make sure we fire on the next frame after animation
+    },
     emulateTransitionEnd = function(element,handler){ // emulateTransitionEnd since 2.0.4
-      if (supportTransitions) { one(element, transitionEndEvent, function(e){ handler(e); }); }
-      else { handler(); }
+      var called = 0, duration = getTransitionDurationFromElement(element);
+      supportTransitions && one(element, transitionEndEvent, function(e){ handler(e); called = 1; });
+      setTimeout(function() { !called && handler(); }, duration);
     },
     bootstrapCustomEvent = function (eventName, componentName, related) {
       var OriginalCustomEvent = new CustomEvent( eventName + '.bs.' + componentName);
@@ -4750,8 +4751,8 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
           scroll = parent === DOC[body] ? getScroll() : { x: parent[offsetLeft] + parent[scrollLeft], y: parent[offsetTop] + parent[scrollTop] },
           linkDimensions = { w: rect[right] - rect[left], h: rect[bottom] - rect[top] },
           isPopover = hasClass(element,'popover'),
-          topPosition, leftPosition, 
-          
+          topPosition, leftPosition,
+  
           arrow = queryElement('.arrow',element),
           arrowTop, arrowLeft, arrowWidth, arrowHeight,
   
@@ -4770,7 +4771,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
       position = position === bottom && bottomExceed ? top : position;
       position = position === left && leftExceed ? right : position;
       position = position === right && rightExceed ? left : position;
-      
+  
       // update tooltip/popover class
       element.className[indexOf](position) === -1 && (element.className = element.className.replace(tipPositions,position));
   
@@ -4823,7 +4824,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
       arrowLeft && (arrow[style][left] = arrowLeft + 'px');
     };
   
-  BSN.version = '2.0.22';
+  BSN.version = '2.0.23';
   
   /* Native Javascript for Bootstrap 4 | Alert
   -------------------------------------------*/
@@ -4993,7 +4994,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     // DATA API
     var intervalAttribute = element[getAttribute](dataInterval),
         intervalOption = options[interval],
-        intervalData = intervalAttribute === 'false' ? 0 : parseInt(intervalAttribute) || 5000,  // bootstrap carousel default interval
+        intervalData = intervalAttribute === 'false' ? 0 : parseInt(intervalAttribute),  
         pauseData = element[getAttribute](dataPause) === hoverEvent || false,
         keyboardData = element[getAttribute](dataKeyboard) === 'true' || false,
       
@@ -5008,8 +5009,8 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     this[pause] = (options[pause] === hoverEvent || pauseData) ? hoverEvent : false; // false / hover
   
     this[interval] = typeof intervalOption === 'number' ? intervalOption
-                   : intervalData === 0 ? 0
-                   : intervalData;
+                   : intervalOption === false || intervalData === 0 || intervalData === false ? 0
+                   : 5000; // bootstrap carousel default interval
   
     // bind, event targets
     var self = this, index = element.index = 0, timer = element.timer = 0, 
@@ -5128,10 +5129,10 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
         addClass(slides[next],carouselItem +'-'+ slideDirection);
         addClass(slides[activeItem],carouselItem +'-'+ slideDirection);
   
-        one(slides[activeItem], transitionEndEvent, function(e) {
-          var timeout = e[target] !== slides[activeItem] ? e.elapsedTime*1000 : 0;
+        one(slides[next], transitionEndEvent, function(e) {
+          var timeout = e[target] !== slides[next] ? e.elapsedTime*1000+100 : 20;
           
-          setTimeout(function(){
+          isSliding && setTimeout(function(){
             isSliding = false;
   
             addClass(slides[next],active);
@@ -5146,7 +5147,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
             if ( !DOC.hidden && self[interval] && !hasClass(element,paused) ) {
               self.cycle();
             }
-          },timeout+100);
+          }, timeout);
         });
   
       } else {
@@ -5211,23 +5212,24 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
   
     // event targets and constants
     var accordion = null, collapse = null, self = this, 
-      isAnimating = false, // when true it will prevent click handlers
       accordionData = element[getAttribute]('data-parent'),
+      activeCollapse, activeElement,
   
       // component strings
       component = 'collapse',
       collapsed = 'collapsed',
+      isAnimating = 'isAnimating',
   
       // private methods
       openAction = function(collapseElement,toggle) {
         bootstrapCustomEvent.call(collapseElement, showEvent, component);
-        isAnimating = true;
+        collapseElement[isAnimating] = true;
         addClass(collapseElement,collapsing);
         removeClass(collapseElement,component);
         collapseElement[style][height] = collapseElement[scrollHeight] + 'px';
         
         emulateTransitionEnd(collapseElement, function() {
-          isAnimating = false;
+          collapseElement[isAnimating] = false;
           collapseElement[setAttribute](ariaExpanded,'true');
           toggle[setAttribute](ariaExpanded,'true');
           removeClass(collapseElement,collapsing);
@@ -5239,7 +5241,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
       },
       closeAction = function(collapseElement,toggle) {
         bootstrapCustomEvent.call(collapseElement, hideEvent, component);
-        isAnimating = true;
+        collapseElement[isAnimating] = true;
         collapseElement[style][height] = collapseElement[scrollHeight] + 'px'; // set height first
         removeClass(collapseElement,component);
         removeClass(collapseElement,showClass);
@@ -5248,7 +5250,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
         collapseElement[style][height] = '0px';
         
         emulateTransitionEnd(collapseElement, function() {
-          isAnimating = false;
+          collapseElement[isAnimating] = false;
           collapseElement[setAttribute](ariaExpanded,'false');
           toggle[setAttribute](ariaExpanded,'false');
           removeClass(collapseElement,collapsing);
@@ -5267,29 +5269,29 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     // public methods
     this.toggle = function(e) {
       e[preventDefault]();
-      if (isAnimating) return;
       if (!hasClass(collapse,showClass)) { self.show(); } 
       else { self.hide(); }
     };
     this.hide = function() {
+      if ( collapse[isAnimating] ) return;    
       closeAction(collapse,element);
       addClass(element,collapsed);
     };
     this.show = function() {
       if ( accordion ) {
-        var activeCollapse = queryElement('.'+component+'.'+showClass,accordion),
-            toggle = activeCollapse && (queryElement('['+dataToggle+'="'+component+'"]['+dataTarget+'="#'+activeCollapse.id+'"]',accordion)
-                   || queryElement('['+dataToggle+'="'+component+'"][href="#'+activeCollapse.id+'"]',accordion) ),
-            correspondingCollapse = toggle && (toggle[getAttribute](dataTarget) || toggle.href);
-        if ( activeCollapse && toggle && activeCollapse !== collapse ) {
-          closeAction(activeCollapse,toggle); 
-          if ( correspondingCollapse.split('#')[1] !== collapse.id ) { addClass(toggle,collapsed); } 
-          else { removeClass(toggle,collapsed); }
-        }
+        activeCollapse = queryElement('.'+component+'.'+showClass,accordion);
+        activeElement = activeCollapse && (queryElement('['+dataToggle+'="'+component+'"]['+dataTarget+'="#'+activeCollapse.id+'"]',accordion)
+                      || queryElement('['+dataToggle+'="'+component+'"][href="#'+activeCollapse.id+'"]',accordion) );
       }
   
-      openAction(collapse,element);
-      removeClass(element,collapsed);
+      if ( !collapse[isAnimating] || activeCollapse && !activeCollapse[isAnimating] ) {
+        if ( activeElement && activeCollapse !== collapse ) {
+          closeAction(activeCollapse,activeElement); 
+          addClass(activeElement,collapsed);
+        }
+        openAction(collapse,element);
+        removeClass(element,collapsed);
+      }
     };
   
     // init
@@ -5297,6 +5299,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
       on(element, clickEvent, self.toggle);
     }
     collapse = getTarget();
+    collapse[isAnimating] = false;  // when true it will prevent click handlers  
     accordion = queryElement(options.parent) || accordionData && getClosest(element, accordionData);
     element[stringCollapse] = self;
   };
@@ -5454,6 +5457,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
     var btnCheck = element[getAttribute](dataTarget)||element[getAttribute]('href'),
       checkModal = queryElement( btnCheck ),
       modal = hasClass(element,'modal') ? element : checkModal,
+      overlayDelay,
   
       // strings
       component = 'modal',
@@ -5487,13 +5491,13 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
         return globalObject[innerWidth] || (htmlRect[right] - Math.abs(htmlRect[left]));
       },
       setScrollbar = function () {
-        var bodyStyle = globalObject.getComputedStyle(DOC[body]),
+        var bodyStyle = globalObject[getComputedStyle](DOC[body]),
             bodyPad = parseInt((bodyStyle[paddingRight]), 10), itemPad;
         if (bodyIsOverflowing) {
           DOC[body][style][paddingRight] = (bodyPad + scrollbarWidth) + 'px';
           if (fixedItems[length]){
             for (var i = 0; i < fixedItems[length]; i++) {
-              itemPad = globalObject.getComputedStyle(fixedItems[i])[paddingRight];
+              itemPad = globalObject[getComputedStyle](fixedItems[i])[paddingRight];
               fixedItems[i][style][paddingRight] = ( parseInt(itemPad) + scrollbarWidth) + 'px';
             }
           }
@@ -5635,6 +5639,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
   
       if ( overlay && modalOverlay && !hasClass(overlay,showClass)) {
         overlay[offsetWidth]; // force reflow to enable trasition
+        overlayDelay = getTransitionDurationFromElement(overlay);              
         addClass(overlay, showClass);
       }
   
@@ -5654,18 +5659,19 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
         keydownHandlerToggle();
   
         hasClass(modal,'fade') ? emulateTransitionEnd(modal, triggerShow) : triggerShow();
-      }, supportTransitions ? 150 : 0);
+      }, supportTransitions && overlay ? overlayDelay : 0);
     };
     this.hide = function() {
       bootstrapCustomEvent.call(modal, hideEvent, component);
       overlay = queryElement('.'+modalBackdropString);
+      overlayDelay = overlay && getTransitionDurationFromElement(overlay);    
   
       removeClass(modal,showClass);
       modal[setAttribute](ariaHidden, true);
   
-      (function(){
+      setTimeout(function(){
         hasClass(modal,'fade') ? emulateTransitionEnd(modal, triggerHide) : triggerHide();
-      }());
+      }, supportTransitions && overlay ? overlayDelay : 0);
     };
     this.setContent = function( content ) {
       queryElement('.'+component+'-content',modal)[innerHTML] = content;
@@ -6021,7 +6027,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
               tabsContentContainer[style][height] = nextHeight + 'px'; // height animation
               tabsContentContainer[offsetWidth];
               emulateTransitionEnd(tabsContentContainer, triggerEnd);
-            },1);
+            },50);
           }
         } else {
           tabs[isAnimating] = false; 
@@ -6048,7 +6054,7 @@ backbone.nativeview = __webpack_require__(/*! backbone.nativeview */ "./node_mod
           tabsContentContainer[style][height] = containerHeight + 'px'; // height animation
           tabsContentContainer[offsetHeight];
           activeContent[style][float] = '';
-          nextContent[style][float] = '';   
+          nextContent[style][float] = '';
         }
   
         if ( hasClass(nextContent, 'fade') ) {
@@ -33088,12 +33094,13 @@ var map = {
 
 function webpackContext(req) {
 	var id = webpackContextResolve(req);
-	return __webpack_require__(id);
+	var module = __webpack_require__(id);
+	return module;
 }
 function webpackContextResolve(req) {
 	var id = map[req];
 	if(!(id + 1)) { // check for number or string
-		var e = new Error("Cannot find module '" + req + "'");
+		var e = new Error('Cannot find module "' + req + '".');
 		e.code = 'MODULE_NOT_FOUND';
 		throw e;
 	}
@@ -65863,26 +65870,26 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
 /*! no static exports found */
 /***/ (function(module, exports) {
 
-var g;
-
-// This works in non-strict mode
-g = (function() {
-	return this;
-})();
-
-try {
-	// This works if eval is allowed (see CSP)
-	g = g || Function("return this")() || (1, eval)("this");
-} catch (e) {
-	// This works if the window reference is available
-	if (typeof window === "object") g = window;
-}
-
-// g can still be undefined, but nothing to do about it...
-// We return undefined, instead of nothing here, so it's
-// easier to handle this case. if(!global) { ...}
-
-module.exports = g;
+var g;
+
+// This works in non-strict mode
+g = (function() {
+	return this;
+})();
+
+try {
+	// This works if eval is allowed (see CSP)
+	g = g || Function("return this")() || (1, eval)("this");
+} catch (e) {
+	// This works if the window reference is available
+	if (typeof window === "object") g = window;
+}
+
+// g can still be undefined, but nothing to do about it...
+// We return undefined, instead of nothing here, so it's
+// easier to handle this case. if(!global) { ...}
+
+module.exports = g;
 
 
 /***/ }),
@@ -65894,28 +65901,28 @@ module.exports = g;
 /*! no static exports found */
 /***/ (function(module, exports) {
 
-module.exports = function(module) {
-	if (!module.webpackPolyfill) {
-		module.deprecate = function() {};
-		module.paths = [];
-		// module.parent = undefined by default
-		if (!module.children) module.children = [];
-		Object.defineProperty(module, "loaded", {
-			enumerable: true,
-			get: function() {
-				return module.l;
-			}
-		});
-		Object.defineProperty(module, "id", {
-			enumerable: true,
-			get: function() {
-				return module.i;
-			}
-		});
-		module.webpackPolyfill = 1;
-	}
-	return module;
-};
+module.exports = function(module) {
+	if (!module.webpackPolyfill) {
+		module.deprecate = function() {};
+		module.paths = [];
+		// module.parent = undefined by default
+		if (!module.children) module.children = [];
+		Object.defineProperty(module, "loaded", {
+			enumerable: true,
+			get: function() {
+				return module.l;
+			}
+		});
+		Object.defineProperty(module, "id", {
+			enumerable: true,
+			get: function() {
+				return module.i;
+			}
+		});
+		module.webpackPolyfill = 1;
+	}
+	return module;
+};
 
 
 /***/ }),
@@ -68354,9 +68361,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           return _converse.log(`Invalid JID "${jid}" provided in URL fragment`, Strophe.LogLevel.WARN);
         }
 
-        Promise.all([_converse.api.waitUntil('rosterContactsFetched'), _converse.api.waitUntil('chatBoxesFetched')]).then(() => {
-          _converse.api.chats.open(jid);
-        });
+        _converse.api.chats.open(jid);
       }
 
       _converse.router.route('converse/chat?jid=:jid', openChat);
@@ -69279,18 +69284,21 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           },
 
           'open'(jids, attrs) {
-            if (_.isUndefined(jids)) {
-              _converse.log("chats.open: You need to provide at least one JID", Strophe.LogLevel.ERROR);
-
-              return null;
-            } else if (_.isString(jids)) {
-              const chatbox = _converse.api.chats.create(jids, attrs);
+            return new Promise((resolve, reject) => {
+              Promise.all([_converse.api.waitUntil('rosterContactsFetched'), _converse.api.waitUntil('chatBoxesFetched')]).then(() => {
+                if (_.isUndefined(jids)) {
+                  const err_msg = "chats.open: You need to provide at least one JID";
 
-              chatbox.trigger('show');
-              return chatbox;
-            }
+                  _converse.log(err_msg, Strophe.LogLevel.ERROR);
 
-            return _.map(jids, jid => _converse.api.chats.create(jid, attrs).trigger('show'));
+                  reject(new Error(err_msg));
+                } else if (_.isString(jids)) {
+                  resolve(_converse.api.chats.create(jids, attrs).trigger('show'));
+                } else {
+                  resolve(_.map(jids, jid => _converse.api.chats.create(jid, attrs).trigger('show')));
+                }
+              }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+            });
           },
 
           'get'(jids) {
@@ -73835,8 +73843,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
         throw new Error("converse-embedded: auto_join_rooms must be an Array");
       }
 
-      if (_converse.auto_join_rooms.length !== 1 && _converse.auto_join_private_chats.length !== 1) {
-        throw new Error("converse-embedded: It doesn't make " + "sense to have the auto_join_rooms setting to zero or " + "more then one, since only one chat room can be open " + "at any time.");
+      if (_converse.auto_join_rooms.length > 1 && _converse.auto_join_private_chats.length > 1) {
+        throw new Error("converse-embedded: It doesn't make " + "sense to have the auto_join_rooms setting more then one, " + "since only one chat room can be open at any time.");
       }
     }
 
@@ -79019,13 +79027,21 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_
           },
 
           'open'(jids, attrs) {
-            if (_.isUndefined(jids)) {
-              throw new TypeError('rooms.open: You need to provide at least one JID');
-            } else if (_.isString(jids)) {
-              return _converse.api.rooms.create(jids, attrs).trigger('show');
-            }
+            return new Promise((resolve, reject) => {
+              _converse.api.waitUntil('chatBoxesFetched').then(() => {
+                if (_.isUndefined(jids)) {
+                  const err_msg = 'rooms.open: You need to provide at least one JID';
 
-            return _.map(jids, jid => _converse.api.rooms.create(jid, attrs).trigger('show'));
+                  _converse.log(err_msg, Strophe.LogLevel.ERROR);
+
+                  reject(new TypeError(err_msg));
+                } else if (_.isString(jids)) {
+                  resolve(_converse.api.rooms.create(jids, attrs).trigger('show'));
+                } else {
+                  resolve(_.map(jids, jid => _converse.api.rooms.create(jid, attrs).trigger('show')));
+                }
+              });
+            });
           },
 
           'get'(jids, attrs, create) {

+ 32 - 43
docs/source/developer_api.rst

@@ -816,86 +816,75 @@ Note, for MUC chatrooms, you need to use the "rooms" grouping instead.
 get
 ~~~
 
-Returns an object representing a chatbox. The chatbox should already be open.
+Returns an object representing a chat. The chat should already be open.
 
-To return a single chatbox, provide the JID of the contact you're chatting
-with in that chatbox:
+To return a single chat, provide the JID of the contact you're chatting
+with in that chat:
 
 .. code-block:: javascript
 
     _converse.api.chats.get('buddy@example.com')
 
-To return an array of chatboxes, provide an array of JIDs:
+To return an array of chats, provide an array of JIDs:
 
 .. code-block:: javascript
 
     _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com'])
 
-To return all open chatboxes, call the method without any JIDs::
+To return all open chats, call the method without any JIDs::
 
     _converse.api.chats.get()
 
 open
 ~~~~
 
-Opens a chatbox and returns a `Backbone.View <http://backbonejs.org/#View>`_ object
-representing a chatbox.
+Opens a new chat.
+
+It returns an promise which will resolve with a `Backbone.Model <https://backbonejs.org/#Model>`_ representing the chat.
 
 Note that converse doesn't allow opening chats with users who aren't in your roster
 (unless you have set :ref:`allow_non_roster_messaging` to ``true``).
 
-Before opening a chat, you should first wait until the roster has been populated.
-This is the :ref:`rosterContactsFetched` event/promise.
-
-Besides that, it's a good idea to also first wait until already opened chatboxes
-(which are cached in sessionStorage) have also been fetched from the cache.
-This is the :ref:`chatBoxesFetched` event/promise.
-
 These two events fire only once per session, so they're also available as promises.
 
-So, to open a single chatbox:
+So, to open a single chat:
 
 .. code-block:: javascript
 
     converse.plugins.add('myplugin', {
-      initialize: function() {
-        var _converse = this._converse;
-        Promise.all([
-            _converse.api.waitUntil('rosterContactsFetched'),
-            _converse.api.waitUntil('chatBoxesFetched')
-        ]).then(function() {
+        initialize: function() {
+            var _converse = this._converse;
+
             // Note, buddy@example.org must be in your contacts roster!
-            _converse.api.chats.open('buddy@example.com')
-        });
-      }
+            _converse.api.chats.open('buddy@example.com').then((chat) => {
+                // Now you can do something with the chat model
+            });
+        }
     });
 
-To return an array of chatboxes, provide an array of JIDs:
+To return an array of chats, provide an array of JIDs:
 
 .. code-block:: javascript
 
     converse.plugins.add('myplugin', {
         initialize: function () {
             var _converse = this._converse;
-            Promise.all([
-                _converse.api.waitUntil('rosterContactsFetched'),
-                _converse.api.waitUntil('chatBoxesFetched')
-            ]).then(function() {
-                // Note, these users must first be in your contacts roster!
-                _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com'])
+            // Note, these users must first be in your contacts roster!
+            _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then((chats) => {
+                // Now you can do something with the chat models
             });
         }
     });
 
 
-*The returned chatbox object contains the following methods:*
+*The returned chat object contains the following methods:*
 
 +-------------------+------------------------------------------+
 | Method            | Description                              |
 +===================+==========================================+
-| close             | Close the chatbox.                       |
+| close             | Close the chat.                          |
 +-------------------+------------------------------------------+
-| focus             | Focuses the chatbox textarea             |
+| focus             | Focuses the chat textarea                |
 +-------------------+------------------------------------------+
 | model.endOTR      | End an OTR (Off-the-record) session.     |
 +-------------------+------------------------------------------+
@@ -903,13 +892,13 @@ To return an array of chatboxes, provide an array of JIDs:
 +-------------------+------------------------------------------+
 | model.initiateOTR | Start an OTR (off-the-record) session.   |
 +-------------------+------------------------------------------+
-| model.maximize    | Minimize the chatbox.                    |
+| model.maximize    | Minimize the chat.                       |
 +-------------------+------------------------------------------+
-| model.minimize    | Maximize the chatbox.                    |
+| model.minimize    | Maximize the chat.                       |
 +-------------------+------------------------------------------+
 | model.set         | Set an attribute (i.e. mutator).         |
 +-------------------+------------------------------------------+
-| show              | Opens/shows the chatbox.                 |
+| show              | Opens/shows the chat.                    |
 +-------------------+------------------------------------------+
 
 *The get and set methods can be used to retrieve and change the following attributes:*
@@ -917,9 +906,9 @@ To return an array of chatboxes, provide an array of JIDs:
 +-------------+-----------------------------------------------------+
 | Attribute   | Description                                         |
 +=============+=====================================================+
-| height      | The height of the chatbox.                          |
+| height      | The height of the chat.                             |
 +-------------+-----------------------------------------------------+
-| url         | The URL of the chatbox heading.                     |
+| url         | The URL of the chat heading.                        |
 +-------------+-----------------------------------------------------+
 
 The **chatviews** grouping
@@ -1014,7 +1003,7 @@ The **rooms** grouping
 get
 ~~~
 
-Returns an object representing a multi user chatbox (room).
+Returns an object representing a multi user chat (room).
 It takes 3 parameters:
 
 * the room JID (if not specified, all rooms will be returned).
@@ -1046,7 +1035,7 @@ It takes 3 parameters:
 open
 ~~~~
 
-Opens a multi user chatbox and returns an object representing it.
+Opens a multi user chat and returns an object representing it.
 Similar to the ``chats.get`` API.
 
 It takes 2 parameters:
@@ -1055,7 +1044,7 @@ It takes 2 parameters:
 * A map (object) containing any extra room attributes. For example, if you want
   to specify the nickname, use ``{'nick': 'bloodninja'}``.
 
-To open a single multi user chatbox, provide the JID of the room:
+To open a single multi user chat, provide the JID of the room:
 
 .. code-block:: javascript
 
@@ -1328,7 +1317,7 @@ Parameters:
 
 Returns a Promise which results with the VCard data for a particular JID or for
 a `Backbone.Model` instance which represents an entity with a JID (such as a roster contact,
-chatbox or chatroom occupant).
+chat or chatroom occupant).
 
 If a `Backbone.Model` instance is passed in, then it must have either a `jid`
 attribute or a `muc_jid` attribute.

+ 3 - 3
package-lock.json

@@ -2978,9 +2978,9 @@
       "dev": true
     },
     "bootstrap.native": {
-      "version": "2.0.22",
-      "resolved": "https://registry.npmjs.org/bootstrap.native/-/bootstrap.native-2.0.22.tgz",
-      "integrity": "sha512-eypi4y1eKJoRt8cTwkZCI3QQ7W04rbv4VU1nBjBshqNXkONI7jO6tG3qZTwq9Zd+gDoeaQASyk6V185y+Y7KHQ==",
+      "version": "2.0.23",
+      "resolved": "https://registry.npmjs.org/bootstrap.native/-/bootstrap.native-2.0.23.tgz",
+      "integrity": "sha512-bcbVgqIjRkyiHd6DN8Y18BYjJTKvvnN3Msb7Yh6K5vGGsjRT3gV0IFKR3rcEYgJ5Kvg1egL9exIfN/Hvwn4wNA==",
       "dev": true
     },
     "bourbon": {

+ 53 - 40
spec/chatbox.js

@@ -19,32 +19,35 @@
 
             it("has a /help command to show the available commands",
                 mock.initConverseWithPromises(
-                    null, ['rosterGroupsFetched'], {},
+                    null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     function (done, _converse) {
 
-                test_utils.createContacts(_converse, 'current');
+                test_utils.createContacts(_converse, 'current', 1);
+                _converse.emit('rosterContactsFetched');
                 test_utils.openControlBox();
 
-                var contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
                 test_utils.openChatBoxFor(_converse, contact_jid);
-                var view = _converse.chatboxviews.get(contact_jid);
-                test_utils.sendMessage(view, '/help');
+                test_utils.waitUntil(() => _converse.chatboxes.length == 2).then(() => {
+                    var view = _converse.chatboxviews.get(contact_jid);
+                    test_utils.sendMessage(view, '/help');
 
-                const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
-                expect(info_messages.length).toBe(3);
-                expect(info_messages.pop().textContent).toBe('/help: Show this menu');
-                expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
-                expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
+                    const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
+                    expect(info_messages.length).toBe(3);
+                    expect(info_messages.pop().textContent).toBe('/help: Show this menu');
+                    expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
+                    expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
 
-                var msg = $msg({
-                        from: contact_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: (new Date()).getTime()
-                    }).c('body').t('hello world').tree();
-                _converse.chatboxes.onMessage(msg);
-                expect(view.content.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
-                done();
+                    var msg = $msg({
+                            from: contact_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: (new Date()).getTime()
+                        }).c('body').t('hello world').tree();
+                    _converse.chatboxes.onMessage(msg);
+                    expect(view.content.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
+                    done();
+                });
             }));
 
 
@@ -108,38 +111,48 @@
                 });
             }));
 
-            it("is created when you click on a roster item",
-                mock.initConverseWithPromises(
-                    null, ['rosterGroupsFetched'], {},
+            it("is created when you click on a roster item", mock.initConverseWithPromises(
+                    null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     function (done, _converse) {
 
                 test_utils.createContacts(_converse, 'current');
+                _converse.emit('rosterContactsFetched');
                 test_utils.openControlBox();
 
-                var i, $el, jid, chatboxview;
+                let jid, online_contacts;
                 // openControlBox was called earlier, so the controlbox is
                 // visible, but no other chat boxes have been created.
                 expect(_converse.chatboxes.length).toEqual(1);
                 spyOn(_converse.chatboxviews, 'trimChats');
-                expect($("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+                expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
 
-                test_utils.waitUntil(function () {
-                    return $(_converse.rosterview.el).find('.roster-group li').length;
-                }, 700).then(function () {
-                    var online_contacts = $(_converse.rosterview.el).find('.roster-group .current-xmpp-contact a.open-chat');
+                test_utils.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group li').length, 700).then(function () {
+                    online_contacts = _converse.rosterview.el.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
                     expect(online_contacts.length).toBe(15);
-                    for (i=0; i<online_contacts.length; i++) {
-                        $el = $(online_contacts[i]);
-                        jid = $el.text().trim().replace(/ /g,'.').toLowerCase() + '@localhost';
-                        $el[0].click();
-                        chatboxview = _converse.chatboxviews.get(jid);
-                        expect(_converse.chatboxes.length).toEqual(i+2);
-                        expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
-                        // Check that new chat boxes are created to the left of the
-                        // controlbox (but to the right of all existing chat boxes)
-                        expect($("#conversejs .chatbox").length).toBe(i+2);
-                        expect($("#conversejs .chatbox")[1].id).toBe(chatboxview.model.get('box_id'));
-                    }
+                    const el = online_contacts[0];
+                    jid = el.textContent.trim().replace(/ /g,'.').toLowerCase() + '@localhost';
+                    el.click();
+                    return test_utils.waitUntil(() => _converse.chatboxes.length == 2);
+                }).then(() => {
+                    const chatboxview = _converse.chatboxviews.get(jid);
+                    expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+                    // Check that new chat boxes are created to the left of the
+                    // controlbox (but to the right of all existing chat boxes)
+                    expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(2);
+                    expect(document.querySelectorAll("#conversejs .chatbox")[1].id).toBe(chatboxview.model.get('box_id'));
+                    online_contacts[1].click();
+                    return test_utils.waitUntil(() => _converse.chatboxes.length == 3);
+                }).then(() => {
+                    const el = online_contacts[1];
+                    const new_jid = el.textContent.trim().replace(/ /g,'.').toLowerCase() + '@localhost';
+                    const chatboxview = _converse.chatboxviews.get(jid);
+                    const new_chatboxview = _converse.chatboxviews.get(new_jid);
+                    expect(_converse.chatboxviews.trimChats).toHaveBeenCalled();
+                    // Check that new chat boxes are created to the left of the
+                    // controlbox (but to the right of all existing chat boxes)
+                    expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3);
+                    expect(document.querySelectorAll("#conversejs .chatbox")[2].id).toBe(chatboxview.model.get('box_id'));
+                    expect(document.querySelectorAll("#conversejs .chatbox")[1].id).toBe(new_chatboxview.model.get('box_id'));
                     done();
                 });
             }));

+ 38 - 35
spec/controlbox.js

@@ -66,47 +66,50 @@
 
             it("shows the number of unread mentions received",
                 mock.initConverseWithPromises(
-                    null, ['rosterGroupsFetched'], {},
+                    null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     function (done, _converse) {
 
                 test_utils.createContacts(_converse, 'all').openControlBox();
+                _converse.emit('rosterContactsFetched');
 
-                var contacts_panel = _converse.chatboxviews.get('controlbox').contactspanel;
+                const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
 
-                var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
                 test_utils.openChatBoxFor(_converse, sender_jid);
-                var chatview = _converse.chatboxviews.get(sender_jid);
-                chatview.model.set({'minimized': true});
-
-                expect(_.isNull(_converse.chatboxviews.el.querySelector('.restore-chat .message-count'))).toBeTruthy();
-                expect(_.isNull(_converse.rosterview.el.querySelector('.msgs-indicator'))).toBeTruthy();
-
-                var msg = $msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: (new Date()).getTime()
-                    }).c('body').t('hello').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.chatboxes.onMessage(msg);
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('1');
-                expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('1');
-
-                msg = $msg({
-                        from: sender_jid,
-                        to: _converse.connection.jid,
-                        type: 'chat',
-                        id: (new Date()).getTime()
-                    }).c('body').t('hello again').up()
-                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.chatboxes.onMessage(msg);
-                expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('2');
-                expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('2');
-
-                chatview.model.set({'minimized': false});
-                expect(_.isNull(_converse.chatboxviews.el.querySelector('.restore-chat .message-count'))).toBeTruthy();
-                expect(_.isNull(_converse.rosterview.el.querySelector('.msgs-indicator'))).toBeTruthy();
-                done();
+                return test_utils.waitUntil(() => _converse.chatboxes.length).then(() => {
+
+                    const chatview = _converse.chatboxviews.get(sender_jid);
+                    chatview.model.set({'minimized': true});
+
+                    expect(_.isNull(_converse.chatboxviews.el.querySelector('.restore-chat .message-count'))).toBeTruthy();
+                    expect(_.isNull(_converse.rosterview.el.querySelector('.msgs-indicator'))).toBeTruthy();
+
+                    var msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: (new Date()).getTime()
+                        }).c('body').t('hello').up()
+                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+                    _converse.chatboxes.onMessage(msg);
+                    expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('1');
+                    expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('1');
+
+                    msg = $msg({
+                            from: sender_jid,
+                            to: _converse.connection.jid,
+                            type: 'chat',
+                            id: (new Date()).getTime()
+                        }).c('body').t('hello again').up()
+                        .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+                    _converse.chatboxes.onMessage(msg);
+                    expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('2');
+                    expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('2');
+
+                    chatview.model.set({'minimized': false});
+                    expect(_.isNull(_converse.chatboxviews.el.querySelector('.restore-chat .message-count'))).toBeTruthy();
+                    expect(_.isNull(_converse.rosterview.el.querySelector('.msgs-indicator'))).toBeTruthy();
+                    done();
+                });
             }));
         });
 

+ 57 - 44
spec/converse.js

@@ -1,12 +1,12 @@
 (function (root, factory) {
     define([
-        "jquery",
         "jasmine",
         "mock",
         "test-utils"], factory);
-} (this, function ($, jasmine, mock, test_utils) {
-    var b64_sha1 = converse.env.b64_sha1;
-    var _ = converse.env._;
+} (this, function (jasmine, mock, test_utils) {
+    const b64_sha1 = converse.env.b64_sha1,
+          _ = converse.env._,
+          u = converse.env.utils;
 
     describe("Converse", function() {
         
@@ -274,59 +274,72 @@
 
         describe("The \"chats\" API", function() {
 
-            it("has a method 'get' which returns the chatbox model", mock.initConverseWithPromises(
-                null, ['rosterInitialized'], {}, function (done, _converse) {
+            it("has a method 'get' which returns the promise that resolves to a chat model", mock.initConverseWithPromises(
+                null, ['rosterInitialized', 'chatBoxesInitialized'], {}, function (done, _converse) {
                     test_utils.openControlBox();
-                    test_utils.createContacts(_converse, 'current');
+                    test_utils.createContacts(_converse, 'current', 2);
+                    _converse.emit('rosterContactsFetched');
+
                     // Test on chat that doesn't exist.
                     expect(_converse.api.chats.get('non-existing@jabber.org')).toBeFalsy();
-                    var jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                    const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                    const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
+
                     // Test on chat that's not open
-                    var box = _converse.api.chats.get(jid);
+                    let box = _converse.api.chats.get(jid);
                     expect(typeof box === 'undefined').toBeTruthy();
-                    var chatboxview = _converse.chatboxviews.get(jid);
-                    // Test for single JID
+                    expect(_converse.chatboxes.length).toBe(1);
+
+                    // Test for one JID
                     test_utils.openChatBoxFor(_converse, jid);
-                    box = _converse.api.chats.get(jid);
+                    test_utils.waitUntil(() => _converse.chatboxes.length == 1).then(() => {
+                        box = _converse.api.chats.get(jid);
+                        expect(box instanceof Object).toBeTruthy();
+                        expect(box.get('box_id')).toBe(b64_sha1(jid));
+
+                        const chatboxview = _converse.chatboxviews.get(jid);
+                        expect(u.isVisible(chatboxview.el)).toBeTruthy();
+                        // Test for multiple JIDs
+                        test_utils.openChatBoxFor(_converse, jid2);
+                        return test_utils.waitUntil(() => _converse.chatboxes.length == 2);
+                    }).then(() => {
+                        const list = _converse.api.chats.get([jid, jid2]);
+                        expect(_.isArray(list)).toBeTruthy();
+                        expect(list[0].get('box_id')).toBe(b64_sha1(jid));
+                        expect(list[1].get('box_id')).toBe(b64_sha1(jid2));
+                        done();
+                    }).catch(_.partial(console.error, _));
+            }));
+
+            it("has a method 'open' which opens and returns promise that resolves to a chat model", mock.initConverseWithPromises(
+                null, ['rosterGroupsFetched', 'chatBoxesInitialized'], {}, function (done, _converse) {
+
+                test_utils.openControlBox();
+                test_utils.createContacts(_converse, 'current', 2);
+                _converse.emit('rosterContactsFetched');
+
+                const jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
+                const jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
+                // Test on chat that doesn't exist.
+                expect(_converse.api.chats.get('non-existing@jabber.org')).toBeFalsy();
+
+                return _converse.api.chats.open(jid).then((box) => {
                     expect(box instanceof Object).toBeTruthy();
                     expect(box.get('box_id')).toBe(b64_sha1(jid));
-                    chatboxview = _converse.chatboxviews.get(jid);
-                    expect($(chatboxview.el).is(':visible')).toBeTruthy();
+                    expect(
+                        _.keys(box),
+                        ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set']
+                    );
+                    const chatboxview = _converse.chatboxviews.get(jid);
+                    expect(u.isVisible(chatboxview.el)).toBeTruthy();
                     // Test for multiple JIDs
-                    var jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    test_utils.openChatBoxFor(_converse, jid2);
-                    var list = _converse.api.chats.get([jid, jid2]);
+                    return _converse.api.chats.open([jid, jid2]);
+                }).then((list) => {
                     expect(_.isArray(list)).toBeTruthy();
                     expect(list[0].get('box_id')).toBe(b64_sha1(jid));
                     expect(list[1].get('box_id')).toBe(b64_sha1(jid2));
                     done();
-            }));
-
-            it("has a method 'open' which opens and returns the chatbox model", mock.initConverseWithPromises(
-                null, ['rosterGroupsFetched'], {}, function (done, _converse) {
-
-                test_utils.openControlBox();
-                test_utils.createContacts(_converse, 'current');
-                var jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var chatboxview;
-                // Test on chat that doesn't exist.
-                expect(_converse.api.chats.get('non-existing@jabber.org')).toBeFalsy();
-                var box = _converse.api.chats.open(jid);
-                expect(box instanceof Object).toBeTruthy();
-                expect(box.get('box_id')).toBe(b64_sha1(jid));
-                expect(
-                    _.keys(box),
-                    ['close', 'endOTR', 'focus', 'get', 'initiateOTR', 'is_chatroom', 'maximize', 'minimize', 'open', 'set']
-                );
-                chatboxview = _converse.chatboxviews.get(jid);
-                expect($(chatboxview.el).is(':visible')).toBeTruthy();
-                // Test for multiple JIDs
-                var jid2 = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var list = _converse.api.chats.open([jid, jid2]);
-                expect(_.isArray(list)).toBeTruthy();
-                expect(list[0].get('box_id')).toBe(b64_sha1(jid));
-                expect(list[1].get('box_id')).toBe(b64_sha1(jid2));
-                done();
+                });
             }));
         });
 

+ 62 - 53
spec/minchats.js

@@ -1,8 +1,9 @@
 (function (root, factory) {
     define(["jquery", "jasmine", "mock", "test-utils"], factory);
 } (this, function ($, jasmine, mock, test_utils) {
-    var _ = converse.env._;
-    var $msg = converse.env.$msg;
+    const _ = converse.env._;
+    const  $msg = converse.env.$msg;
+    const u = converse.env.utils;
 
     describe("The Minimized Chats Widget", function () {
 
@@ -62,9 +63,8 @@
             expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeFalsy();
             _converse.minimized_chats.el.querySelector('#toggle-minimized-chats').click();
 
-            return test_utils.waitUntil(function () {
-                return $(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout')).is(':visible');
-            }, 500).then(function () {
+            return test_utils.waitUntil(() => u.isVisible(u.isVisible(_converse.minimized_chats.el.querySelector('.minimized-chats-flyout'))))
+            .then(function () {
                 expect(_converse.minimized_chats.toggleview.model.get('collapsed')).toBeTruthy();
                 done();
             });
@@ -72,70 +72,79 @@
 
         it("shows the number messages received to minimized chats",
             mock.initConverseWithPromises(
-                null, ['rosterGroupsFetched'], {},
+                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 function (done, _converse) {
 
             test_utils.createContacts(_converse, 'current');
+            _converse.emit('rosterContactsFetched');
+
             test_utils.openControlBox();
             _converse.minimized_chats.toggleview.model.browserStorage._clear();
             _converse.minimized_chats.initToggle();
 
             var i, contact_jid, chatview, msg;
             _converse.minimized_chats.toggleview.model.set({'collapsed': true});
-            expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeFalsy();
+
+            const unread_el = _converse.minimized_chats.toggleview.el.querySelector('.unread-message-count');
+            expect(_.isNull(unread_el)).toBe(true);
+
             for (i=0; i<3; i++) {
                 contact_jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
                 test_utils.openChatBoxFor(_converse, contact_jid);
-                chatview = _converse.chatboxviews.get(contact_jid);
-                chatview.model.set({'minimized': true});
-                msg = $msg({
+            }
+            return test_utils.waitUntil(() => _converse.chatboxes.length == 4).then(() => {
+                for (i=0; i<3; i++) {
+                    chatview = _converse.chatboxviews.get(contact_jid);
+                    chatview.model.set({'minimized': true});
+                    msg = $msg({
+                        from: contact_jid,
+                        to: _converse.connection.jid,
+                        type: 'chat',
+                        id: (new Date()).getTime()
+                    }).c('body').t('This message is sent to a minimized chatbox').up()
+                    .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+                    _converse.chatboxes.onMessage(msg);
+                    expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeTruthy();
+                    expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i+1).toString());
+                }
+                // Chat state notifications don't increment the unread messages counter
+                // <composing> state
+                _converse.chatboxes.onMessage($msg({
                     from: contact_jid,
                     to: _converse.connection.jid,
                     type: 'chat',
                     id: (new Date()).getTime()
-                }).c('body').t('This message is sent to a minimized chatbox').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.chatboxes.onMessage(msg);
-                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeTruthy();
-                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i+1).toString());
-            }
-            // Chat state notifications don't increment the unread messages counter
-            // <composing> state
-            _converse.chatboxes.onMessage($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: (new Date()).getTime()
-            }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
-
-            // <paused> state
-            _converse.chatboxes.onMessage($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: (new Date()).getTime()
-            }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
-
-            // <gone> state
-            _converse.chatboxes.onMessage($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: (new Date()).getTime()
-            }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
-
-            // <inactive> state
-            _converse.chatboxes.onMessage($msg({
-                from: contact_jid,
-                to: _converse.connection.jid,
-                type: 'chat',
-                id: (new Date()).getTime()
-            }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
-            done();
+                }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
+
+                // <paused> state
+                _converse.chatboxes.onMessage($msg({
+                    from: contact_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: (new Date()).getTime()
+                }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
+
+                // <gone> state
+                _converse.chatboxes.onMessage($msg({
+                    from: contact_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: (new Date()).getTime()
+                }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
+
+                // <inactive> state
+                _converse.chatboxes.onMessage($msg({
+                    from: contact_jid,
+                    to: _converse.connection.jid,
+                    type: 'chat',
+                    id: (new Date()).getTime()
+                }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+                expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe((i).toString());
+                done();
+            });
         }));
 
         it("shows the number messages received to minimized groupchats",

+ 10 - 9
spec/user-details-modal.js

@@ -16,22 +16,23 @@
 
         it("can be used to remove a contact",
             mock.initConverseWithPromises(
-                null, ['rosterGroupsFetched'], {},
+                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                 function (done, _converse) {
 
             test_utils.createContacts(_converse, 'current');
             _converse.emit('rosterContactsFetched');
 
+            let view, show_modal_button, modal;
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
             test_utils.openChatBoxFor(_converse, contact_jid);
-
-            const view = _converse.chatboxviews.get(contact_jid);
-            const show_modal_button = view.el.querySelector('.show-user-details-modal');
-            expect(u.isVisible(show_modal_button)).toBeTruthy();
-            show_modal_button.click();
-            const modal = view.user_details_modal;
-            test_utils.waitUntil(() => u.isVisible(modal.el), 1000)
-            .then(function () {
+            return test_utils.waitUntil(() => _converse.chatboxes.length).then(() => {
+                view = _converse.chatboxviews.get(contact_jid);
+                show_modal_button = view.el.querySelector('.show-user-details-modal');
+                expect(u.isVisible(show_modal_button)).toBeTruthy();
+                show_modal_button.click();
+                modal = view.user_details_modal;
+                return test_utils.waitUntil(() => u.isVisible(modal.el), 1000);
+            }).then(function () {
                 spyOn(window, 'confirm').and.returnValue(true);
                 spyOn(view.model.contact, 'removeFromRoster').and.callFake(function (callback) {
                     callback();

+ 17 - 15
src/converse-chatboxes.js

@@ -69,12 +69,7 @@
                         Strophe.LogLevel.WARN
                     );
                 }
-                Promise.all([
-                    _converse.api.waitUntil('rosterContactsFetched'),
-                    _converse.api.waitUntil('chatBoxesFetched')
-                ]).then(() => {
-                    _converse.api.chats.open(jid);
-                });
+                _converse.api.chats.open(jid);
             }
             _converse.router.route('converse/chat?jid=:jid', openChat);
 
@@ -905,15 +900,22 @@
                         });
                     },
                     'open' (jids, attrs) {
-                        if (_.isUndefined(jids)) {
-                            _converse.log("chats.open: You need to provide at least one JID", Strophe.LogLevel.ERROR);
-                            return null;
-                        } else if (_.isString(jids)) {
-                            const chatbox = _converse.api.chats.create(jids, attrs);
-                            chatbox.trigger('show');
-                            return chatbox;
-                        }
-                        return _.map(jids, (jid) => _converse.api.chats.create(jid, attrs).trigger('show'));
+                        return new Promise((resolve, reject) => {
+                            Promise.all([
+                                _converse.api.waitUntil('rosterContactsFetched'),
+                                _converse.api.waitUntil('chatBoxesFetched')
+                            ]).then(() => {
+                                if (_.isUndefined(jids)) {
+                                    const err_msg = "chats.open: You need to provide at least one JID";
+                                    _converse.log(err_msg, Strophe.LogLevel.ERROR);
+                                    reject(new Error(err_msg));
+                                } else if (_.isString(jids)) {
+                                    resolve(_converse.api.chats.create(jids, attrs).trigger('show'));
+                                } else {
+                                    resolve(_.map(jids, (jid) => _converse.api.chats.create(jid, attrs).trigger('show')));
+                                }
+                            }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
+                        });
                     },
                     'get' (jids) {
                         if (_.isUndefined(jids)) {

+ 15 - 6
src/converse-muc.js

@@ -1254,14 +1254,23 @@
                         }
                         return _.map(jids, _.partial(createChatRoom, _, attrs));
                     },
+
                     'open' (jids, attrs) {
-                        if (_.isUndefined(jids)) {
-                            throw new TypeError('rooms.open: You need to provide at least one JID');
-                        } else if (_.isString(jids)) {
-                            return _converse.api.rooms.create(jids, attrs).trigger('show');
-                        }
-                        return _.map(jids, (jid) => _converse.api.rooms.create(jid, attrs).trigger('show'));
+                        return new Promise((resolve, reject) => {
+                            _converse.api.waitUntil('chatBoxesFetched').then(() => {
+                                if (_.isUndefined(jids)) {
+                                    const err_msg = 'rooms.open: You need to provide at least one JID';
+                                    _converse.log(err_msg, Strophe.LogLevel.ERROR);
+                                    reject(new TypeError(err_msg));
+                                } else if (_.isString(jids)) {
+                                    resolve(_converse.api.rooms.create(jids, attrs).trigger('show'));
+                                } else {
+                                    resolve(_.map(jids, (jid) => _converse.api.rooms.create(jid, attrs).trigger('show')));
+                                }
+                            });
+                        });
                     },
+
                     'get' (jids, attrs, create) {
                         if (_.isString(attrs)) {
                             attrs = {'nick': attrs};