text: /**
   * @file SDK to connect to Tinode chat server.
   * See <a href="https://github.com/tinode/webapp">
   * https://github.com/tinode/webapp</a> for real-life usage.
   *
   * @copyright 2015-2019 Tinode
   * @summary Javascript bindings for Tinode.
   * @license Apache 2.0
   * @version 0.16
   *
   * @example
   * <head>
   * <script src=".../tinode.js"></script>
   * </head>
   *
   * <body>
   *  ...
   * <script>
   *  // Instantiate tinode.
   *  const tinode = new Tinode(APP_NAME, HOST, API_KEY, null, true);
   *  tinode.enableLogging(true);
   *  // Add logic to handle disconnects.
   *  tinode.onDisconnect = function(err) { ... };
   *  // Connect to the server.
   *  tinode.connect().then(() => {
   *    // Connected. Login now.
   *    return tinode.loginBasic(login, password);
   *  }).then((ctrl) => {
   *    // Logged in fine, attach callbacks, subscribe to 'me'.
   *    const me = tinode.getMeTopic();
   *    me.onMetaDesc = function(meta) { ... };
   *    // Subscribe, fetch topic description and the list of contacts.
   *    me.subscribe({get: {desc: {}, sub: {}});
   *  }).catch((err) => {
   *    // Login or subscription failed, do something.
   *    ...
   *  });
   *  ...
   * </script>
   * </body>
   */
  'use strict';
  
  // NOTE TO DEVELOPERS:
  // Localizable strings should be double quoted "строка на другом языке",
  // non-localizable strings should be single quoted 'non-localized'.
  
  if (typeof require == 'function') {
    if (typeof Drafty == 'undefined') {
      var Drafty = require('./drafty.js');
    }
    var package_version = require('../version.json').version;
  }
  
  let WebSocketProvider;
  if (typeof WebSocket != 'undefined') {
    WebSocketProvider = WebSocket;
  }
  
  let XHRProvider;
  if (typeof XMLHttpRequest != 'undefined') {
    XHRProvider = XMLHttpRequest;
  }
  initForNonBrowserApp();
  
  // Global constants
  const PROTOCOL_VERSION = '0';
  const VERSION = package_version || '0.16';
  const LIBRARY = 'tinodejs/' + VERSION;
  
  const TOPIC_NEW = 'new';
  const TOPIC_NEW_CHAN = 'nch';
  const TOPIC_ME = 'me';
  const TOPIC_FND = 'fnd';
  const TOPIC_SYS = 'sys';
  const TOPIC_CHAN = 'chn';
  const USER_NEW = 'new';
  
  // Starting value of a locally-generated seqId used for pending messages.
  const LOCAL_SEQID = 0xFFFFFFF;
  
  const MESSAGE_STATUS_NONE = 0; // Status not assigned.
  const MESSAGE_STATUS_QUEUED = 1; // Local ID assigned, in progress to be sent.
  const MESSAGE_STATUS_SENDING = 2; // Transmission started.
  const MESSAGE_STATUS_FAILED = 3; // At least one attempt was made to send the message.
  const MESSAGE_STATUS_SENT = 4; // Delivered to the server.
  const MESSAGE_STATUS_RECEIVED = 5; // Received by the client.
  const MESSAGE_STATUS_READ = 6; // Read by the user.
  const MESSAGE_STATUS_TO_ME = 7; // Message from another user.
  
  // Error code to return in case of a network problem.
  const NETWORK_ERROR = 503;
  const NETWORK_ERROR_TEXT = "Connection failed";
  
  // Reject unresolved futures after this many milliseconds.
  const EXPIRE_PROMISES_TIMEOUT = 5000;
  // Periodicity of garbage collection of unresolved futures.
  const EXPIRE_PROMISES_PERIOD = 1000;
  
  // Error code to return when user disconnected from server.
  const NETWORK_USER = 418;
  const NETWORK_USER_TEXT = "Disconnected by client";
  
  // Utility functions
  
  // Add brower missing function for non browser app, eg nodeJs
  function initForNonBrowserApp() {
    // Tinode requirement in native mode because react native doesn't provide Base64 method
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
  
    if (typeof btoa == 'undefined') {
      const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
      global.btoa = function(input = '') {
        let str = input;
        let output = '';
  
        for (let block = 0, charCode, i = 0, map = chars; str.charAt(i | 0) || (map = '=', i % 1); output += map.charAt(63 & block >> 8 - i % 1 * 8)) {
  
          charCode = str.charCodeAt(i += 3 / 4);
  
          if (charCode > 0xFF) {
            throw new Error("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.");
          }
  
          block = block << 8 | charCode;
        }
  
        return output;
      };
    }
  
    if (typeof atob == 'undefined') {
      global.atob = function(input = '') {
        let str = input.replace(/=+$/, '');
        let output = '';
  
        if (str.length % 4 == 1) {
          throw new Error("'atob' failed: The string to be decoded is not correctly encoded.");
        }
        for (let bc = 0, bs = 0, buffer, i = 0; buffer = str.charAt(i++);
  
          ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer,
            bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0
        ) {
          buffer = chars.indexOf(buffer);
        }
  
        return output;
      };
    }
  
    if (typeof window == 'undefined') {
      global.window = {
        WebSocket: WebSocketProvider,
        XMLHttpRequest: XHRProvider,
        URL: {
          createObjectURL: function() {
            throw new Error("Unable to use URL.createObjectURL in a non-browser application");
          }
        }
      }
    }
  }
  
  // RFC3339 formater of Date
  function rfc3339DateString(d) {
    if (!d || d.getTime() == 0) {
      return undefined;
    }
  
    function pad(val, sp) {
      sp = sp || 2;
      return '0'.repeat(sp - ('' + val).length) + val;
    }
  
    const millis = d.getUTCMilliseconds();
    return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) +
      'T' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) +
      (millis ? '.' + pad(millis, 3) : '') + 'Z';
  }
  
  // btoa replacement. Stock btoa fails on on non-Latin1 strings.
  function b64EncodeUnicode(str) {
    // The encodeURIComponent percent-encodes UTF-8 string,
    // then the percent encoding is converted into raw bytes which
    // can be fed into btoa.
    return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
      function toSolidBytes(match, p1) {
        return String.fromCharCode('0x' + p1);
      }));
  }
  
  // Recursively merge src's own properties to dst.
  // Ignore properties where ignore[property] is true.
  // Array and Date objects are shallow-copied.
  function mergeObj(dst, src, ignore) {
    if (typeof src != 'object') {
      if (src === Tinode.DEL_CHAR) {
        return undefined;
      }
      if (src === undefined) {
        return dst;
      }
      return src;
    }
    // JS is crazy: typeof null is 'object'.
    if (src === null) {
      return src;
    }
  
    // Handle Date
    if (src instanceof Date) {
      return (!dst || !(dst instanceof Date) || dst < src) ? src : dst;
    }
  
    // Access mode
    if (src instanceof AccessMode) {
      return new AccessMode(src);
    }
  
    // Handle Array
    if (src instanceof Array) {
      return src;
    }
  
    if (!dst || dst === Tinode.DEL_CHAR) {
      dst = src.constructor();
    }
  
    for (let prop in src) {
      if (src.hasOwnProperty(prop) &&
        (!ignore || !ignore[prop]) &&
        (prop != '_noForwarding')) {
  
        dst[prop] = mergeObj(dst[prop], src[prop]);
      }
    }
    return dst;
  }
  
  // Update object stored in a cache. Returns updated value.
  function mergeToCache(cache, key, newval, ignore) {
    cache[key] = mergeObj(cache[key], newval, ignore);
    return cache[key];
  }
  
  function stringToDate(obj) {
    if (typeof obj.created == 'string') {
      obj.created = new Date(obj.created);
    }
    if (typeof obj.updated == 'string') {
      obj.updated = new Date(obj.updated);
    }
    if (typeof obj.touched == 'string') {
      obj.touched = new Date(obj.touched);
    }
  }
  
  // JSON stringify helper - pre-processor for JSON.stringify
  function jsonBuildHelper(key, val) {
    if (val instanceof Date) {
      // Convert javascript Date objects to rfc3339 strings
      val = rfc3339DateString(val);
    } else if (val instanceof AccessMode) {
      val = val.jsonHelper();
    } else if (val === undefined || val === null || val === false ||
      (Array.isArray(val) && val.length == 0) ||
      ((typeof val == 'object') && (Object.keys(val).length == 0))) {
      // strip out empty elements while serializing objects to JSON
      return undefined;
    }
  
    return val;
  };
  
  // Strips all values from an object of they evaluate to false or if their name starts with '_'.
  function simplify(obj) {
    Object.keys(obj).forEach(function(key) {
      if (key[0] == '_') {
        // Strip fields like "obj._key".
        delete obj[key];
      } else if (!obj[key]) {
        // Strip fields which evaluate to false.
        delete obj[key];
      } else if (Array.isArray(obj[key]) && obj[key].length == 0) {
        // Strip empty arrays.
        delete obj[key];
      } else if (!obj[key]) {
        // Strip fields which evaluate to false.
        delete obj[key];
      } else if (typeof obj[key] == 'object' && !(obj[key] instanceof Date)) {
        simplify(obj[key]);
        // Strip empty objects.
        if (Object.getOwnPropertyNames(obj[key]).length == 0) {
          delete obj[key];
        }
      }
    });
    return obj;
  };
  
  // Trim whitespace, strip empty and duplicate elements elements.
  // If the result is an empty array, add a single element "\u2421" (Unicode Del character).
  function normalizeArray(arr) {
    let out = [];
    if (Array.isArray(arr)) {
      // Trim, throw away very short and empty tags.
      for (let i = 0, l = arr.length; i < l; i++) {
        let t = arr[i];
        if (t) {
          t = t.trim().toLowerCase();
          if (t.length > 1) {
            out.push(t);
          }
        }
      }
      out.sort().filter(function(item, pos, ary) {
        return !pos || item != ary[pos - 1];
      });
    }
    if (out.length == 0) {
      // Add single tag with a Unicode Del character, otherwise an ampty array
      // is ambiguos. The Del tag will be stripped by the server.
      out.push(Tinode.DEL_CHAR);
    }
    return out;
  }
  
  // Attempt to convert date strings to objects.
  function jsonParseHelper(key, val) {
    // Convert string timestamps with optional milliseconds to Date
    // 2015-09-02T01:45:43[.123]Z
    if (key === 'ts' && typeof val === 'string' &&
      val.length >= 20 && val.length <= 24) {
      let date = new Date(val);
      if (date) {
        return date;
      }
    } else if (key === 'acs' && typeof val === 'object') {
      return new AccessMode(val);
    }
    return val;
  };
  
  // Trims very long strings (encoded images) to make logged packets more readable.
  function jsonLoggerHelper(key, val) {
    if (typeof val == 'string' && val.length > 128) {
      return '<' + val.length + ', bytes: ' + val.substring(0, 12) + '...' + val.substring(val.length - 12) + '>';
    }
    return jsonBuildHelper(key, val);
  };
  
  // Parse browser user agent to extract browser name and version.
  function getBrowserInfo(ua, product) {
    ua = ua || '';
    let reactnative = '';
    // Check if this is a ReactNative app.
    if (/reactnative/i.test(product)) {
      reactnative = 'ReactNative; ';
    }
    // Then test for WebKit based browser.
    ua = ua.replace(' (KHTML, like Gecko)', '');
    let m = ua.match(/(AppleWebKit\/[.\d]+)/i);
    let result;
    if (m) {
      // List of common strings, from more useful to less useful.
      let priority = ['chrome', 'safari', 'mobile', 'version'];
      let tmp = ua.substr(m.index + m[0].length).split(" ");
      let tokens = [];
      // Split Name/0.0.0 into Name and version 0.0.0
      for (let i = 0; i < tmp.length; i++) {
        let m2 = /([\w.]+)[\/]([\.\d]+)/.exec(tmp[i]);
        if (m2) {
          tokens.push([m2[1], m2[2], priority.findIndex(function(e) {
            return (e == m2[1].toLowerCase());
          })]);
        }
      }
      // Sort by priority: more interesting is earlier than less interesting.
      tokens.sort(function(a, b) {
        let diff = a[2] - b[2];
        return diff != 0 ? diff : b[0].length - a[0].length;
      });
      if (tokens.length > 0) {
        // Return the least common browser string and version.
        result = tokens[0][0] + '/' + tokens[0][1];
      } else {
        // Failed to ID the browser. Return the webkit version.
        result = m[1];
      }
      // Test for MSIE.
    } else if (/trident/i.test(ua)) {
      m = /(?:\brv[ :]+([.\d]+))|(?:\bMSIE ([.\d]+))/g.exec(ua);
      if (m) {
        result = 'MSIE/' + (m[1] || m[2]);
      } else {
        result = 'MSIE/?';
      }
      // Test for Firefox.
    } else if (/firefox/i.test(ua)) {
      m = /Firefox\/([.\d]+)/g.exec(ua);
      if (m) {
        result = 'Firefox/' + m[1];
      } else {
        result = 'Firefox/?';
      }
      // Older Opera.
    } else if (/presto/i.test(ua)) {
      m = /Opera\/([.\d]+)/g.exec(ua);
      if (m) {
        result = 'Opera/' + m[1];
      } else {
        result = 'Opera/?';
      }
    } else {
      // Failed to parse anything meaningfull. Try the last resort.
      m = /([\w.]+)\/([.\d]+)/.exec(ua);
      if (m) {
        result = m[1] + '/' + m[2];
      } else {
        m = ua.split(' ');
        result = m[0];
      }
    }
  
    // Shorten the version to one dot 'a.bb.ccc.d -> a.bb' at most.
    m = result.split('/');
    if (m.length > 1) {
      let v = m[1].split('.');
      result = m[0] + '/' + v[0] + (v[1] ? '.' + v[1] : '');
    }
    return reactnative + result;
  }
  
  /**
   * In-memory sorted cache of objects.
   *
   * @class CBuffer
   * @memberof Tinode
   * @protected
   *
   * @param {function} compare custom comparator of objects. Returns -1 if a < b, 0 if a == b, 1 otherwise.
   * @param {boolean} unique enforce element uniqueness: replace existing element with a new one on conflict.
   */
  var CBuffer = function(compare, unique) {
    let buffer = [];
  
    compare = compare || function(a, b) {
      return a === b ? 0 : a < b ? -1 : 1;
    };
  
    function findNearest(elem, arr, exact) {
      let start = 0;
      let end = arr.length - 1;
      let pivot = 0;
      let diff = 0;
      let found = false;
  
      while (start <= end) {
        pivot = (start + end) / 2 | 0;
        diff = compare(arr[pivot], elem);
        if (diff < 0) {
          start = pivot + 1;
        } else if (diff > 0) {
          end = pivot - 1;
        } else {
          found = true;
          break;
        }
      }
      if (found) {
        return {
          idx: pivot,
          exact: true
        };
      }
      if (exact) {
        return {
          idx: -1
        };
      }
      // Not exact - insertion point
      return {
        idx: diff < 0 ? pivot + 1 : pivot
      };
    }
  
    // Insert element into a sorted array.
    function insertSorted(elem, arr) {
      const found = findNearest(elem, arr, false);
      const count = (found.exact && unique) ? 1 : 0;
      arr.splice(found.idx, count, elem);
      return arr;
    }
  
    return {
      /**
       * Get an element at the given position.
       * @memberof Tinode.CBuffer#
       * @param {number} at - Position to fetch from.
       * @returns {Object} Element at the given position or <tt>undefined</tt>
       */
      getAt: function(at) {
        return buffer[at];
      },
  
      /**
       * Convenience method for getting the last element of the buffer.
       * @memberof Tinode.CBuffer#
       * @returns {Object} The last element in the buffer or <tt>undefined</tt> if buffer is empty.
       */
      getLast: function() {
        return buffer.length > 0 ? buffer[buffer.length - 1] : undefined;
      },
  
      /** Add new element(s) to the buffer. Variadic: takes one or more arguments. If an array is passed as a single
       * argument, its elements are inserted individually.
       * @memberof Tinode.CBuffer#
       *
       * @param {...Object|Array} - One or more objects to insert.
       */
      put: function() {
        let insert;
        // inspect arguments: if array, insert its elements, if one or more non-array arguments, insert them one by one
        if (arguments.length == 1 && Array.isArray(arguments[0])) {
          insert = arguments[0];
        } else {
          insert = arguments;
        }
        for (let idx in insert) {
          insertSorted(insert[idx], buffer);
        }
      },
  
      /**
       * Remove element at the given position.
       * @memberof Tinode.CBuffer#
       * @param {number} at - Position to delete at.
       * @returns {Object} Element at the given position or <tt>undefined</tt>
       */
      delAt: function(at) {
        let r = buffer.splice(at, 1);
        if (r && r.length > 0) {
          return r[0];
        }
        return undefined;
      },
  
      /**
       * Remove elements between two positions.
       * @memberof Tinode.CBuffer#
       * @param {number} since - Position to delete from (inclusive).
       * @param {number} before - Position to delete to (exclusive).
       *
       * @returns {Array} array of removed elements (could be zero length).
       */
      delRange: function(since, before) {
        return buffer.splice(since, before - since);
      },
  
      /**
       * Return the number of elements the buffer holds.
       * @memberof Tinode.CBuffer#
       * @return {number} Number of elements in the buffer.
       */
      length: function() {
        return buffer.length;
      },
  
      /**
       * Reset the buffer discarding all elements
       * @memberof Tinode.CBuffer#
       */
      reset: function() {
        buffer = [];
      },
  
      /**
       * Callback for iterating contents of buffer. See {@link Tinode.CBuffer#forEach}.
       * @callback ForEachCallbackType
       * @memberof Tinode.CBuffer#
       * @param {Object} elem - Element of the buffer.
       * @param {number} index - Index of the current element.
       */
  
      /**
       * Apply given function `callback` to all elements of the buffer.
       * @memberof Tinode.CBuffer#
       *
       * @param {Tinode.ForEachCallbackType} callback - Function to call for each element.
       * @param {integer} startIdx - Optional index to start iterating from (inclusive).
       * @param {integer} beforeIdx - Optional index to stop iterating before (exclusive).
       * @param {Object} context - calling context (i.e. value of 'this' in callback)
       */
      forEach: function(callback, startIdx, beforeIdx, context) {
        startIdx = startIdx | 0;
        beforeIdx = beforeIdx || buffer.length;
        for (let i = startIdx; i < beforeIdx; i++) {
          callback.call(context, buffer[i], i);
        }
      },
  
      /**
       * Find element in buffer using buffer's comparison function.
       * @memberof Tinode.CBuffer#
       *
       * @param {Object} elem - element to find.
       * @param {boolean=} nearest - when true and exact match is not found, return the nearest element (insertion point).
       * @returns {number} index of the element in the buffer or -1.
       */
      find: function(elem, nearest) {
        const {
          idx
        } = findNearest(elem, buffer, !nearest);
        return idx;
      }
    }
  }
  
  // Helper function for creating an endpoint URL
  function makeBaseUrl(host, protocol, apiKey) {
    let url = null;
  
    if (protocol === 'http' || protocol === 'https' || protocol === 'ws' || protocol === 'wss') {
      url = protocol + '://';
      url += host;
      if (url.charAt(url.length - 1) !== '/') {
        url += '/';
      }
      url += 'v' + PROTOCOL_VERSION + '/channels';
      if (protocol === 'http' || protocol === 'https') {
        // Long polling endpoint end with "lp", i.e.
        // '/v0/channels/lp' vs just '/v0/channels' for ws
        url += '/lp';
      }
      url += '?apikey=' + apiKey;
    }
  
    return url;
  }
  
  /**
   * An abstraction for a websocket or a long polling connection.
   *
   * @class Connection
   * @memberof Tinode
   *
   * @param {string} host_ - Host name and port number to connect to.
   * @param {string} apiKey_ - API key generated by keygen
   * @param {string} transport_ - Network transport to use, either `ws`/`wss` for websocket or `lp` for long polling.
   * @param {boolean} secure_ - Use secure WebSocket (wss) if true.
   * @param {boolean} autoreconnect_ - If connection is lost, try to reconnect automatically.
   */
  var Connection = function(host_, apiKey_, transport_, secure_, autoreconnect_) {
    let host = host_;
    let secure = secure_;
    let apiKey = apiKey_;
  
    let autoreconnect = autoreconnect_;
  
    // Settings for exponential backoff
    const _BOFF_BASE = 2000; // 2000 milliseconds, minimum delay between reconnects
    const _BOFF_MAX_ITER = 10; // Maximum delay between reconnects 2^10 * 2000 ~ 34 minutes
    const _BOFF_JITTER = 0.3; // Add random delay
  
    let _boffTimer = null;
    let _boffIteration = 0;
    let _boffClosed = false; // Indicator if the socket was manually closed - don't autoreconnect if true.
  
    let log = (text, ...args) => {
      if (this.logger) {
        this.logger(text, ...args);
      }
    }
  
    // Backoff implementation - reconnect after a timeout.
    function boffReconnect() {
      // Clear timer
      clearTimeout(_boffTimer);
      // Calculate when to fire the reconnect attempt
      const timeout = _BOFF_BASE * (Math.pow(2, _boffIteration) * (1.0 + _BOFF_JITTER * Math.random()));
      // Update iteration counter for future use
      _boffIteration = (_boffIteration >= _BOFF_MAX_ITER ? _boffIteration : _boffIteration + 1);
      if (this.onAutoreconnectIteration) {
        this.onAutoreconnectIteration(timeout);
      }
  
      _boffTimer = setTimeout(() => {
        log("Reconnecting, iter=" + _boffIteration + ", timeout=" + timeout);
        // Maybe the socket was closed while we waited for the timer?
        if (!_boffClosed) {
          const prom = this.connect();
          if (this.onAutoreconnectIteration) {
            this.onAutoreconnectIteration(0, prom);
          } else {
            // Suppress error if it's not used.
            prom.catch(() => {
              /* do nothing */
            });
          }
        } else if (this.onAutoreconnectIteration) {
          this.onAutoreconnectIteration(-1);
        }
      }, timeout);
    }
  
    // Terminate auto-reconnect process.
    function boffStop() {
      clearTimeout(_boffTimer);
      _boffTimer = null;
    }
  
    // Reset auto-reconnect iteration counter.
    function boffReset() {
      _boffIteration = 0;
    }
  
    // Initialization for Websocket
    function init_ws(instance) {
      let _socket = null;
  
      /**
       * Initiate a new connection
       * @memberof Tinode.Connection#
       * @param {string} host_ Host name to connect to; if null the old host name will be used.
       * @param {boolean} force Force new connection even if one already exists.
       * @return {Promise} Promise resolved/rejected when the connection call completes, resolution is called without parameters, rejection passes the {Error} as parameter.
       */
      instance.connect = function(host_, force) {
        _boffClosed = false;
  
        if (_socket) {
          if (!force && _socket.readyState == _socket.OPEN) {
            return Promise.resolve();
          }
          _socket.close();
          _socket = null;
        }
  
        if (host_) {
          host = host_;
        }
  
        return new Promise(function(resolve, reject) {
          const url = makeBaseUrl(host, secure ? 'wss' : 'ws', apiKey);
  
          log("Connecting to: ", url);
  
          // It throws when the server is not accessible but the exception cannot be caught:
          // https://stackoverflow.com/questions/31002592/javascript-doesnt-catch-error-in-websocket-instantiation/31003057
          const conn = new WebSocketProvider(url);
  
          conn.onerror = function(err) {
            reject(err);
          }
  
          conn.onopen = function(evt) {
            if (autoreconnect) {
              boffStop();
            }
  
            if (instance.onOpen) {
              instance.onOpen();
            }
  
            resolve();
          }
  
          conn.onclose = function(evt) {
            _socket = null;
  
            if (instance.onDisconnect) {
              const code = _boffClosed ? NETWORK_USER : NETWORK_ERROR;
              instance.onDisconnect(new Error(_boffClosed ? NETWORK_USER_TEXT : NETWORK_ERROR_TEXT +
                ' (' + code + ')'), code);
            }
  
            if (!_boffClosed && autoreconnect) {
              boffReconnect.call(instance);
            }
          }
  
          conn.onmessage = function(evt) {
            if (instance.onMessage) {
              instance.onMessage(evt.data);
            }
          }
          _socket = conn;
        });
      }
  
      /**
       * Try to restore a network connection, also reset backoff.
       * @memberof Tinode.Connection#
       *
       * @param {boolean} force - reconnect even if there is a live connection already.
       */
      instance.reconnect = function(force) {
        boffStop();
        instance.connect(null, force);
      }
  
      /**
       * Terminate the network connection
       * @memberof Tinode.Connection#
       */
      instance.disconnect = function() {
        _boffClosed = true;
        boffStop();
  
        if (!_socket) {
          return;
        }
        _socket.close();
        _socket = null;
      }
  
      /**
       * Send a string to the server.
       * @memberof Tinode.Connection#
       *
       * @param {string} msg - String to send.
       * @throws Throws an exception if the underlying connection is not live.
       */
      instance.sendText = function(msg) {
        if (_socket && (_socket.readyState == _socket.OPEN)) {
          _socket.send(msg);
        } else {
          throw new Error("Websocket is not connected");
        }
      };
  
      /**
       * Check if socket is alive.
       * @memberof Tinode.Connection#
       * @returns {boolean} true if connection is live, false otherwise
       */
      instance.isConnected = function() {
        return (_socket && (_socket.readyState == _socket.OPEN));
      }
  
      /**
       * Get the name of the current network transport.
       * @memberof Tinode.Connection#
       * @returns {string} name of the transport such as 'ws' or 'lp'.
       */
      instance.transport = function() {
        return 'ws';
      }
  
      /**
       * Send network probe to check if connection is indeed live.
       * @memberof Tinode.Connection#
       */
      instance.probe = function() {
        instance.sendText('1');
      }
    }
  
    // Initialization for long polling.
    function init_lp(instance) {
      const XDR_UNSENT = 0; //	Client has been created. open() not called yet.
      const XDR_OPENED = 1; //	open() has been called.
      const XDR_HEADERS_RECEIVED = 2; // send() has been called, and headers and status are available.
      const XDR_LOADING = 3; //	Downloading; responseText holds partial data.
      const XDR_DONE = 4; // The operation is complete.
      // Fully composed endpoint URL, with API key & SID
      let _lpURL = null;
  
      let _poller = null;
      let _sender = null;
  
      function lp_sender(url_) {
        let sender = new XMLHttpRequest();
        sender.onreadystatechange = function(evt) {
          if (sender.readyState == XDR_DONE && sender.status >= 400) {
            // Some sort of error response
            throw new Error("LP sender failed, " + sender.status);
          }
        }
  
        sender.open('POST', url_, true);
        return sender;
      }
  
      function lp_poller(url_, resolve, reject) {
        let poller = new XMLHttpRequest();
        let promiseCompleted = false;
  
        poller.onreadystatechange = function(evt) {
  
          if (poller.readyState == XDR_DONE) {
            if (poller.status == 201) { // 201 == HTTP.Created, get SID
              let pkt = JSON.parse(poller.responseText, jsonParseHelper);
              _lpURL = url_ + '&sid=' + pkt.ctrl.params.sid
              poller = lp_poller(_lpURL);
              poller.send(null)
              if (instance.onOpen) {
                instance.onOpen();
              }
  
              if (resolve) {
                promiseCompleted = true;
                resolve();
              }
  
              if (autoreconnect) {
                boffStop();
              }
            } else if (poller.status < 400) { // 400 = HTTP.BadRequest
              if (instance.onMessage) {
                instance.onMessage(poller.responseText)
              }
              poller = lp_poller(_lpURL);
              poller.send(null);
            } else {
              // Don't throw an error here, gracefully handle server errors
              if (reject && !promiseCompleted) {
                promiseCompleted = true;
                reject(poller.responseText);
              }
              if (instance.onMessage && poller.responseText) {
                instance.onMessage(poller.responseText);
              }
              if (instance.onDisconnect) {
                const code = poller.status || (_boffClosed ? NETWORK_USER : NETWORK_ERROR);
                const text = poller.responseText || (_boffClosed ? NETWORK_USER_TEXT : NETWORK_ERROR_TEXT);
                instance.onDisconnect(new Error(text + ' (' + code + ')'), code);
              }
  
              // Polling has stopped. Indicate it by setting poller to null.
              poller = null;
              if (!_boffClosed && autoreconnect) {
                boffReconnect.call(instance);
              }
            }
          }
        }
        poller.open('GET', url_, true);
        return poller;
      }
  
      instance.connect = function(host_, force) {
        _boffClosed = false;
  
        if (_poller) {
          if (!force) {
            return Promise.resolve();
          }
          _poller.onreadystatechange = undefined;
          _poller.abort();
          _poller = null;
        }
  
        if (host_) {
          host = host_;
        }
  
        return new Promise(function(resolve, reject) {
          const url = makeBaseUrl(host, secure ? 'https' : 'http', apiKey);
          log("Connecting to: ", url);
          _poller = lp_poller(url, resolve, reject);
          _poller.send(null)
        }).catch((err) => {
          console.log("LP connection failed:", err);
        });
      };
  
      instance.reconnect = function(force) {
        boffStop();
        instance.connect(null, force);
      };
  
      instance.disconnect = function() {
        _boffClosed = true;
        boffStop();
  
        if (_sender) {
          _sender.onreadystatechange = undefined;
          _sender.abort();
          _sender = null;
        }
        if (_poller) {
          _poller.onreadystatechange = undefined;
          _poller.abort();
          _poller = null;
        }
  
        if (instance.onDisconnect) {
          instance.onDisconnect(new Error(NETWORK_USER_TEXT + ' (' + NETWORK_USER + ')'), NETWORK_USER);
        }
        // Ensure it's reconstructed
        _lpURL = null;
      }
  
      instance.sendText = function(msg) {
        _sender = lp_sender(_lpURL);
        if (_sender && (_sender.readyState == 1)) { // 1 == OPENED
          _sender.send(msg);
        } else {
          throw new Error("Long poller failed to connect");
        }
      };
  
      instance.isConnected = function() {
        return (_poller && true);
      }
  
      instance.transport = function() {
        return 'lp';
      }
  
      instance.probe = function() {
        instance.sendText('1');
      }
    }
  
    let initialized = false;
    if (transport_ === 'lp') {
      // explicit request to use long polling
      init_lp(this);
      initialized = true;
    } else if (transport_ === 'ws') {
      // explicit request to use web socket
      // if websockets are not available, horrible things will happen
      init_ws(this);
      initialized = true;
  
      // Default transport selection
    } else if (typeof window == 'object') {
      if (window['WebSocket']) {
        // Using web sockets -- default.
        init_ws(this);
        initialized = true;
      } else if (window['XMLHttpRequest']) {
        // The browser has no websockets, using long polling.
        init_lp(this);
        initialized = true;
      }
    }
  
    if (!initialized) {
      // No transport is avaiilable.
      console.log("No network transport is available. Running under Node? Call 'Tinode.setNetworkProviders()'.");
      throw new Error("No network transport is available. Running under Node? Call 'Tinode.setNetworkProviders()'.");
    }
  
    /**
     * Check if the given network transport is available.
     * @memberof Tinode.Connection#
     * @param {String} trans - either 'ws' (websocket) or 'lp' (long polling).
     * @returns true if given transport is available, false otherwise.
     */
    this.transportAvailable = function(transp) {
      switch (transp) {
        case 'ws':
          return typeof window == 'object' && window['WebSocket'];
        case 'lp':
          return typeof window == 'object' && window['XMLHttpRequest'];
        default:
          console.log("Request for unknown transport", transp);
          return false;
      }
    }
  
    /**
     * Reset autoreconnect counter to zero.
     * @memberof Tinode.Connection#
     */
    this.backoffReset = function() {
      boffReset();
    }
  
    // Callbacks:
    /**
     * A callback to pass incoming messages to. See {@link Tinode.Connection#onMessage}.
     * @callback Tinode.Connection.OnMessage
     * @memberof Tinode.Connection
     * @param {string} message - Message to process.
     */
    /**
     * A callback to pass incoming messages to.
     * @type {Tinode.Connection.OnMessage}
     * @memberof Tinode.Connection#
     */
    this.onMessage = undefined;
  
    /**
     * A callback for reporting a dropped connection.
     * @type {function}
     * @memberof Tinode.Connection#
     */
    this.onDisconnect = undefined;
  
    /**
     * A callback called when the connection is ready to be used for sending. For websockets it's socket open,
     * for long polling it's readyState=1 (OPENED)
     * @type {function}
     * @memberof Tinode.Connection#
     */
    this.onOpen = undefined;
  
    /**
     * A callback to notify of reconnection attempts. See {@link Tinode.Connection#onAutoreconnectIteration}.
     * @memberof Tinode.Connection
     * @callback AutoreconnectIterationType
     * @param {string} timeout - time till the next reconnect attempt in milliseconds. -1 means reconnect was skipped.
     * @param {Promise} promise resolved or rejected when the reconnect attemp completes.
     *
     */
    /**
     * A callback to inform when the next attampt to reconnect will happen and to receive connection promise.
     * @memberof Tinode.Connection#
     * @type {Tinode.Connection.AutoreconnectIterationType}
     */
    this.onAutoreconnectIteration = undefined;
  
    /**
     * A callback to log events from Connection. See {@link Tinode.Connection#logger}.
     * @memberof Tinode.Connection
     * @callback LoggerCallbackType
     * @param {string} event - Event to log.
     */
    /**
     * A callback to report logging events.
     * @memberof Tinode.Connection#
     * @type {Tinode.Connection.LoggerCallbackType}
     */
    this.logger = undefined;
  };
  
  /**
   * @class Tinode
   *
   * @param {string} appname_ - Name of the caliing application to be reported in User Agent.
   * @param {string} host_ - Host name and port number to connect to.
   * @param {string} apiKey_ - API key generated by keygen
   * @param {string} transport_ - See {@link Tinode.Connection#transport}.
   * @param {boolean} secure_ - Use Secure WebSocket if true.
   * @param {string} platform_ - Optional platform identifier, one of "ios", "web", "android".
   */
  var Tinode = function(appname_, host_, apiKey_, transport_, secure_, platform_) {
    // Client-provided application name, format <Name>/<version number>
    if (appname_) {
      this._appName = appname_;
    } else {
      this._appName = "Undefined";
    }
  
    // API Key.
    this._apiKey = apiKey_;
  
    // Name and version of the browser.
    this._browser = '';
    this._platform = platform_;
    this._hwos = 'undefined';
    this._humanLanguage = 'xx';
    // Underlying OS.
    if (typeof navigator != 'undefined') {
      this._browser = getBrowserInfo(navigator.userAgent, navigator.product);
      this._hwos = navigator.platform;
      // This is the default language. It could be changed by client.
      this._humanLanguage = navigator.language || 'en-US';
    }
    // Logging to console enabled
    this._loggingEnabled = false;
    // When logging, trip long strings (base64-encoded images) for readability
    this._trimLongStrings = false;
    // UID of the currently authenticated user.
    this._myUID = null;
    // Status of connection: authenticated or not.
    this._authenticated = false;
    // Login used in the last successful basic authentication
    this._login = null;
    // Token which can be used for login instead of login/password.
    this._authToken = null;
    // Counter of received packets
    this._inPacketCount = 0;
    // Counter for generating unique message IDs
    this._messageId = Math.floor((Math.random() * 0xFFFF) + 0xFFFF);
    // Information about the server, if connected
    this._serverInfo = null;
    // Push notification token. Called deviceToken for consistency with the Android SDK.
    this._deviceToken = null;
  
    // Cache of pending promises by message id.
    this._pendingPromises = {};
  
    // The Timeout object returned by the reject expired promises setInterval
    this._expirePromises = null;
  
  
    /** A connection object, see {@link Tinode.Connection}. */
    this._connection = new Connection(host_, apiKey_, transport_, secure_, true);
    // Console logger. Babel somehow fails to parse '...rest' parameter.
    this.logger = (str, ...args) => {
      if (this._loggingEnabled) {
        const d = new Date()
        const dateString = ('0' + d.getUTCHours()).slice(-2) + ':' +
          ('0' + d.getUTCMinutes()).slice(-2) + ':' +
          ('0' + d.getUTCSeconds()).slice(-2) + '.' +
          ('00' + d.getUTCMilliseconds()).slice(-3);
  
        console.log('[' + dateString + ']', str, args.join(' '));
      }
    }
    this._connection.logger = this.logger;
  
    // Tinode's cache of objects
    this._cache = {};
  
    const cachePut = this.cachePut = (type, name, obj) => {
      this._cache[type + ':' + name] = obj;
    }
  
    const cacheGet = this.cacheGet = (type, name) => {
      return this._cache[type + ':' + name];
    }
  
    const cacheDel = this.cacheDel = (type, name) => {
      delete this._cache[type + ':' + name];
    }
    // Enumerate all items in cache, call func for each item.
    // Enumeration stops if func returns true.
    const cacheMap = this.cacheMap = (func, context) => {
      for (let idx in this._cache) {
        if (func(this._cache[idx], idx, context)) {
          break;
        }
      }
    }
  
    // Make limited cache management available to topic.
    // Caching user.public only. Everything else is per-topic.
    this.attachCacheToTopic = (topic) => {
      topic._tinode = this;
  
      topic._cacheGetUser = (uid) => {
        const pub = cacheGet('user', uid);
        if (pub) {
          return {
            user: uid,
            public: mergeObj({}, pub)
          };
        }
        return undefined;
      };
      topic._cachePutUser = (uid, user) => {
        return cachePut('user', uid, mergeObj({}, user.public));
      };
      topic._cacheDelUser = (uid) => {
        return cacheDel('user', uid);
      };
      topic._cachePutSelf = () => {
        return cachePut('topic', topic.name, topic);
      }
      topic._cacheDelSelf = () => {
        return cacheDel('topic', topic.name);
      }
    }
  
    // Resolve or reject a pending promise.
    // Unresolved promises are stored in _pendingPromises.
    const execPromise = (id, code, onOK, errorText) => {
      const callbacks = this._pendingPromises[id];
      if (callbacks) {
        delete this._pendingPromises[id];
        if (code >= 200 && code < 400) {
          if (callbacks.resolve) {
            callbacks.resolve(onOK);
          }
        } else if (callbacks.reject) {
          callbacks.reject(new Error(errorText + " (" + code + ")"));
        }
      }
    }
  
    // Generator of default promises for sent packets.
    const makePromise = (id) => {
      let promise = null;
      if (id) {
        promise = new Promise((resolve, reject) => {
          // Stored callbacks will be called when the response packet with this Id arrives
          this._pendingPromises[id] = {
            'resolve': resolve,
            'reject': reject,
            'ts': new Date()
          };
        })
      }
      return promise;
    }
  
    // Generates unique message IDs
    const getNextUniqueId = this.getNextUniqueId = () => {
      return (this._messageId != 0) ? '' + this._messageId++ : undefined;
    }
  
    // Get User Agent string
    const getUserAgent = () => {
      return this._appName + ' (' + (this._browser ? this._browser + '; ' : '') + this._hwos + '); ' + LIBRARY;
    }
  
    // Generator of packets stubs
    this.initPacket = (type, topic) => {
      switch (type) {
        case 'hi':
          return {
            'hi': {
              'id': getNextUniqueId(),
              'ver': VERSION,
              'ua': getUserAgent(),
              'dev': this._deviceToken,
              'lang': this._humanLanguage,
              'platf': this._platform
            }
          };
  
        case 'acc':
          return {
            'acc': {
              'id': getNextUniqueId(),
              'user': null,
              'scheme': null,
              'secret': null,
              'login': false,
              'tags': null,
              'desc': {},
              'cred': {}
            }
          };
  
        case 'login':
          return {
            'login': {
              'id': getNextUniqueId(),
              'scheme': null,
              'secret': null
            }
          };
  
        case 'sub':
          return {
            'sub': {
              'id': getNextUniqueId(),
              'topic': topic,
              'set': {},
              'get': {}
            }
          };
  
        case 'leave':
          return {
            'leave': {
              'id': getNextUniqueId(),
              'topic': topic,
              'unsub': false
            }
          };
  
        case 'pub':
          return {
            'pub': {
              'id': getNextUniqueId(),
              'topic': topic,
              'noecho': false,
              'head': null,
              'content': {}
            }
          };
  
        case 'get':
          return {
            'get': {
              'id': getNextUniqueId(),
              'topic': topic,
              'what': null, // data, sub, desc, space separated list; unknown strings are ignored
              'desc': {},
              'sub': {},
              'data': {}
            }
          };
  
        case 'set':
          return {
            'set': {
              'id': getNextUniqueId(),
              'topic': topic,
              'desc': {},
              'sub': {},
              'tags': []
            }
          };
  
        case 'del':
          return {
            'del': {
              'id': getNextUniqueId(),
              'topic': topic,
              'what': null,
              'delseq': null,
              'user': null,
              'hard': false
            }
          };
  
        case 'note':
          return {
            'note': {
              // no id by design
              'topic': topic,
              'what': null, // one of "recv", "read", "kp"
              'seq': undefined // the server-side message id aknowledged as received or read
            }
          };
  
        default:
          throw new Error("Unknown packet type requested: " + type);
      }
    }
  
    // Send a packet. If packet id is provided return a promise.
    this.send = (pkt, id) => {
      let promise;
      if (id) {
        promise = makePromise(id);
      }
      pkt = simplify(pkt);
      let msg = JSON.stringify(pkt);
      this.logger("out: " + (this._trimLongStrings ? JSON.stringify(pkt, jsonLoggerHelper) : msg));
      try {
        this._connection.sendText(msg);
      } catch (err) {
        // If sendText throws, wrap the error in a promise or rethrow.
        if (id) {
          execPromise(id, NETWORK_ERROR, null, err.message);
        } else {
          throw err;
        }
      }
      return promise;
    }
  
    // On successful login save server-provided data.
    this.loginSuccessful = (ctrl) => {
      if (!ctrl.params || !ctrl.params.user) {
        return;
      }
      // This is a response to a successful login,
      // extract UID and security token, save it in Tinode module
      this._myUID = ctrl.params.user;
      this._authenticated = (ctrl && ctrl.code >= 200 && ctrl.code < 300);
      if (ctrl.params && ctrl.params.token && ctrl.params.expires) {
        this._authToken = {
          token: ctrl.params.token,
          expires: new Date(ctrl.params.expires)
        };
      } else {
        this._authToken = null;
      }
  
      if (this.onLogin) {
        this.onLogin(ctrl.code, ctrl.text);
      }
    }
  
    // The main message dispatcher.
    this._connection.onMessage = (data) => {
      // Skip empty response. This happens when LP times out.
      if (!data) return;
  
      this._inPacketCount++;
  
      // Send raw message to listener
      if (this.onRawMessage) {
        this.onRawMessage(data);
      }
  
      if (data === '0') {
        // Server response to a network probe.
        if (this.onNetworkProbe) {
          this.onNetworkProbe();
        }
        // No processing is necessary.
        return;
      }
  
      let pkt = JSON.parse(data, jsonParseHelper);
      if (!pkt) {
        this.logger("in: " + data);
        this.logger("ERROR: failed to parse data");
      } else {
        this.logger("in: " + (this._trimLongStrings ? JSON.stringify(pkt, jsonLoggerHelper) : data));
  
        // Send complete packet to listener
        if (this.onMessage) {
          this.onMessage(pkt);
        }
  
        if (pkt.ctrl) {
          // Handling {ctrl} message
          if (this.onCtrlMessage) {
            this.onCtrlMessage(pkt.ctrl);
          }
  
          // Resolve or reject a pending promise, if any
          if (pkt.ctrl.id) {
            execPromise(pkt.ctrl.id, pkt.ctrl.code, pkt.ctrl, pkt.ctrl.text);
          }
          setTimeout(() => {
            if (pkt.ctrl.code == 205 && pkt.ctrl.text == 'evicted') {
              // User evicted from topic.
              const topic = cacheGet('topic', pkt.ctrl.topic);
              if (topic) {
                topic._resetSub();
                if (pkt.ctrl.params && pkt.ctrl.params.unsub) {
                  topic._gone();
                }
              }
            } else if (pkt.ctrl.params && pkt.ctrl.params.what == 'data') {
              // All messages received: "params":{"count":11,"what":"data"},
              const topic = cacheGet('topic', pkt.ctrl.topic);
              if (topic) {
                topic._allMessagesReceived(pkt.ctrl.params.count);
              }
            } else if (pkt.ctrl.params && pkt.ctrl.params.what == 'sub') {
              // The topic has no subscriptions.
              const topic = cacheGet('topic', pkt.ctrl.topic);
              if (topic) {
                // Trigger topic.onSubsUpdated.
                topic._processMetaSub([]);
              }
            }
          }, 0);
        } else {
          setTimeout(() => {
            if (pkt.meta) {
              // Handling a {meta} message.
  
              // Preferred API: Route meta to topic, if one is registered
              const topic = cacheGet('topic', pkt.meta.topic);
              if (topic) {
                topic._routeMeta(pkt.meta);
              }
  
              if (pkt.meta.id) {
                execPromise(pkt.meta.id, 200, pkt.meta, 'META');
              }
  
              // Secondary API: callback
              if (this.onMetaMessage) {
                this.onMetaMessage(pkt.meta);
              }
            } else if (pkt.data) {
              // Handling {data} message
  
              // Preferred API: Route data to topic, if one is registered
              const topic = cacheGet('topic', pkt.data.topic);
              if (topic) {
                topic._routeData(pkt.data);
              }
  
              // Secondary API: Call callback
              if (this.onDataMessage) {
                this.onDataMessage(pkt.data);
              }
            } else if (pkt.pres) {
              // Handling {pres} message
  
              // Preferred API: Route presence to topic, if one is registered
              const topic = cacheGet('topic', pkt.pres.topic);
              if (topic) {
                topic._routePres(pkt.pres);
              }
  
              // Secondary API - callback
              if (this.onPresMessage) {
                this.onPresMessage(pkt.pres);
              }
            } else if (pkt.info) {
              // {info} message - read/received notifications and key presses
  
              // Preferred API: Route {info}} to topic, if one is registered
              const topic = cacheGet('topic', pkt.info.topic);
              if (topic) {
                topic._routeInfo(pkt.info);
              }
  
              // Secondary API - callback
              if (this.onInfoMessage) {
                this.onInfoMessage(pkt.info);
              }
            } else {
              this.logger("ERROR: Unknown packet received.");
            }
          }, 0);
        }
      }
    }
  
    // Ready to start sending.
    this._connection.onOpen = () => {
      if (!this._expirePromises) {
        // Reject promises which have not been resolved for too long.
        this._expirePromises = setInterval(() => {
          const err = new Error("Timeout (504)");
          const expires = new Date(new Date().getTime() - EXPIRE_PROMISES_TIMEOUT);
          for (let id in this._pendingPromises) {
            let callbacks = this._pendingPromises[id];
            if (callbacks && callbacks.ts < expires) {
              this.logger("Promise expired", id);
              delete this._pendingPromises[id];
              if (callbacks.reject) {
                callbacks.reject(err);
              }
            }
          }
        }, EXPIRE_PROMISES_PERIOD);
      }
      this.hello();
    }
  
    // Wrapper for the reconnect iterator callback.
    this._connection.onAutoreconnectIteration = (timeout, promise) => {
      if (this.onAutoreconnectIteration) {
        this.onAutoreconnectIteration(timeout, promise);
      }
    }
  
    this._connection.onDisconnect = (err, code) => {
      this._inPacketCount = 0;
      this._serverInfo = null;
      this._authenticated = false;
  
      if (this._expirePromises) {
        clearInterval(this._expirePromises);
        this._expirePromises = null;
      }
  
      // Mark all topics as unsubscribed
      cacheMap((obj, key) => {
        if (key.lastIndexOf('topic:', 0) === 0) {
          obj._resetSub();
        }
      });
  
      // Reject all pending promises
      for (let key in this._pendingPromises) {
        let callbacks = this._pendingPromises[key];
        if (callbacks && callbacks.reject) {
          callbacks.reject(err);
        }
      }
      this._pendingPromises = {};
  
      if (this.onDisconnect) {
        this.onDisconnect(err);
      }
    }
  };
  
  // Static methods.
  
  /**
   * Helper method to package account credential.
   * @memberof Tinode
   * @static
   *
   * @param {String|Object} meth - validation method or object with validation data.
   * @param {String=} val - validation value (e.g. email or phone number).
   * @param {Object=} params - validation parameters.
   * @param {String=} resp - validation response.
   *
   * @returns {Array} array with a single credentail or null if no valid credentials were given.
   */
  Tinode.credential = function(meth, val, params, resp) {
    if (typeof meth == 'object') {
      ({
        val,
        params,
        resp,
        meth
      } = meth);
    }
    if (meth && (val || resp)) {
      return [{
        'meth': meth,
        'val': val,
        'resp': resp,
        'params': params
      }];
    }
    return null;
  };
  
  /**
   * Determine topic type from topic's name: grp, p2p, me, fnd.
   * @memberof Tinode
   * @static
   *
   * @param {string} name - Name of the topic to test.
   * @returns {string} One of <tt>'me'</tt>, <tt>'grp'</tt>, <tt>'p2p'</tt> or <tt>undefined</tt>.
   */
  Tinode.topicType = function(name) {
    const types = {
      'me': 'me',
      'fnd': 'fnd',
      'grp': 'grp',
      'new': 'grp',
      'nch': 'grp',
      'chn': 'grp',
      'usr': 'p2p',
      'sys': 'sys'
    };
    return types[(typeof name == 'string') ? name.substring(0, 3) : 'xxx'];
  };
  
  /**
   * Check if the given topic name is a name of a group topic.
   * @memberof Tinode
   * @static
   *
   * @param {string} name - Name of the topic to test.
   * @returns {boolean} true if the name is a name of a group topic, false otherwise.
   */
  Tinode.isGroupTopicName = function(name) {
    return Tinode.topicType(name) === 'grp';
  };
  
  /**
   * Check if the given topic name is a name of a p2p topic.
   * @memberof Tinode
   * @static
   *
   * @param {string} name - Name of the topic to test.
   * @returns {boolean} true if the name is a name of a p2p topic, false otherwise.
   */
  Tinode.isP2PTopicName = function(name) {
    return Tinode.topicType(name) === 'p2p';
  };
  
  /**
   * Check if the topic name is a name of a new topic.
   * @memberof Tinode
   * @static
   *
   * @param {string} name - topic name to check.
   * @returns {boolean} true if the name is a name of a new topic.
   */
  Tinode.isNewGroupTopicName = function(name) {
    return (typeof name == 'string') &&
      (name.substring(0, 3) == TOPIC_NEW || name.substring(0, 3) == TOPIC_NEW_CHAN);
  };
  
  /**
   * Check if the topic name is a name of a channel.
   * @memberof Tinode
   * @static
   *
   * @param {string} name - topic name to check.
   * @returns {boolean} true if the name is a name of a channel.
   */
  Tinode.isChannelTopicName = function(name) {
    return (typeof name == 'string') &&
      (name.substring(0, 3) == TOPIC_CHAN || name.substring(0, 3) == TOPIC_NEW_CHAN);
  };
  
  /**
   * Return information about the current version of this Tinode client library.
   * @memberof Tinode
   * @static
   *
   * @returns {string} semantic version of the library, e.g. '0.15.5-rc1'.
   */
  Tinode.getVersion = function() {
    return VERSION;
  };
  
  /**
   * To use Tinode in a non browser context, supply WebSocket and XMLHttpRequest providers.
   * @static
   * @memberof Tinode
   * @param wsProvider WebSocket provider, e.g. for nodeJS require('ws').
   * @param xhrProvider XMLHttpRequest provider, e.g. for node require('xhr').
   */
  Tinode.setNetworkProviders = function(wsProvider, xhrProvider) {
    WebSocketProvider = wsProvider;
    XHRProvider = xhrProvider;
  };
  
  /**
   * Return information about the current name and version of this Tinode library.
   * @memberof Tinode
   * @static
   *
   * @returns {string} the name of the library and it's version.
   */
  Tinode.getLibrary = function() {
    return LIBRARY;
  };
  
  // Exported constants
  Tinode.MESSAGE_STATUS_NONE = MESSAGE_STATUS_NONE;
  Tinode.MESSAGE_STATUS_QUEUED = MESSAGE_STATUS_QUEUED;
  Tinode.MESSAGE_STATUS_SENDING = MESSAGE_STATUS_SENDING;
  Tinode.MESSAGE_STATUS_FAILED = MESSAGE_STATUS_FAILED;
  Tinode.MESSAGE_STATUS_SENT = MESSAGE_STATUS_SENT;
  Tinode.MESSAGE_STATUS_RECEIVED = MESSAGE_STATUS_RECEIVED;
  Tinode.MESSAGE_STATUS_READ = MESSAGE_STATUS_READ;
  Tinode.MESSAGE_STATUS_TO_ME = MESSAGE_STATUS_TO_ME;
  
  // Unicode [del] symbol.
  Tinode.DEL_CHAR = '\u2421';
  
  /**
   * Check if the given string represents NULL value as defined by Tinode ('\u2421').
   * @memberof Tinode
   * @static
   *
   * @param {String} str - string to check for null value.
   *
   * @returns {boolean} true if string represents NULL value, false otherwise.
   */
  Tinode.isNullValue = function(str) {
    return str === Tinode.DEL_CHAR;
  };
  
  /**
   * Check if the given URL string is a relative URL.
   * Check for cases like:
   *  'http://example.com'
   *  ' http://example.com'
   *  '//example.com/'
   *  'http:example.com'
   *  'http:/example.com'
   * @memberof Tinode
   * @static
   *
   * @param {String} url - URL string to check.
   *
   * @returns {boolean} true if the URL is relative, false otherwise.
   */
  Tinode.isRelativeURL = function(url) {
    return !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
  };
  
  // Names of keys to server-provided configuration limits.
  Tinode.MAX_MESSAGE_SIZE = 'maxMessageSize';
  Tinode.MAX_SUBSCRIBER_COUNT = 'maxSubscriberCount';
  Tinode.MAX_TAG_COUNT = 'maxTagCount';
  Tinode.MAX_FILE_UPLOAD_SIZE = 'maxFileUploadSize';
  
  // Public methods;
  Tinode.prototype = {
    /**
     * Connect to the server.
     * @memberof Tinode#
     *
     * @param {String} host_ - name of the host to connect to.
     *
     * @return {Promise} Promise resolved/rejected when the connection call completes:
     * <tt>resolve()</tt> is called without parameters, <tt>reject()</tt> receives the <tt>Error</tt> as a single parameter.
     */
    connect: function(host_) {
      return this._connection.connect(host_);
    },
  
    /**
     * Attempt to reconnect to the server immediately.
     * @memberof Tinode#
     *
     * @param {String} force - reconnect even if there is a connection already.
     */
    reconnect: function(force) {
      this._connection.reconnect(force);
    },
  
    /**
     * Disconnect from the server.
     * @memberof Tinode#
     */
    disconnect: function() {
      this._connection.disconnect();
    },
  
    /**
     * Send a network probe message to make sure the connection is alive.
     * @memberof Tinode#
     */
    networkProbe: function() {
      this._connection.probe();
    },
  
    /**
     * Check for live connection to server.
     * @memberof Tinode#
     *
     * @returns {Boolean} true if there is a live connection, false otherwise.
     */
    isConnected: function() {
      return this._connection.isConnected();
    },
  
    /**
     * Check if connection is authenticated (last login was successful).
     * @memberof Tinode#
     * @returns {boolean} true if authenticated, false otherwise.
     */
    isAuthenticated: function() {
      return this._authenticated;
    },
  
    /**
     * Add API key and auth token to the relative URL making it usable for getting data
     * from the server in a simple GET request.
     * @memberof Tinode#
     *
     * @param {String} URL to wrap.
     * @returns {string} URL with appended API key and token, if valid token is present.
     */
    authorizeURL: function(url) {
      if (!url) {
        return url;
      }
  
      if (Tinode.isRelativeURL(url)) {
        // Fake base to make the relative URL parseable.
        const base = 'scheme://host/';
        const parsed = new URL(url, base);
        if (this._apiKey) {
          parsed.searchParams.append('apikey', this._apiKey);
        }
        if (this._authToken.token) {
          parsed.searchParams.append('auth', 'token');
          parsed.searchParams.append('secret', this._authToken.token);
        }
        // Convert back to string and strip fake base URL except for the root slash.
        url = parsed.toString().substring(base.length - 1);
      }
      return url;
    },
  
    /**
     * @typedef AccountParams
     * @memberof Tinode
     * @type Object
     * @property {Tinode.DefAcs=} defacs - Default access parameters for user's <tt>me</tt> topic.
     * @property {Object=} public - Public application-defined data exposed on <tt>me</tt> topic.
     * @property {Object=} private - Private application-defined data accessible on <tt>me</tt> topic.
     * @property {Array} tags - array of string tags for user discovery.
     * @property {string=} token - authentication token to use.
     */
    /**
     * @typedef DefAcs
     * @memberof Tinode
     * @type Object
     * @property {string=} auth - Access mode for <tt>me</tt> for authenticated users.
     * @property {string=} anon - Access mode for <tt>me</tt>  anonymous users.
     */
  
    /**
     * Create or update an account.
     * @memberof Tinode#
     *
     * @param {String} uid - User id to update
     * @param {String} scheme - Authentication scheme; <tt>"basic"</tt> and <tt>"anonymous"</tt> are the currently supported schemes.
     * @param {String} secret - Authentication secret, assumed to be already base64 encoded.
     * @param {Boolean=} login - Use new account to authenticate current session
     * @param {Tinode.AccountParams=} params - User data to pass to the server.
     */
    account: function(uid, scheme, secret, login, params) {
      const pkt = this.initPacket('acc');
      pkt.acc.user = uid;
      pkt.acc.scheme = scheme;
      pkt.acc.secret = secret;
      // Log in to the new account using selected scheme
      pkt.acc.login = login;
  
      if (params) {
        pkt.acc.desc.defacs = params.defacs;
        pkt.acc.desc.public = params.public;
        pkt.acc.desc.private = params.private;
  
        pkt.acc.tags = params.tags;
        pkt.acc.cred = params.cred;
  
        pkt.acc.token = params.token;
      }
  
      return this.send(pkt, pkt.acc.id);
    },
  
    /**
     * Create a new user. Wrapper for {@link Tinode#account}.
     * @memberof Tinode#
     *
     * @param {String} scheme - Authentication scheme; <tt>"basic"</tt> is the only currently supported scheme.
     * @param {String} secret - Authentication.
     * @param {Boolean=} login - Use new account to authenticate current session
     * @param {Tinode.AccountParams=} params - User data to pass to the server.
     *
     * @returns {Promise} Promise which will be resolved/rejected when server reply is received.
     */
    createAccount: function(scheme, secret, login, params) {
      let promise = this.account(USER_NEW, scheme, secret, login, params);
      if (login) {
        promise = promise.then((ctrl) => {
          this.loginSuccessful(ctrl);
          return ctrl;
        });
      }
      return promise;
    },
  
    /**
     * Create user with 'basic' authentication scheme and immediately
     * use it for authentication. Wrapper for {@link Tinode#account}.
     * @memberof Tinode#
     *
     * @param {string} username - Login to use for the new account.
     * @param {string} password - User's password.
     * @param {Tinode.AccountParams=} params - User data to pass to the server.
     *
     * @returns {Promise} Promise which will be resolved/rejected when server reply is received.
     */
    createAccountBasic: function(username, password, params) {
      // Make sure we are not using 'null' or 'undefined';
      username = username || '';
      password = password || '';
      return this.createAccount('basic',
        b64EncodeUnicode(username + ':' + password), true, params);
    },
  
    /**
     * Update user's credentials for 'basic' authentication scheme. Wrapper for {@link Tinode#account}.
     * @memberof Tinode#
     *
     * @param {string} uid - User ID to update.
     * @param {string} username - Login to use for the new account.
     * @param {string} password - User's password.
     * @param {Tinode.AccountParams=} params - data to pass to the server.
     *
     * @returns {Promise} Promise which will be resolved/rejected when server reply is received.
     */
    updateAccountBasic: function(uid, username, password, params) {
      // Make sure we are not using 'null' or 'undefined';
      username = username || '';
      password = password || '';
      return this.account(uid, 'basic',
        b64EncodeUnicode(username + ':' + password), false, params);
    },
  
    /**
     * Send handshake to the server.
     * @memberof Tinode#
     *
     * @returns {Promise} Promise which will be resolved/rejected when server reply is received.
     */
    hello: function() {
      const pkt = this.initPacket('hi');
  
      return this.send(pkt, pkt.hi.id)
        .then((ctrl) => {
          // Reset backoff counter on successful connection.
          this._connection.backoffReset();
  
          // Server response contains server protocol version, build, constraints,
          // session ID for long polling. Save them.
          if (ctrl.params) {
            this._serverInfo = ctrl.params;
          }
  
          if (this.onConnect) {
            this.onConnect();
          }
  
          return ctrl;
        }).catch((err) => {
          this._connection.reconnect(true);
  
          if (this.onDisconnect) {
            this.onDisconnect(err);
          }
        });
    },
  
    /**
     * Set or refresh the push notifications/device token. If the client is connected,
     * the deviceToken can be sent to the server.
     *
     * @memberof Tinode#
     * @param {string} dt - token obtained from the provider.
     * @param {boolean} sendToServer - if true, send dt to server immediately.
     *
     * @returns true if attempt was made to send the token to the server.
     */
    setDeviceToken: function(dt, sendToServer) {
      let sent = false;
      if (dt && dt != this._deviceToken) {
        this._deviceToken = dt;
        if (sendToServer && this.isConnected() && this.isAuthenticated()) {
          this.send({
            'hi': {
              'dev': dt
            }
          });
          sent = true;
        }
      }
      return sent;
    },
  
    /**
     * Authenticate current session.
     * @memberof Tinode#
     *
     * @param {String} scheme - Authentication scheme; <tt>"basic"</tt> is the only currently supported scheme.
     * @param {String} secret - Authentication secret, assumed to be already base64 encoded.
     *
     * @returns {Promise} Promise which will be resolved/rejected when server reply is received.
     */
    login: function(scheme, secret, cred) {
      const pkt = this.initPacket('login');
      pkt.login.scheme = scheme;
      pkt.login.secret = secret;
      pkt.login.cred = cred;
  
      return this.send(pkt, pkt.login.id)
        .then((ctrl) => {
          this.loginSuccessful(ctrl);
          return ctrl;
        });
    },
  
    /**
     * Wrapper for {@link Tinode#login} with basic authentication
     * @memberof Tinode#
     *
     * @param {String} uname - User name.
     * @param {String} password  - Password.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    loginBasic: function(uname, password, cred) {
      return this.login('basic', b64EncodeUnicode(uname + ':' + password), cred)
        .then((ctrl) => {
          this._login = uname;
          return ctrl;
        });
    },
  
    /**
     * Wrapper for {@link Tinode#login} with token authentication
     * @memberof Tinode#
     *
     * @param {String} token - Token received in response to earlier login.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    loginToken: function(token, cred) {
      return this.login('token', token, cred);
    },
  
    /**
     * Send a request for resetting an authentication secret.
     * @memberof Tinode#
     *
     * @param {String} scheme - authentication scheme to reset.
     * @param {String} method - method to use for resetting the secret, such as "email" or "tel".
     * @param {String} value - value of the credential to use, a specific email address or a phone number.
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving the server reply.
     */
    requestResetAuthSecret: function(scheme, method, value) {
      return this.login('reset', b64EncodeUnicode(scheme + ':' + method + ':' + value));
    },
  
    /**
     * @typedef AuthToken
     * @memberof Tinode
     * @type Object
     * @property {String} token - Token value.
     * @property {Date} expires - Token expiration time.
     */
    /**
     * Get stored authentication token.
     * @memberof Tinode#
     *
     * @returns {Tinode.AuthToken} authentication token.
     */
    getAuthToken: function() {
      if (this._authToken && (this._authToken.expires.getTime() > Date.now())) {
        return this._authToken;
      } else {
        this._authToken = null;
      }
      return null;
    },
  
    /**
     * Application may provide a saved authentication token.
     * @memberof Tinode#
     *
     * @param {Tinode.AuthToken} token - authentication token.
     */
    setAuthToken: function(token) {
      this._authToken = token;
    },
  
    /**
     * @typedef SetParams
     * @memberof Tinode
     * @property {Tinode.SetDesc=} desc - Topic initialization parameters when creating a new topic or a new subscription.
     * @property {Tinode.SetSub=} sub - Subscription initialization parameters.
     */
    /**
     * @typedef SetDesc
     * @memberof Tinode
     * @property {Tinode.DefAcs=} defacs - Default access mode.
     * @property {Object=} public - Free-form topic description, publically accessible.
     * @property {Object=} private - Free-form topic descriptionaccessible only to the owner.
     */
    /**
     * @typedef SetSub
     * @memberof Tinode
     * @property {String=} user - UID of the user affected by the request. Default (empty) - current user.
     * @property {String=} mode - User access mode, either requested or assigned dependent on context.
     * @property {Object=} info - Free-form payload to pass to the invited user or topic manager.
     */
    /**
     * Parameters passed to {@link Tinode#subscribe}.
     *
     * @typedef SubscriptionParams
     * @memberof Tinode
     * @property {Tinode.SetParams=} set - Parameters used to initialize topic
     * @property {Tinode.GetQuery=} get - Query for fetching data from topic.
     */
  
    /**
     * Send a topic subscription request.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to subscribe to.
     * @param {Tinode.GetQuery=} getParams - Optional subscription metadata query
     * @param {Tinode.SetParams=} setParams - Optional initialization parameters
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    subscribe: function(topicName, getParams, setParams) {
      const pkt = this.initPacket('sub', topicName)
      if (!topicName) {
        topicName = TOPIC_NEW;
      }
  
      pkt.sub.get = getParams;
  
      if (setParams) {
        if (setParams.sub) {
          pkt.sub.set.sub = setParams.sub;
        }
  
        if (setParams.desc) {
          if (Tinode.isNewGroupTopicName(topicName)) {
            // Full set.desc params are used for new topics only
            pkt.sub.set.desc = setParams.desc;
          } else if (Tinode.isP2PTopicName(topicName) && setParams.desc.defacs) {
            // Use optional default permissions only.
            pkt.sub.set.desc = {
              defacs: setParams.desc.defacs
            };
          }
        }
  
        if (setParams.tags) {
          pkt.sub.set.tags = setParams.tags;
        }
      }
  
      return this.send(pkt, pkt.sub.id);
    },
  
    /**
     * Detach and optionally unsubscribe from the topic
     * @memberof Tinode#
     *
     * @param {String} topic - Topic to detach from.
     * @param {Boolean} unsub - If <tt>true</tt>, detach and unsubscribe, otherwise just detach.
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    leave: function(topic, unsub) {
      const pkt = this.initPacket('leave', topic);
      pkt.leave.unsub = unsub;
  
      return this.send(pkt, pkt.leave.id);
    },
  
    /**
     * Create message draft without sending it to the server.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to publish to.
     * @param {Object} data - Payload to publish.
     * @param {Boolean=} noEcho - If <tt>true</tt>, tell the server not to echo the message to the original session.
     *
     * @returns {Object} new message which can be sent to the server or otherwise used.
     */
    createMessage: function(topic, data, noEcho) {
      const pkt = this.initPacket('pub', topic);
  
      let dft = typeof data == 'string' ? Drafty.parse(data) : data;
      if (dft && !Drafty.isPlainText(dft)) {
        pkt.pub.head = {
          mime: Drafty.getContentType()
        };
        data = dft;
      }
      pkt.pub.noecho = noEcho;
      pkt.pub.content = data;
  
      return pkt.pub;
    },
  
    /**
     * Publish {data} message to topic.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to publish to.
     * @param {Object} data - Payload to publish.
     * @param {Boolean=} noEcho - If <tt>true</tt>, tell the server not to echo the message to the original session.
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    publish: function(topic, data, noEcho) {
      return this.publishMessage(
        this.createMessage(topic, data, noEcho)
      );
    },
  
    /**
     * Publish message to topic. The message should be created by {@link Tinode#createMessage}.
     * @memberof Tinode#
     *
     * @param {Object} pub - Message to publish.
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    publishMessage: function(pub) {
      // Make a shallow copy. Needed in order to clear locally-assigned temp values;
      pub = Object.assign({}, pub);
      pub.seq = undefined;
      pub.from = undefined;
      pub.ts = undefined;
      return this.send({
        pub: pub
      }, pub.id);
    },
  
    /**
     * @typedef GetQuery
     * @type Object
     * @memberof Tinode
     * @property {Tinode.GetOptsType=} desc - If provided (even if empty), fetch topic description.
     * @property {Tinode.GetOptsType=} sub - If provided (even if empty), fetch topic subscriptions.
     * @property {Tinode.GetDataType=} data - If provided (even if empty), get messages.
     */
  
    /**
     * @typedef GetOptsType
     * @type Object
     * @memberof Tinode
     * @property {Date=} ims - "If modified since", fetch data only it was was modified since stated date.
     * @property {Number=} limit - Maximum number of results to return. Ignored when querying topic description.
     */
  
    /**
     * @typedef GetDataType
     * @type Object
     * @memberof Tinode
     * @property {Number=} since - Load messages with seq id equal or greater than this value.
     * @property {Number=} before - Load messages with seq id lower than this number.
     * @property {Number=} limit - Maximum number of results to return.
     */
  
    /**
     * Request topic metadata
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to query.
     * @param {Tinode.GetQuery} params - Parameters of the query. Use {Tinode.MetaGetBuilder} to generate.
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    getMeta: function(topic, params) {
      const pkt = this.initPacket('get', topic);
  
      pkt.get = mergeObj(pkt.get, params);
  
      return this.send(pkt, pkt.get.id);
    },
  
    /**
     * Update topic's metadata: description, subscribtions.
     * @memberof Tinode#
     *
     * @param {String} topic - Topic to update.
     * @param {Tinode.SetParams} params - topic metadata to update.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    setMeta: function(topic, params) {
      const pkt = this.initPacket('set', topic);
      const what = [];
  
      if (params) {
        ['desc', 'sub', 'tags', 'cred'].map(function(key) {
          if (params.hasOwnProperty(key)) {
            what.push(key);
            pkt.set[key] = params[key];
          }
        });
      }
  
      if (what.length == 0) {
        return Promise.reject(new Error("Invalid {set} parameters"));
      }
  
      return this.send(pkt, pkt.set.id);
    },
  
    /**
     * Range of message IDs to delete.
     *
     * @typedef DelRange
     * @type Object
     * @memberof Tinode
     * @property {Number} low - low end of the range, inclusive (closed).
     * @property {Number=} hi - high end of the range, exclusive (open).
     */
    /**
     * Delete some or all messages in a topic.
     * @memberof Tinode#
     *
     * @param {String} topic - Topic name to delete messages from.
     * @param {Tinode.DelRange[]} list - Ranges of message IDs to delete.
     * @param {Boolean=} hard - Hard or soft delete
     *
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delMessages: function(topic, ranges, hard) {
      const pkt = this.initPacket('del', topic);
  
      pkt.del.what = 'msg';
      pkt.del.delseq = ranges;
      pkt.del.hard = hard;
  
      return this.send(pkt, pkt.del.id);
    },
  
    /**
     * Delete the topic alltogether. Requires Owner permission.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to delete
     * @param {Boolean} hard - hard-delete topic.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delTopic: function(topic, hard) {
      const pkt = this.initPacket('del', topic);
      pkt.del.what = 'topic';
      pkt.del.hard = hard;
  
      return this.send(pkt, pkt.del.id).then((ctrl) => {
        this.cacheDel('topic', topic);
        return this.ctrl;
      });
    },
  
    /**
     * Delete subscription. Requires Share permission.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to delete
     * @param {String} user - User ID to remove.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delSubscription: function(topic, user) {
      const pkt = this.initPacket('del', topic);
      pkt.del.what = 'sub';
      pkt.del.user = user;
  
      return this.send(pkt, pkt.del.id);
    },
  
    /**
     * Delete credential. Always sent on 'me' topic.
     * @memberof Tinode#
     *
     * @param {String} method - validation method such as 'email' or 'tel'.
     * @param {String} value - validation value, i.e. 'alice@example.com'.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delCredential: function(method, value) {
      const pkt = this.initPacket('del', TOPIC_ME);
      pkt.del.what = 'cred';
      pkt.del.cred = {
        meth: method,
        val: value
      };
  
      return this.send(pkt, pkt.del.id);
    },
  
    /**
     * Request to delete account of the current user.
     * @memberof Tinode#
     *
     * @param {Boolean} hard - hard-delete user.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delCurrentUser: function(hard) {
      const pkt = this.initPacket('del', null);
      pkt.del.what = 'user';
      pkt.del.hard = hard;
  
      return this.send(pkt, pkt.del.id).then((ctrl) => {
        this._myUID = null;
      });
    },
  
    /**
     * Notify server that a message or messages were read or received. Does NOT return promise.
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic where the mesage is being aknowledged.
     * @param {String} what - Action being aknowledged, either "read" or "recv".
     * @param {Number} seq - Maximum id of the message being acknowledged.
     */
    note: function(topic, what, seq) {
      if (seq <= 0 || seq >= LOCAL_SEQID) {
        throw new Error("Invalid message id " + seq);
      }
  
      const pkt = this.initPacket('note', topic);
      pkt.note.what = what;
      pkt.note.seq = seq;
      this.send(pkt);
    },
  
    /**
     * Broadcast a key-press notification to topic subscribers. Used to show
     * typing notifications "user X is typing...".
     * @memberof Tinode#
     *
     * @param {String} topic - Name of the topic to broadcast to.
     */
    noteKeyPress: function(topic) {
      const pkt = this.initPacket('note', topic);
      pkt.note.what = 'kp';
      this.send(pkt);
    },
  
    /**
     * Get a named topic, either pull it from cache or create a new instance.
     * There is a single instance of topic for each name.
     * @memberof Tinode#
     *
     * @param {String} name - Name of the topic to get.
     * @returns {Tinode.Topic} Requested or newly created topic or <tt>undefined</tt> if topic name is invalid.
     */
    getTopic: function(name) {
      let topic = this.cacheGet('topic', name);
      if (!topic && name) {
        if (name == TOPIC_ME) {
          topic = new TopicMe();
        } else if (name == TOPIC_FND) {
          topic = new TopicFnd();
        } else {
          topic = new Topic(name);
        }
        // topic._new = false;
        this.cachePut('topic', name, topic);
        this.attachCacheToTopic(topic);
      }
      return topic;
    },
  
    /**
     * Check if named topic is already present in cache.
     * @memberof Tinode#
     *
     * @param {String} name - Name of the topic to check.
     * @returns {Boolean} true if topic is found in cache, false oterwise.
     */
    isTopicCached: function(name) {
      return !!this.cacheGet('topic', name);
    },
  
    /**
     * Instantiate a new group topic. An actual name will be assigned by the server
     * on {@link Tinode.Topic.subscribe}.
     * @memberof Tinode#
     *
     * @param {Tinode.Callbacks} callbacks - Object with callbacks for various events.
     * @returns {Tinode.Topic} Newly created topic.
     */
    newTopic: function(callbacks) {
      const topic = new Topic(TOPIC_NEW, callbacks);
      this.attachCacheToTopic(topic);
      return topic;
    },
  
    /**
     * Instantiate a new channel-enabled group topic. An actual name will be assigned by the server
     * on {@link Tinode.Topic.subscribe}.
     * @memberof Tinode#
     *
     * @param {Tinode.Callbacks} callbacks - Object with callbacks for various events.
     * @returns {Tinode.Topic} Newly created topic.
     */
    newChannel: function(callbacks) {
      const topic = new Topic(TOPIC_NEW_CHAN, callbacks);
      this.attachCacheToTopic(topic);
      return topic;
    },
  
    /**
     * Generate unique name like 'new123456' suitable for creating a new group topic.
     * @memberof Tinode#
     *
     * @param {Boolean} isChan - if the topic is channel-enabled.
     * @returns {string} name which can be used for creating a new group topic.
     */
    newGroupTopicName: function(isChan) {
      return (isChan ? TOPIC_NEW_CHAN : TOPIC_NEW) + this.getNextUniqueId();
    },
  
    /**
     * Instantiate a new P2P topic with a given peer.
     * @memberof Tinode#
     *
     * @param {string} peer - UID of the peer to start topic with.
     * @param {Tinode.Callbacks} callbacks - Object with callbacks for various events.
     * @returns {Tinode.Topic} Newly created topic.
     */
    newTopicWith: function(peer, callbacks) {
      const topic = new Topic(peer, callbacks);
      this.attachCacheToTopic(topic);
      return topic;
    },
  
    /**
     * Instantiate 'me' topic or get it from cache.
     * @memberof Tinode#
     *
     * @returns {Tinode.TopicMe} Instance of 'me' topic.
     */
    getMeTopic: function() {
      return this.getTopic(TOPIC_ME);
    },
  
    /**
     * Instantiate 'fnd' (find) topic or get it from cache.
     * @memberof Tinode#
     *
     * @returns {Tinode.Topic} Instance of 'fnd' topic.
     */
    getFndTopic: function() {
      return this.getTopic(TOPIC_FND);
    },
  
    /**
     * Create a new LargeFileHelper instance
     * @memberof Tinode#
     *
     * @returns {Tinode.LargeFileHelper} instance of a LargeFileHelper.
     */
    getLargeFileHelper: function() {
      return new LargeFileHelper(this);
    },
  
    /**
     * Get the UID of the the current authenticated user.
     * @memberof Tinode#
     * @returns {string} UID of the current user or <tt>undefined</tt> if the session is not yet authenticated or if there is no session.
     */
    getCurrentUserID: function() {
      return this._myUID;
    },
  
    /**
     * Check if the given user ID is equal to the current user's UID.
     * @memberof Tinode#
     * @param {string} uid - UID to check.
     * @returns {boolean} true if the given UID belongs to the current logged in user.
     */
    isMe: function(uid) {
      return this._myUID === uid;
    },
  
    /**
     * Get login used for last successful authentication.
     * @memberof Tinode#
     * @returns {string} login last used successfully or <tt>undefined</tt>.
     */
    getCurrentLogin: function() {
      return this._login;
    },
  
    /**
     * Return information about the server: protocol version and build timestamp.
     * @memberof Tinode#
     * @returns {Object} build and version of the server or <tt>null</tt> if there is no connection or if the first server response has not been received yet.
     */
    getServerInfo: function() {
      return this._serverInfo;
    },
  
    /**
     * Return server-provided configuration value (long integer).
     * @memberof Tinode#
     * @param {String} name of the value to return
     * @param {Object} defaultValue to return in case server limit is not set or not found.
     * @returns {integer} named value.
     */
    getServerLimit: function(name, defaultValue) {
      return (this._serverInfo ? this._serverInfo[name] : null) || defaultValue;
    },
  
    /**
     * Toggle console logging. Logging is off by default.
     * @memberof Tinode#
     * @param {boolean} enabled - Set to <tt>true</tt> to enable logging to console.
     * @param {boolean} trimLongStrings - Set to <tt>true</tt> to trim long strings.
     */
    enableLogging: function(enabled, trimLongStrings) {
      this._loggingEnabled = enabled;
      this._trimLongStrings = enabled && trimLongStrings;
    },
  
    /**
     * Set UI language to report to the server. Must be called before 'hi' is sent, otherwise it will not be used.
     * @memberof Tinode#
     *
     * @param {String} hl - human (UI) language, like "en_US" or "zh-Hans".
     */
    setHumanLanguage: function(hl) {
      if (hl) {
        this._humanLanguage = hl;
      }
    },
  
    /**
     * Check if given topic is online.
     * @memberof Tinode#
     *
     * @param {String} name of the topic to test.
     * @returns {Boolean} true if topic is online, false otherwise.
     */
    isTopicOnline: function(name) {
      const me = this.getMeTopic();
      const cont = me && me.getContact(name);
      return cont && cont.online;
    },
  
    /**
     * Get access mode for the given contact.
     * @memberof Tinode#
     *
     * @param {String} name of the topic to query.
     * @returns {AccessMode} access mode if topic is found, null otherwise.
     */
    getTopicAccessMode: function(name) {
      const me = this.getMeTopic();
      const cont = me && me.getContact(name);
      return cont ? cont.acs : null;
    },
  
    /**
     * Include message ID into all subsequest messages to server instructin it to send aknowledgemens.
     * Required for promises to function. Default is "on".
     * @memberof Tinode#
     *
     * @param {Boolean} status - Turn aknowledgemens on or off.
     * @deprecated
     */
    wantAkn: function(status) {
      if (status) {
        this._messageId = Math.floor((Math.random() * 0xFFFFFF) + 0xFFFFFF);
      } else {
        this._messageId = 0;
      }
    },
  
    // Callbacks:
    /**
     * Callback to report when the websocket is opened. The callback has no parameters.
     * @memberof Tinode#
     * @type {Tinode.onWebsocketOpen}
     */
    onWebsocketOpen: undefined,
  
    /**
     * @typedef Tinode.ServerParams
     * @memberof Tinode
     * @type Object
     * @property {string} ver - Server version
     * @property {string} build - Server build
     * @property {string=} sid - Session ID, long polling connections only.
     */
  
    /**
     * @callback Tinode.onConnect
     * @param {number} code - Result code
     * @param {string} text - Text epxplaining the completion, i.e "OK" or an error message.
     * @param {Tinode.ServerParams} params - Parameters returned by the server.
     */
    /**
     * Callback to report when connection with Tinode server is established.
     * @memberof Tinode#
     * @type {Tinode.onConnect}
     */
    onConnect: undefined,
  
    /**
     * Callback to report when connection is lost. The callback has no parameters.
     * @memberof Tinode#
     * @type {Tinode.onDisconnect}
     */
    onDisconnect: undefined,
  
    /**
     * @callback Tinode.onLogin
     * @param {number} code - NUmeric completion code, same as HTTP status codes.
     * @param {string} text - Explanation of the completion code.
     */
    /**
     * Callback to report login completion.
     * @memberof Tinode#
     * @type {Tinode.onLogin}
     */
    onLogin: undefined,
  
    /**
     * Callback to receive {ctrl} (control) messages.
     * @memberof Tinode#
     * @type {Tinode.onCtrlMessage}
     */
    onCtrlMessage: undefined,
  
    /**
     * Callback to recieve {data} (content) messages.
     * @memberof Tinode#
     * @type {Tinode.onDataMessage}
     */
    onDataMessage: undefined,
  
    /**
     * Callback to receive {pres} (presence) messages.
     * @memberof Tinode#
     * @type {Tinode.onPresMessage}
     */
    onPresMessage: undefined,
  
    /**
     * Callback to receive all messages as objects.
     * @memberof Tinode#
     * @type {Tinode.onMessage}
     */
    onMessage: undefined,
  
    /**
     * Callback to receive all messages as unparsed text.
     * @memberof Tinode#
     * @type {Tinode.onRawMessage}
     */
    onRawMessage: undefined,
  
    /**
     * Callback to receive server responses to network probes. See {@link Tinode#networkProbe}
     * @memberof Tinode#
     * @type {Tinode.onNetworkProbe}
     */
    onNetworkProbe: undefined,
  
    /**
     * Callback to be notified when exponential backoff is iterating.
     * @memberof Tinode#
     * @type {Tinode.onAutoreconnectIteration}
     */
    onAutoreconnectIteration: undefined,
  };
  
  /**
   * Helper class for constructing {@link Tinode.GetQuery}.
   *
   * @class MetaGetBuilder
   * @memberof Tinode
   *
   * @param {Tinode.Topic} parent topic which instantiated this builder.
   */
  var MetaGetBuilder = function(parent) {
    this.topic = parent;
    const me = parent._tinode.getMeTopic();
    this.contact = me && me.getContact(parent.name);
    this.what = {};
  }
  
  MetaGetBuilder.prototype = {
  
    // Get latest timestamp
    _get_ims: function() {
      const cupd = this.contact && this.contact.updated;
      const tupd = this.topic._lastDescUpdate || 0;
      return cupd > tupd ? cupd : tupd;
    },
  
    /**
     * Add query parameters to fetch messages within explicit limits.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} since messages newer than this (inclusive);
     * @param {Number=} before older than this (exclusive)
     * @param {Number=} limit number of messages to fetch
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withData: function(since, before, limit) {
      this.what['data'] = {
        since: since,
        before: before,
        limit: limit
      };
      return this;
    },
  
    /**
     * Add query parameters to fetch messages newer than the latest saved message.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} limit number of messages to fetch
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withLaterData: function(limit) {
      return this.withData(this.topic._maxSeq > 0 ? this.topic._maxSeq + 1 : undefined, undefined, limit);
    },
  
    /**
     * Add query parameters to fetch messages older than the earliest saved message.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} limit maximum number of messages to fetch.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withEarlierData: function(limit) {
      return this.withData(undefined, this.topic._minSeq > 0 ? this.topic._minSeq : undefined, limit);
    },
  
    /**
     * Add query parameters to fetch topic description if it's newer than the given timestamp.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Date=} ims fetch messages newer than this timestamp.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withDesc: function(ims) {
      this.what['desc'] = {
        ims: ims
      };
      return this;
    },
  
    /**
     * Add query parameters to fetch topic description if it's newer than the last update.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withLaterDesc: function() {
      return this.withDesc(this._get_ims());
    },
  
    /**
     * Add query parameters to fetch subscriptions.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Date=} ims fetch subscriptions modified more recently than this timestamp
     * @param {Number=} limit maximum number of subscriptions to fetch.
     * @param {String=} userOrTopic user ID or topic name to fetch for fetching one subscription.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withSub: function(ims, limit, userOrTopic) {
      const opts = {
        ims: ims,
        limit: limit
      };
      if (this.topic.getType() == 'me') {
        opts.topic = userOrTopic;
      } else {
        opts.user = userOrTopic;
      }
      this.what['sub'] = opts;
      return this;
    },
  
    /**
     * Add query parameters to fetch a single subscription.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Date=} ims fetch subscriptions modified more recently than this timestamp
     * @param {String=} userOrTopic user ID or topic name to fetch for fetching one subscription.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withOneSub: function(ims, userOrTopic) {
      return this.withSub(ims, undefined, userOrTopic);
    },
  
    /**
     * Add query parameters to fetch a single subscription if it's been updated since the last update.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {String=} userOrTopic user ID or topic name to fetch for fetching one subscription.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withLaterOneSub: function(userOrTopic) {
      return this.withOneSub(this.topic._lastSubsUpdate, userOrTopic);
    },
  
    /**
     * Add query parameters to fetch subscriptions updated since the last update.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} limit maximum number of subscriptions to fetch.
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withLaterSub: function(limit) {
      return this.withSub(this.topic.isP2P() ?
        this._get_ims() : this.topic._lastSubsUpdate,
        limit);
    },
  
    /**
     * Add query parameters to fetch topic tags.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withTags: function() {
      this.what['tags'] = true;
      return this;
    },
  
    /**
     * Add query parameters to fetch user's credentials. 'me' topic only.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withCred: function() {
      if (this.topic.getType() == 'me') {
        this.what['cred'] = true;
      } else {
        this.topic._tinode.logger("ERROR: Invalid topic type for MetaGetBuilder:withCreds", this.topic.getType());
      }
      return this;
    },
  
    /**
     * Add query parameters to fetch deleted messages within explicit limits. Any/all parameters can be null.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} since ids of messages deleted since this 'del' id (inclusive)
     * @param {Number=} limit number of deleted message ids to fetch
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withDel: function(since, limit) {
      if (since || limit) {
        this.what['del'] = {
          since: since,
          limit: limit
        };
      }
      return this;
    },
  
    /**
     * Add query parameters to fetch messages deleted after the saved 'del' id.
     * @memberof Tinode.MetaGetBuilder#
     *
     * @param {Number=} limit number of deleted message ids to fetch
     *
     * @returns {Tinode.MetaGetBuilder} <tt>this</tt> object.
     */
    withLaterDel: function(limit) {
      // Specify 'since' only if we have already received some messages. If
      // we have no locally cached messages then we don't care if any messages were deleted.
      return this.withDel(this.topic._maxSeq > 0 ? this.topic._maxDel + 1 : undefined, limit);
    },
  
    /**
     * Construct parameters
     * @memberof Tinode.MetaGetBuilder#
     *
     * @returns {Tinode.GetQuery} Get query
     */
    build: function() {
      const what = [];
      const instance = this;
      let params = {};
      ['data', 'sub', 'desc', 'tags', 'cred', 'del'].map(function(key) {
        if (instance.what.hasOwnProperty(key)) {
          what.push(key);
          if (Object.getOwnPropertyNames(instance.what[key]).length > 0) {
            params[key] = instance.what[key];
          }
        }
      });
      if (what.length > 0) {
        params.what = what.join(' ');
      } else {
        params = undefined;
      }
      return params;
    }
  };
  
  /**
   * Helper class for handling access mode.
   *
   * @class AccessMode
   * @memberof Tinode
   *
   * @param {AccessMode|Object=} acs AccessMode to copy or access mode object received from the server.
   */
  var AccessMode = function(acs) {
    if (acs) {
      this.given = typeof acs.given == 'number' ? acs.given : AccessMode.decode(acs.given);
      this.want = typeof acs.want == 'number' ? acs.want : AccessMode.decode(acs.want);
      this.mode = acs.mode ? (typeof acs.mode == 'number' ? acs.mode : AccessMode.decode(acs.mode)) :
        (this.given & this.want);
    }
  };
  
  AccessMode._NONE = 0x00;
  AccessMode._JOIN = 0x01;
  AccessMode._READ = 0x02;
  AccessMode._WRITE = 0x04;
  AccessMode._PRES = 0x08;
  AccessMode._APPROVE = 0x10;
  AccessMode._SHARE = 0x20;
  AccessMode._DELETE = 0x40;
  AccessMode._OWNER = 0x80;
  
  AccessMode._BITMASK = AccessMode._JOIN | AccessMode._READ | AccessMode._WRITE | AccessMode._PRES |
    AccessMode._APPROVE | AccessMode._SHARE | AccessMode._DELETE | AccessMode._OWNER;
  AccessMode._INVALID = 0x100000;
  
  AccessMode._checkFlag = function(val, side, flag) {
    side = side || 'mode';
    if (['given', 'want', 'mode'].includes(side)) {
      return ((val[side] & flag) != 0);
    }
    throw new Error("Invalid AccessMode component '" + side + "'");
  }
  
  /**
   * Parse string into an access mode value.
   * @memberof Tinode.AccessMode
   * @static
   *
   * @param {string | number} mode - either a String representation of the access mode to parse or a set of bits to assign.
   * @returns {number} - Access mode as a numeric value.
   */
  AccessMode.decode = function(str) {
    if (!str) {
      return null;
    } else if (typeof str == 'number') {
      return str & AccessMode._BITMASK;
    } else if (str === 'N' || str === 'n') {
      return AccessMode._NONE;
    }
  
    const bitmask = {
      'J': AccessMode._JOIN,
      'R': AccessMode._READ,
      'W': AccessMode._WRITE,
      'P': AccessMode._PRES,
      'A': AccessMode._APPROVE,
      'S': AccessMode._SHARE,
      'D': AccessMode._DELETE,
      'O': AccessMode._OWNER
    };
  
    let m0 = AccessMode._NONE;
  
    for (let i = 0; i < str.length; i++) {
      const bit = bitmask[str.charAt(i).toUpperCase()];
      if (!bit) {
        // Unrecognized bit, skip.
        continue;
      }
      m0 |= bit;
    }
    return m0;
  };
  
  /**
   * Convert numeric representation of the access mode into a string.
   *
   * @memberof Tinode.AccessMode
   * @static
   *
   * @param {number} val - access mode value to convert to a string.
   * @returns {string} - Access mode as a string.
   */
  AccessMode.encode = function(val) {
    if (val === null || val === AccessMode._INVALID) {
      return null;
    } else if (val === AccessMode._NONE) {
      return 'N';
    }
  
    const bitmask = ['J', 'R', 'W', 'P', 'A', 'S', 'D', 'O'];
    let res = '';
    for (let i = 0; i < bitmask.length; i++) {
      if ((val & (1 << i)) != 0) {
        res = res + bitmask[i];
      }
    }
    return res;
  };
  
  /**
   * Update numeric representation of access mode with the new value. The value
   * is one of the following:
   *  - a string starting with '+' or '-' then the bits to add or remove, e.g. '+R-W' or '-PS'.
   *  - a new value of access mode
   *
   * @memberof Tinode.AccessMode
   * @static
   *
   * @param {number} val - access mode value to update.
   * @param {string} upd - update to apply to val.
   * @returns {number} - updated access mode.
   */
  AccessMode.update = function(val, upd) {
    if (!upd || typeof upd != 'string') {
      return val;
    }
  
    let action = upd.charAt(0);
    if (action == '+' || action == '-') {
      let val0 = val;
      // Split delta-string like '+ABC-DEF+Z' into an array of parts including + and -.
      const parts = upd.split(/([-+])/);
      // Starting iteration from 1 because String.split() creates an array with the first empty element.
      // Iterating by 2 because we parse pairs +/- then data.
      for (let i = 1; i < parts.length - 1; i += 2) {
        action = parts[i];
        const m0 = AccessMode.decode(parts[i + 1]);
        if (m0 == AccessMode._INVALID) {
          return val;
        }
        if (m0 == null) {
          continue;
        }
        if (action === '+') {
          val0 |= m0;
        } else if (action === '-') {
          val0 &= ~m0;
        }
      }
      val = val0;
    } else {
      // The string is an explicit new value 'ABC' rather than delta.
      const val0 = AccessMode.decode(upd);
      if (val0 != AccessMode._INVALID) {
        val = val0;
      }
    }
  
    return val;
  };
  
  /**
   * Bits present in a1 but missing in a2.
   *
   * @static
   * @memberof Tinode
   *
   * @param {number | string} a1 - access mode to subtract from.
   * @param {number | string} a2 - access mode to subtract.
   * @returns {number} access mode with bits present in a1 but missing in a2
   */
  AccessMode.diff = function(a1, a2) {
    a1 = AccessMode.decode(a1);
    a2 = AccessMode.decode(a2);
  
    if (a1 == AccessMode._INVALID || a2 == AccessMode._INVALID) {
      return AccessMode._INVALID;
    }
    return a1 & ~a2;
  };
  
  /**
   * AccessMode is a class representing topic access mode.
   *
   * @memberof Tinode
   * @class AccessMode
   */
  AccessMode.prototype = {
    /**
     * Custom formatter
     */
    toString: function() {
      return '{"mode": "' + AccessMode.encode(this.mode) +
        '", "given": "' + AccessMode.encode(this.given) +
        '", "want": "' + AccessMode.encode(this.want) + '"}';
    },
    /**
     * Convert numeric values to strings.
     */
    jsonHelper: function() {
      return {
        mode: AccessMode.encode(this.mode),
        given: AccessMode.encode(this.given),
        want: AccessMode.encode(this.want)
      };
    },
    /**
     * Assign value to 'mode'.
     * @memberof Tinode.AccessMode
     *
     * @param {string | number} m - either a string representation of the access mode or a set of bits.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    setMode: function(m) {
      this.mode = AccessMode.decode(m);
      return this;
    },
    /**
     * Update 'mode' value.
     * @memberof Tinode.AccessMode
     *
     * @param {string} u - string representation of the changes to apply to access mode.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    updateMode: function(u) {
      this.mode = AccessMode.update(this.mode, u);
      return this;
    },
    /**
     * Get 'mode' value as a string.
     * @memberof Tinode.AccessMode
     *
     * @returns {string} - <b>mode</b> value.
     */
    getMode: function() {
      return AccessMode.encode(this.mode);
    },
  
    /**
     * Assign 'given' value.
     * @memberof Tinode.AccessMode
     *
     * @param {string | number} g - either a string representation of the access mode or a set of bits.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    setGiven: function(g) {
      this.given = AccessMode.decode(g);
      return this;
    },
    /**
     * Update 'given' value.
     * @memberof Tinode.AccessMode
     *
     * @param {string} u - string representation of the changes to apply to access mode.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    updateGiven: function(u) {
      this.given = AccessMode.update(this.given, u);
      return this;
    },
    /**
     * Get 'given' value as a string.
     * @memberof Tinode.AccessMode
     *
     * @returns {string} - <b>given</b> value.
     */
    getGiven: function() {
      return AccessMode.encode(this.given);
    },
  
    /**
     * Assign 'want' value.
     * @memberof Tinode.AccessMode
     *
     * @param {string | number} w - either a string representation of the access mode or a set of bits.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    setWant: function(w) {
      this.want = AccessMode.decode(w);
      return this;
    },
    /**
     * Update 'want' value.
     * @memberof Tinode.AccessMode
     *
     * @param {string} u - string representation of the changes to apply to access mode.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    updateWant: function(u) {
      this.want = AccessMode.update(this.want, u);
      return this;
    },
    /**
     * Get 'want' value as a string.
     * @memberof Tinode.AccessMode
     *
     * @returns {string} - <b>want</b> value.
     */
    getWant: function() {
      return AccessMode.encode(this.want);
    },
  
    /**
     * Get permissions present in 'want' but missing in 'given'.
     * Inverse of {@link Tinode.AccessMode#getExcessive}
     *
     * @memberof Tinode.AccessMode
     *
     * @returns {string} permissions present in <b>want</b> but missing in <b>given</b>.
     */
    getMissing: function() {
      return AccessMode.encode(this.want & ~this.given);
    },
  
    /**
     * Get permissions present in 'given' but missing in 'want'.
     * Inverse of {@link Tinode.AccessMode#getMissing}
     * @memberof Tinode.AccessMode
     *
     * @returns {string} permissions present in <b>given</b> but missing in <b>want</b>.
     */
    getExcessive: function() {
      return AccessMode.encode(this.given & ~this.want);
    },
  
    /**
     * Update 'want', 'give', and 'mode' values.
     * @memberof Tinode.AccessMode
     *
     * @param {AccessMode} val - new access mode value.
     * @returns {AccessMode} - <b>this</b> AccessMode.
     */
    updateAll: function(val) {
      if (val) {
        this.updateGiven(val.given);
        this.updateWant(val.want);
        this.mode = this.given & this.want;
      }
      return this;
    },
  
    /**
     * Check if Owner (O) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isOwner: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._OWNER);
    },
  
    /**
     * Check if Presence (P) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isPresencer: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._PRES);
    },
  
    /**
     * Check if Presence (P) flag is NOT set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isMuted: function(side) {
      return !this.isPresencer(side);
    },
  
    /**
     * Check if Join (J) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isJoiner: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._JOIN);
    },
  
    /**
     * Check if Reader (R) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isReader: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._READ);
    },
  
    /**
     * Check if Writer (W) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isWriter: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._WRITE);
    },
  
    /**
     * Check if Approver (A) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isApprover: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._APPROVE);
    },
  
    /**
     * Check if either one of Owner (O) or Approver (A) flags is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isAdmin: function(side) {
      return this.isOwner(side) || this.isApprover(side);
    },
  
    /**
     * Check if either one of Owner (O), Approver (A), or Sharer (S) flags is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isSharer: function(side) {
      return this.isAdmin(side) || AccessMode._checkFlag(this, side, AccessMode._SHARE);
    },
  
    /**
     * Check if Deleter (D) flag is set.
     * @memberof Tinode.AccessMode
     * @param {string=} side - which permission to check: given, want, mode; default: mode.
     * @returns {boolean} - true if flag is set.
     */
    isDeleter: function(side) {
      return AccessMode._checkFlag(this, side, AccessMode._DELETE);
    }
  };
  
  /**
   * @callback Tinode.Topic.onData
   * @param {Data} data - Data packet
   */
  /**
   * Topic is a class representing a logical communication channel.
   * @class Topic
   * @memberof Tinode
   *
   * @param {string} name - Name of the topic to create.
   * @param {Object=} callbacks - Object with various event callbacks.
   * @param {Tinode.Topic.onData} callbacks.onData - Callback which receives a {data} message.
   * @param {callback} callbacks.onMeta - Callback which receives a {meta} message.
   * @param {callback} callbacks.onPres - Callback which receives a {pres} message.
   * @param {callback} callbacks.onInfo - Callback which receives an {info} message.
   * @param {callback} callbacks.onMetaDesc - Callback which receives changes to topic desctioption {@link desc}.
   * @param {callback} callbacks.onMetaSub - Called for a single subscription record change.
   * @param {callback} callbacks.onSubsUpdated - Called after a batch of subscription changes have been recieved and cached.
   * @param {callback} callbacks.onDeleteTopic - Called after the topic is deleted.
   * @param {callback} callbacls.onAllMessagesReceived - Called when all requested {data} messages have been recived.
   */
  var Topic = function(name, callbacks) {
    // Parent Tinode object.
    this._tinode = null;
  
    // Server-provided data, locally immutable.
    // topic name
    this.name = name;
    // timestamp when the topic was created
    this.created = null;
    // timestamp when the topic was last updated
    this.updated = null;
    // timestamp of the last messages
    this.touched = null;
    // access mode, see AccessMode
    this.acs = new AccessMode(null);
    // per-topic private data
    this.private = null;
    // per-topic public data
    this.public = null;
  
    // Locally cached data
    // Subscribed users, for tracking read/recv/msg notifications.
    this._users = {};
  
    // Current value of locally issued seqId, used for pending messages.
    this._queuedSeqId = LOCAL_SEQID;
  
    // The maximum known {data.seq} value.
    this._maxSeq = 0;
    // The minimum known {data.seq} value.
    this._minSeq = 0;
    // Indicator that the last request for earlier messages returned 0.
    this._noEarlierMsgs = false;
    // The maximum known deletion ID.
    this._maxDel = 0;
    // User discovery tags
    this._tags = [];
    // Credentials such as email or phone number.
    this._credentials = [];
    // Message cache, sorted by message seq values, from old to new.
    this._messages = CBuffer(function(a, b) {
      return a.seq - b.seq;
    }, true);
    // Boolean, true if the topic is currently live
    this._subscribed = false;
    // Timestap when topic meta-desc update was recived.
    this._lastDescUpdate = null;
    // Timestap when topic meta-subs update was recived.
    this._lastSubsUpdate = null;
    // Topic created but not yet synced with the server. Used only during initialization.
    this._new = true;
  
    // Callbacks
    if (callbacks) {
      this.onData = callbacks.onData;
      this.onMeta = callbacks.onMeta;
      this.onPres = callbacks.onPres;
      this.onInfo = callbacks.onInfo;
      // A single desc update;
      this.onMetaDesc = callbacks.onMetaDesc;
      // A single subscription record;
      this.onMetaSub = callbacks.onMetaSub;
      // All subscription records received;
      this.onSubsUpdated = callbacks.onSubsUpdated;
      this.onTagsUpdated = callbacks.onTagsUpdated;
      this.onCredsUpdated = callbacks.onCredsUpdated;
      this.onDeleteTopic = callbacks.onDeleteTopic;
      this.onAllMessagesReceived = callbacks.onAllMessagesReceived;
    }
  };
  
  Topic.prototype = {
    /**
     * Check if the topic is subscribed.
     * @memberof Tinode.Topic#
     * @returns {boolean} True is topic is attached/subscribed, false otherwise.
     */
    isSubscribed: function() {
      return this._subscribed;
    },
  
    /**
     * Request topic to subscribe. Wrapper for {@link Tinode#subscribe}.
     * @memberof Tinode.Topic#
     *
     * @param {Tinode.GetQuery=} getParams - get query parameters.
     * @param {Tinode.SetParams=} setParams - set parameters.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to the request.
     */
    subscribe: function(getParams, setParams) {
      // If the topic is already subscribed, return resolved promise
      if (this._subscribed) {
        return Promise.resolve(this);
      }
  
      // Send subscribe message, handle async response.
      // If topic name is explicitly provided, use it. If no name, then it's a new group topic,
      // use "new".
      return this._tinode.subscribe(this.name || TOPIC_NEW, getParams, setParams).then((ctrl) => {
        if (ctrl.code >= 300) {
          // Do nothing if subscription status has not changed.
          return ctrl;
        }
  
        this._subscribed = true;
        this.acs = (ctrl.params && ctrl.params.acs) ? ctrl.params.acs : this.acs;
  
        // Set topic name for new topics and add it to cache.
        if (this._new) {
          this._new = false;
  
          // Name may change new123456 -> grpAbCdEf
          this.name = ctrl.topic;
  
          this.created = ctrl.ts;
          this.updated = ctrl.ts;
          // Don't assign touched, otherwise topic will be put on top of the list on subscribe.
  
          this._cachePutSelf();
  
          if (this.name != TOPIC_ME && this.name != TOPIC_FND) {
            // Add the new topic to the list of contacts maintained by the 'me' topic.
            const me = this._tinode.getMeTopic();
            if (me) {
              me._processMetaSub([{
                _noForwarding: true,
                topic: this.name,
                created: ctrl.ts,
                updated: ctrl.ts,
                acs: this.acs
              }]);
            }
          }
  
          if (setParams && setParams.desc) {
            setParams.desc._noForwarding = true;
            this._processMetaDesc(setParams.desc);
          }
        }
  
        return ctrl;
      });
    },
  
    /**
     * Create a draft of a message without sending it to the server.
     * @memberof Tinode.Topic#
     *
     * @param {string | Object} data - Content to wrap in a draft.
     * @param {Boolean=} noEcho - If <tt>true</tt> server will not echo message back to originating
     * session. Otherwise the server will send a copy of the message to sender.
     *
     * @returns {Object} message draft.
     */
    createMessage: function(data, noEcho) {
      return this._tinode.createMessage(this.name, data, noEcho);
    },
  
    /**
     * Immediately publish data to topic. Wrapper for {@link Tinode#publish}.
     * @memberof Tinode.Topic#
     *
     * @param {string | Object} data - Data to publish, either plain string or a Drafty object.
     * @param {Boolean=} noEcho - If <tt>true</tt> server will not echo message back to originating
     * @returns {Promise} Promise to be resolved/rejected when the server responds to the request.
     */
    publish: function(data, noEcho) {
      return this.publishMessage(this.createMessage(data, noEcho));
    },
  
    /**
     * Publish message created by {@link Tinode.Topic#createMessage}.
     * @memberof Tinode.Topic#
     *
     * @param {Object} pub - {data} object to publish. Must be created by {@link Tinode.Topic#createMessage}
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to the request.
     */
    publishMessage: function(pub) {
      if (!this._subscribed) {
        return Promise.reject(new Error("Cannot publish on inactive topic"));
      }
  
      // Update header with attachment records.
      if (Drafty.hasAttachments(pub.content) && !pub.head.attachments) {
        let attachments = [];
        Drafty.attachments(pub.content, (data) => {
          attachments.push(data.ref);
        });
        pub.head.attachments = attachments;
      }
  
      // Send data.
      pub._sending = true;
      pub._failed = false;
      return this._tinode.publishMessage(pub).then((ctrl) => {
        pub._sending = false;
        pub.ts = ctrl.ts;
        this.swapMessageId(pub, ctrl.params.seq);
        this._routeData(pub);
        return ctrl;
      }).catch((err) => {
        this._tinode.logger("WARNING: Message rejected by the server", err);
        pub._sending = false;
        pub._failed = true;
        if (this.onData) {
          this.onData();
        }
      });
    },
  
    /**
     * Add message to local message cache, send to the server when the promise is resolved.
     * If promise is null or undefined, the message will be sent immediately.
     * The message is sent when the
     * The message should be created by {@link Tinode.Topic#createMessage}.
     * This is probably not the final API.
     * @memberof Tinode.Topic#
     *
     * @param {Object} pub - Message to use as a draft.
     * @param {Promise} prom - Message will be sent when this promise is resolved, discarded if rejected.
     *
     * @returns {Promise} derived promise.
     */
    publishDraft: function(pub, prom) {
      if (!prom && !this._subscribed) {
        return Promise.reject(new Error("Cannot publish on inactive topic"));
      }
  
      const seq = pub.seq || this._getQueuedSeqId();
      if (!pub._noForwarding) {
        // The 'seq', 'ts', and 'from' are added to mimic {data}. They are removed later
        // before the message is sent.
  
        pub._noForwarding = true;
        pub.seq = seq;
        pub.ts = new Date();
        pub.from = this._tinode.getCurrentUserID();
  
        // Don't need an echo message because the message is added to local cache right away.
        pub.noecho = true;
        // Add to cache.
        this._messages.put(pub);
  
        if (this.onData) {
          this.onData(pub);
        }
      }
      // If promise is provided, send the queued message when it's resolved.
      // If no promise is provided, create a resolved one and send immediately.
      prom = (prom || Promise.resolve()).then(
        ( /* argument ignored */ ) => {
          if (pub._cancelled) {
            return {
              code: 300,
              text: "cancelled"
            };
          }
          return this.publishMessage(pub);
        },
        (err) => {
          this._tinode.logger("WARNING: Message draft rejected", err);
          pub._sending = false;
          pub._failed = true;
          this._messages.delAt(this._messages.find(pub));
          if (this.onData) {
            this.onData();
          }
        });
      return prom;
    },
  
    /**
     * Leave the topic, optionally unsibscribe. Leaving the topic means the topic will stop
     * receiving updates from the server. Unsubscribing will terminate user's relationship with the topic.
     * Wrapper for {@link Tinode#leave}.
     * @memberof Tinode.Topic#
     *
     * @param {Boolean=} unsub - If true, unsubscribe, otherwise just leave.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to the request.
     */
    leave: function(unsub) {
      // It's possible to unsubscribe (unsub==true) from inactive topic.
      if (!this._subscribed && !unsub) {
        return Promise.reject(new Error("Cannot leave inactive topic"));
      }
  
      // Send a 'leave' message, handle async response
      return this._tinode.leave(this.name, unsub).then((ctrl) => {
        this._resetSub();
        if (unsub) {
          this._tinode.cacheDel('topic', this.name);
          this._gone();
        }
        return ctrl;
      });
    },
  
    /**
     * Request topic metadata from the server.
     * @memberof Tinode.Topic#
     *
     * @param {Tinode.GetQuery} request parameters
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    getMeta: function(params) {
      // Send {get} message, return promise.
      return this._tinode.getMeta(this.name, params);
    },
  
    /**
     * Request more messages from the server
     * @memberof Tinode.Topic#
     *
     * @param {integer} limit number of messages to get.
     * @param {boolean} forward if true, request newer messages.
     */
    getMessagesPage: function(limit, forward) {
      const query = this.startMetaQuery();
      if (forward) {
        query.withLaterData(limit);
      } else {
        query.withEarlierData(limit);
      }
      let promise = this.getMeta(query.build());
      if (!forward) {
        promise = promise.then((ctrl) => {
          if (ctrl && ctrl.params && !ctrl.params.count) {
            this._noEarlierMsgs = true;
          }
        });
      }
      return promise;
    },
  
    /**
     * Update topic metadata.
     * @memberof Tinode.Topic#
     *
     * @param {Tinode.SetParams} params parameters to update.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    setMeta: function(params) {
      if (params.tags) {
        params.tags = normalizeArray(params.tags);
      }
      // Send Set message, handle async response.
      return this._tinode.setMeta(this.name, params)
        .then((ctrl) => {
          if (ctrl && ctrl.code >= 300) {
            // Not modified
            return ctrl;
          }
  
          if (params.sub) {
            params.sub.topic = this.name;
            if (ctrl.params && ctrl.params.acs) {
              params.sub.acs = ctrl.params.acs;
              params.sub.updated = ctrl.ts;
            }
            if (!params.sub.user) {
              // This is a subscription update of the current user.
              // Assign user ID otherwise the update will be ignored by _processMetaSub.
              params.sub.user = this._tinode.getCurrentUserID();
              if (!params.desc) {
                // Force update to topic's asc.
                params.desc = {};
              }
            }
            params.sub._noForwarding = true;
            this._processMetaSub([params.sub]);
          }
  
          if (params.desc) {
            if (ctrl.params && ctrl.params.acs) {
              params.desc.acs = ctrl.params.acs;
              params.desc.updated = ctrl.ts;
            }
            this._processMetaDesc(params.desc);
          }
  
          if (params.tags) {
            this._processMetaTags(params.tags);
          }
          if (params.cred) {
            this._processMetaCreds([params.cred], true);
          }
  
          return ctrl;
        });
    },
  
    /**
     * Update access mode of the current user or of another topic subsriber.
     * @memberof Tinode.Topic#
     *
     * @param {string} uid - UID of the user to update or null to update current user.
     * @param {string} update - the update value, full or delta.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    updateMode: function(uid, update) {
      const user = uid ? this.subscriber(uid) : null;
      const am = user ?
        user.acs.updateGiven(update).getGiven() :
        this.getAccessMode().updateWant(update).getWant();
  
      return this.setMeta({
        sub: {
          user: uid,
          mode: am
        }
      });
    },
  
    /**
     * Create new topic subscription. Wrapper for {@link Tinode#setMeta}.
     * @memberof Tinode.Topic#
     *
     * @param {String} uid - ID of the user to invite
     * @param {String=} mode - Access mode. <tt>null</tt> means to use default.
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    invite: function(uid, mode) {
      return this.setMeta({
        sub: {
          user: uid,
          mode: mode
        }
      });
    },
  
    /**
     * Archive or un-archive the topic. Wrapper for {@link Tinode#setMeta}.
     * @memberof Tinode.Topic#
     *
     * @param {Boolean} arch - true to archive the topic, false otherwise.
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    archive: function(arch) {
      if (this.private && this.private.arch == arch) {
        return Promise.resolve(arch);
      }
      return this.setMeta({
        desc: {
          private: {
            arch: arch ? true : Tinode.DEL_CHAR
          }
        }
      });
    },
  
    /**
     * Delete messages. Hard-deleting messages requires Owner permission.
     * Wrapper for {@link Tinode#delMessages}.
     * @memberof Tinode.Topic#
     *
     * @param {Tinode.DelRange[]} ranges - Ranges of message IDs to delete.
     * @param {Boolean=} hard - Hard or soft delete
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    delMessages: function(ranges, hard) {
      if (!this._subscribed) {
        return Promise.reject(new Error("Cannot delete messages in inactive topic"));
      }
  
      // Sort ranges in accending order by low, the descending by hi.
      ranges.sort((r1, r2) => {
        if (r1.low < r2.low) {
          return true;
        }
        if (r1.low == r2.low) {
          return !r2.hi || (r1.hi >= r2.hi);
        }
        return false;
      });
  
      // Remove pending messages from ranges possibly clipping some ranges.
      let tosend = ranges.reduce((out, r) => {
        if (r.low < LOCAL_SEQID) {
          if (!r.hi || r.hi < LOCAL_SEQID) {
            out.push(r);
          } else {
            // Clip hi to max allowed value.
            out.push({
              low: r.low,
              hi: this._maxSeq + 1
            });
          }
        }
        return out;
      }, []);
  
      // Send {del} message, return promise
      let result;
      if (tosend.length > 0) {
        result = this._tinode.delMessages(this.name, tosend, hard);
      } else {
        result = Promise.resolve({
          params: {
            del: 0
          }
        });
      }
      // Update local cache.
      return result.then((ctrl) => {
        if (ctrl.params.del > this._maxDel) {
          this._maxDel = ctrl.params.del;
        }
  
        ranges.map((r) => {
          if (r.hi) {
            this.flushMessageRange(r.low, r.hi);
          } else {
            this.flushMessage(r.low);
          }
        });
  
        this._updateDeletedRanges();
  
        if (this.onData) {
          // Calling with no parameters to indicate the messages were deleted.
          this.onData();
        }
        return ctrl;
      });
    },
  
    /**
     * Delete all messages. Hard-deleting messages requires Owner permission.
     * @memberof Tinode.Topic#
     *
     * @param {boolean} hardDel - true if messages should be hard-deleted.
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    delMessagesAll: function(hardDel) {
      if (!this._maxSeq || this._maxSeq <= 0) {
        // There are no messages to delete.
        return Promise.resolve();
      }
      return this.delMessages([{
        low: 1,
        hi: this._maxSeq + 1,
        _all: true
      }], hardDel);
    },
  
    /**
     * Delete multiple messages defined by their IDs. Hard-deleting messages requires Owner permission.
     * @memberof Tinode.Topic#
     *
     * @param {Tinode.DelRange[]} list - list of seq IDs to delete
     * @param {Boolean=} hardDel - true if messages should be hard-deleted.
     *
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    delMessagesList: function(list, hardDel) {
      // Sort the list in ascending order
      list.sort((a, b) => a - b);
      // Convert the array of IDs to ranges.
      let ranges = list.reduce((out, id) => {
        if (out.length == 0) {
          // First element.
          out.push({
            low: id
          });
        } else {
          let prev = out[out.length - 1];
          if ((!prev.hi && (id != prev.low + 1)) || (id > prev.hi)) {
            // New range.
            out.push({
              low: id
            });
          } else {
            // Expand existing range.
            prev.hi = prev.hi ? Math.max(prev.hi, id + 1) : id + 1;
          }
        }
        return out;
      }, []);
      // Send {del} message, return promise
      return this.delMessages(ranges, hardDel)
    },
  
    /**
     * Delete topic. Requires Owner permission. Wrapper for {@link Tinode#delTopic}.
     * @memberof Tinode.Topic#
     *
     * @param {Boolean} hard - had-delete topic.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to the request.
     */
    delTopic: function(hard) {
      return this._tinode.delTopic(this.name, hard).then((ctrl) => {
        this._resetSub();
        this._gone();
        return ctrl;
      });
    },
  
    /**
     * Delete subscription. Requires Share permission. Wrapper for {@link Tinode#delSubscription}.
     * @memberof Tinode.Topic#
     *
     * @param {String} user - ID of the user to remove subscription for.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    delSubscription: function(user) {
      if (!this._subscribed) {
        return Promise.reject(new Error("Cannot delete subscription in inactive topic"));
      }
      // Send {del} message, return promise
      return this._tinode.delSubscription(this.name, user).then((ctrl) => {
        // Remove the object from the subscription cache;
        delete this._users[user];
        // Notify listeners
        if (this.onSubsUpdated) {
          this.onSubsUpdated(Object.keys(this._users));
        }
        return ctrl;
      });
    },
  
    /**
     * Send a read/recv notification
     * @memberof Tinode.Topic#
     *
     * @param {String} what - what notification to send: <tt>recv</tt>, <tt>read</tt>.
     * @param {Number} seq - ID or the message read or received.
     */
    note: function(what, seq) {
      if (!this._subscribed) {
        // Cannot sending {note} on an inactive topic".
        return;
      }
  
      // Update locally cached contact with the new count.
  
      const me = this._tinode.getMeTopic();
  
      const user = this._users[this._tinode.getCurrentUserID()];
      let update = false;
      if (user) {
        // Known topic subscriber.
        if (!user[what] || user[what] < seq) {
          user[what] = seq;
          update = true;
        }
      } else if (me) {
        // Subscriber not found, such as in case of no S permission.
        update = (me.getMsgReadRecv(this.name, what) < seq);
      }
  
      if (update) {
        this._tinode.note(this.name, what, seq);
      }
  
      // Update locally cached contact with the new count.
      if (me) {
        me.setMsgReadRecv(this.name, what, seq);
      }
    },
  
    /**
     * Send a 'recv' receipt. Wrapper for {@link Tinode#noteRecv}.
     * @memberof Tinode.Topic#
     *
     * @param {Number} seq - ID of the message to aknowledge.
     */
    noteRecv: function(seq) {
      this.note('recv', seq);
    },
  
    /**
     * Send a 'read' receipt. Wrapper for {@link Tinode#noteRead}.
     * @memberof Tinode.Topic#
     *
     * @param {Number} seq - ID of the message to aknowledge or 0/undefined to acknowledge the latest messages.
     */
    noteRead: function(seq) {
      seq = seq || this._maxSeq;
      if (seq > 0) {
        this.note('read', seq);
      }
    },
  
    /**
     * Send a key-press notification. Wrapper for {@link Tinode#noteKeyPress}.
     * @memberof Tinode.Topic#
     */
    noteKeyPress: function() {
      if (this._subscribed) {
        this._tinode.noteKeyPress(this.name);
      } else {
        this._tinode.logger("INFO: Cannot send notification in inactive topic");
      }
    },
  
    /**
     * Get user description from global cache. The user does not need to be a
     * subscriber of this topic.
     * @memberof Tinode.Topic#
     *
     * @param {String} uid - ID of the user to fetch.
     * @return {Object} user description or undefined.
     */
    userDesc: function(uid) {
      // TODO(gene): handle asynchronous requests
  
      const user = this._cacheGetUser(uid);
      if (user) {
        return user; // Promise.resolve(user)
      }
    },
  
    /**
     * Get description of the p2p peer from subscription cache.
     * @memberof Tinode.Topic#
     *
     * @return {Object} peer's description or undefined.
     */
    p2pPeerDesc: function() {
      if (!this.isP2P()) {
        return undefined;
      }
      return this._users[this.name];
    },
  
    /**
     * Iterate over cached subscribers. If callback is undefined, use this.onMetaSub.
     * @memberof Tinode.Topic#
     *
     * @param {Function} callback - Callback which will receive subscribers one by one.
     * @param {Object=} context - Value of `this` inside the `callback`.
     */
    subscribers: function(callback, context) {
      const cb = (callback || this.onMetaSub);
      if (cb) {
        for (let idx in this._users) {
          cb.call(context, this._users[idx], idx, this._users);
        }
      }
    },
  
    /**
     * Get a copy of cached tags.
     * @memberof Tinode.Topic#
     * @return a copy of tags
     */
    tags: function() {
      // Return a copy.
      return this._tags.slice(0);
    },
  
    /**
     * Get cached subscription for the given user ID.
     * @memberof Tinode.Topic#
     *
     * @param {String} uid - id of the user to query for
     * @return user description or undefined.
     */
    subscriber: function(uid) {
      return this._users[uid];
    },
  
    /**
     * Iterate over cached messages. If callback is undefined, use this.onData.
     * @memberof Tinode.Topic#
     *
     * @param {function} callback - Callback which will receive messages one by one. See {@link Tinode.CBuffer#forEach}
     * @param {integer} sinceId - Optional seqId to start iterating from (inclusive).
     * @param {integer} beforeId - Optional seqId to stop iterating before (exclusive).
     * @param {Object} context - Value of `this` inside the `callback`.
     */
    messages: function(callback, sinceId, beforeId, context) {
      const cb = (callback || this.onData);
      if (cb) {
        let startIdx = typeof sinceId == 'number' ? this._messages.find({
          seq: sinceId
        }, true) : undefined;
        let beforeIdx = typeof beforeId == 'number' ? this._messages.find({
          seq: beforeId
        }, true) : undefined;
        if (startIdx != -1 && beforeIdx != -1) {
          this._messages.forEach(cb, startIdx, beforeIdx, context);
        }
      }
    },
  
    /**
     * Iterate over cached unsent messages. Wraps {@link Tinode.Topic#messages}.
     * @memberof Tinode.Topic#
     *
     * @param {function} callback - Callback which will receive messages one by one. See {@link Tinode.CBuffer#forEach}
     * @param {Object} context - Value of `this` inside the `callback`.
     */
    queuedMessages: function(callback, context) {
      if (!callback) {
        throw new Error("Callback must be provided");
      }
      this.messages(callback, LOCAL_SEQID, undefined, context);
    },
  
    /**
     * Get the number of topic subscribers who marked this message as either recv or read
     * Current user is excluded from the count.
     * @memberof Tinode.Topic#
     *
     * @param {String} what - what notification to send: <tt>recv</tt>, <tt>read</tt>.
     * @param {Number} seq - ID or the message read or received.
     */
    msgReceiptCount: function(what, seq) {
      let count = 0;
      if (seq > 0) {
        const me = this._tinode.getCurrentUserID();
        for (let idx in this._users) {
          const user = this._users[idx];
          if (user.user !== me && user[what] >= seq) {
            count++;
          }
        }
      }
      return count;
    },
  
    /**
     * Get the number of topic subscribers who marked this message (and all older messages) as read.
     * The current user is excluded from the count.
     * @memberof Tinode.Topic#
     *
     * @param {Number} seq - Message id to check.
     * @returns {Number} Number of subscribers who claim to have received the message.
     */
    msgReadCount: function(seq) {
      return this.msgReceiptCount('read', seq);
    },
  
    /**
     * Get the number of topic subscribers who marked this message (and all older messages) as received.
     * The current user is excluded from the count.
     * @memberof Tinode.Topic#
     *
     * @param {number} seq - Message id to check.
     * @returns {number} Number of subscribers who claim to have received the message.
     */
    msgRecvCount: function(seq) {
      return this.msgReceiptCount('recv', seq);
    },
  
    /**
     * Check if cached message IDs indicate that the server may have more messages.
     * @memberof Tinode.Topic#
     *
     * @param {boolean} newer check for newer messages
     */
    msgHasMoreMessages: function(newer) {
      return newer ? this.seq > this._maxSeq :
        // _minSeq cound be more than 1, but earlier messages could have been deleted.
        (this._minSeq > 1 && !this._noEarlierMsgs);
    },
  
    /**
     * Check if the given seq Id is id of the most recent message.
     * @memberof Tinode.Topic#
     *
     * @param {integer} seqId id of the message to check
     */
    isNewMessage: function(seqId) {
      return this._maxSeq <= seqId;
    },
  
    /**
     * Remove one message from local cache.
     * @memberof Tinode.Topic#
     *
     * @param {integer} seqId id of the message to remove from cache.
     * @returns {Message} removed message or undefined if such message was not found.
     */
    flushMessage: function(seqId) {
      const idx = this._messages.find({
        seq: seqId
      });
      return idx >= 0 ? this._messages.delAt(idx) : undefined;
    },
  
    /**
     * Update message's seqId.
     * @memberof Tinode.Topic#
     *
     * @param {Object} pub message object.
     * @param {integer} newSeqId new seq id for pub.
     */
    swapMessageId: function(pub, newSeqId) {
      const idx = this._messages.find({
        seq: pub.seq
      });
      const numMessages = this._messages.length();
      pub.seq = newSeqId;
      if (0 <= idx && idx < numMessages) {
        // this._messages are sorted by `seq`.
        // If changing pub.seq to newSeqId breaks the invariant, fix it.
        if ((idx > 0 && this._messages.getAt(idx - 1).seq >= newSeqId) ||
          (idx + 1 < numMessages && newSeqId < this._messages.getAt(idx + 1).seq <= newSeqId)) {
          this._messages.delAt(idx);
          this._messages.put(pub);
        }
      }
    },
  
    /**
     * Remove a range of messages from the local cache.
     * @memberof Tinode.Topic#
     *
     * @param {integer} fromId seq ID of the first message to remove (inclusive).
     * @param {integer} untilId seqID of the last message to remove (exclusive).
     *
     * @returns {Message[]} array of removed messages (could be empty).
     */
    flushMessageRange: function(fromId, untilId) {
      // start, end: find insertion points (nearest == true).
      const since = this._messages.find({
        seq: fromId
      }, true);
      return since >= 0 ? this._messages.delRange(since, this._messages.find({
        seq: untilId
      }, true)) : [];
    },
  
    /**
     * Attempt to stop message from being sent.
     * @memberof Tinode.Topic#
     *
     * @param {integer} seqId id of the message to stop sending and remove from cache.
     *
     * @returns {boolean} true if message was cancelled, false otherwise.
     */
    cancelSend: function(seqId) {
      const idx = this._messages.find({
        seq: seqId
      });
      if (idx >= 0) {
        const msg = this._messages.getAt(idx);
        const status = this.msgStatus(msg);
        if (status == MESSAGE_STATUS_QUEUED || status == MESSAGE_STATUS_FAILED) {
          msg._cancelled = true;
          this._messages.delAt(idx);
          if (this.onData) {
            // Calling with no parameters to indicate the message was deleted.
            this.onData();
          }
          return true;
        }
      }
      return false;
    },
  
    /**
     * Get type of the topic: me, p2p, grp, fnd...
     * @memberof Tinode.Topic#
     *
     * @returns {String} One of 'me', 'p2p', 'grp', 'fnd', 'sys' or <tt>undefined</tt>.
     */
    getType: function() {
      return Tinode.topicType(this.name);
    },
  
    /**
     * Get user's cumulative access mode of the topic.
     * @memberof Tinode.Topic#
     *
     * @returns {Tinode.AccessMode} - user's access mode
     */
    getAccessMode: function() {
      return this.acs;
    },
  
    /**
     * Get topic's default access mode.
     * @memberof Tinode.Topic#
     *
     * @returns {Tinode.DefAcs} - access mode, such as {auth: `RWP`, anon: `N`}.
     */
    getDefaultAccess: function() {
      return this.defacs;
    },
  
    /**
     * Initialize new meta {@link Tinode.GetQuery} builder. The query is attched to the current topic.
     * It will not work correctly if used with a different topic.
     * @memberof Tinode.Topic#
     *
     * @returns {Tinode.MetaGetBuilder} query attached to the current topic.
     */
    startMetaQuery: function() {
      return new MetaGetBuilder(this);
    },
  
    /**
     * Check if topic is archived, i.e. private.arch == true.
     * @memberof Tinode.Topic#
     *
     * @returns {boolean} - true if topic is archived, false otherwise.
     */
    isArchived: function() {
      return this.private && this.private.arch ? true : false;
    },
  
    /**
     * Check if topic is a channel.
     * @memberof Tinode.Topic#
     *
     * @returns {boolean} - true if topic is a channel, false otherwise.
     */
    isChannel: function() {
      return Tinode.isChannelTopicName(this.name);
    },
  
    /**
     * Check if topic is a group topic.
     * @memberof Tinode.Topic#
     *
     * @returns {boolean} - true if topic is a group, false otherwise.
     */
    isGroup: function() {
      return Tinode.isGroupTopicName(this.name);
    },
  
    /**
     * Check if topic is a p2p topic.
     * @memberof Tinode.Topic#
     *
     * @returns {boolean} - true if topic is a p2p topic, false otherwise.
     */
    isP2P: function() {
      return Tinode.isP2PTopicName(this.name);
    },
  
    /**
     * Get status (queued, sent, received etc) of a given message in the context
     * of this topic.
     * @memberof Tinode.Topic#
     *
     * @param {Message} msg message to check for status.
     * @returns message status constant.
     */
    msgStatus: function(msg) {
      let status = MESSAGE_STATUS_NONE;
      if (this._tinode.isMe(msg.from)) {
        if (msg._sending) {
          status = MESSAGE_STATUS_SENDING;
        } else if (msg._failed || msg._cancelled) {
          status = MESSAGE_STATUS_FAILED;
        } else if (msg.seq >= LOCAL_SEQID) {
          status = MESSAGE_STATUS_QUEUED;
        } else if (this.msgReadCount(msg.seq) > 0) {
          status = MESSAGE_STATUS_READ;
        } else if (this.msgRecvCount(msg.seq) > 0) {
          status = MESSAGE_STATUS_RECEIVED;
        } else if (msg.seq > 0) {
          status = MESSAGE_STATUS_SENT;
        }
      } else {
        status = MESSAGE_STATUS_TO_ME;
      }
      return status;
    },
  
    // Process data message
    _routeData: function(data) {
      if (data.content) {
        if (!this.touched || this.touched < data.ts) {
          this.touched = data.ts;
        }
      }
  
      if (data.seq > this._maxSeq) {
        this._maxSeq = data.seq;
      }
      if (data.seq < this._minSeq || this._minSeq == 0) {
        this._minSeq = data.seq;
      }
  
      if (!data._noForwarding) {
        this._messages.put(data);
        this._updateDeletedRanges();
      }
  
      if (this.onData) {
        this.onData(data);
      }
  
      // Update locally cached contact with the new message count.
      const me = this._tinode.getMeTopic();
      if (me) {
        // Messages from the current user are considered to be read already.
        me.setMsgReadRecv(this.name,
          (!data.from || this._tinode.isMe(data.from)) ? 'read' : 'msg', data.seq, data.ts);
      }
    },
  
    // Process metadata message
    _routeMeta: function(meta) {
      if (meta.desc) {
        this._lastDescUpdate = meta.ts;
        this._processMetaDesc(meta.desc);
      }
      if (meta.sub && meta.sub.length > 0) {
        this._lastSubsUpdate = meta.ts;
        this._processMetaSub(meta.sub);
      }
      if (meta.del) {
        this._processDelMessages(meta.del.clear, meta.del.delseq);
      }
      if (meta.tags) {
        this._processMetaTags(meta.tags);
      }
      if (meta.cred) {
        this._processMetaCreds(meta.cred);
      }
      if (this.onMeta) {
        this.onMeta(meta);
      }
    },
  
    // Process presence change message
    _routePres: function(pres) {
      let user;
      switch (pres.what) {
        case 'del':
          // Delete cached messages.
          this._processDelMessages(pres.clear, pres.delseq);
          break;
        case 'on':
        case 'off':
          // Update online status of a subscription.
          user = this._users[pres.src];
          if (user) {
            user.online = pres.what == 'on';
          } else {
            this._tinode.logger("WARNING: Presence update for an unknown user", this.name, pres.src);
          }
          break;
        case 'term':
          // Attachment to topic is terminated probably due to cluster rehashing.
          this._resetSub();
          break;
        case 'acs':
          const uid = pres.src || this._tinode.getCurrentUserID();
          user = this._users[uid];
          if (!user) {
            // Update for an unknown user: notification of a new subscription.
            const acs = new AccessMode().updateAll(pres.dacs);
            if (acs && acs.mode != AccessMode._NONE) {
              user = this._cacheGetUser(uid);
              if (!user) {
                user = {
                  user: uid,
                  acs: acs
                };
                this.getMeta(this.startMetaQuery().withOneSub(undefined, uid).build());
              } else {
                user.acs = acs;
              }
              user.updated = new Date();
              this._processMetaSub([user]);
            }
          } else {
            // Known user
            user.acs.updateAll(pres.dacs);
            // Update user's access mode.
            this._processMetaSub([{
              user: uid,
              updated: new Date(),
              acs: user.acs
            }]);
          }
          break;
        default:
          this._tinode.logger("INFO: Ignored presence update", pres.what);
      }
  
      if (this.onPres) {
        this.onPres(pres);
      }
    },
  
    // Process {info} message
    _routeInfo: function(info) {
      if (info.what !== 'kp') {
        const user = this._users[info.from];
        if (user) {
          user[info.what] = info.seq;
          if (user.recv < user.read) {
            user.recv = user.read;
          }
        }
  
        // If this is an update from the current user, update the contact with the new count too.
        if (this._tinode.isMe(info.from)) {
          const me = this._tinode.getMeTopic();
          if (me) {
            me.setMsgReadRecv(info.topic, info.what, info.seq);
          }
        }
      }
      if (this.onInfo) {
        this.onInfo(info);
      }
    },
  
    // Called by Tinode when meta.desc packet is received.
    // Called by 'me' topic on contact update (desc._noForwarding is true).
    _processMetaDesc: function(desc) {
      // Synthetic desc may include defacs for p2p topics which is useless.
      // Remove it.
      if (this.isP2P()) {
        delete desc.defacs;
      }
  
      // Copy parameters from desc object to this topic.
      mergeObj(this, desc);
      // Make sure date fields are Date().
      stringToDate(this);
  
      // Update relevant contact in the me topic, if available:
      if (this.name !== TOPIC_ME && !desc._noForwarding) {
        const me = this._tinode.getMeTopic();
        if (me) {
          // Must use original 'desc' instead of 'this' so not to lose DEL_CHAR.
          me._processMetaSub([{
            _noForwarding: true,
            topic: this.name,
            updated: this.updated,
            touched: this.touched,
            acs: desc.acs,
            seq: desc.seq,
            read: desc.read,
            recv: desc.recv,
            public: desc.public,
            private: desc.private
          }]);
        }
      }
  
      if (this.onMetaDesc) {
        this.onMetaDesc(this);
      }
    },
  
    // Called by Tinode when meta.sub is recived or in response to received
    // {ctrl} after setMeta-sub.
    _processMetaSub: function(subs) {
      for (let idx in subs) {
        const sub = subs[idx];
  
        sub.updated = new Date(sub.updated);
        sub.deleted = sub.deleted ? new Date(sub.deleted) : null;
  
        let user = null;
        if (!sub.deleted) {
          // If this is a change to user's own permissions, update them in topic too.
          // Desc will update 'me' topic.
          if (this._tinode.isMe(sub.user) && sub.acs) {
            this._processMetaDesc({
              updated: sub.updated || new Date(),
              touched: sub.updated,
              acs: sub.acs
            });
          }
          user = this._updateCachedUser(sub.user, sub);
        } else {
          // Subscription is deleted, remove it from topic (but leave in Users cache)
          delete this._users[sub.user];
          user = sub;
        }
  
        if (this.onMetaSub) {
          this.onMetaSub(user);
        }
      }
  
      if (this.onSubsUpdated) {
        this.onSubsUpdated(Object.keys(this._users));
      }
    },
  
    // Called by Tinode when meta.tags is recived.
    _processMetaTags: function(tags) {
      if (tags.length == 1 && tags[0] == Tinode.DEL_CHAR) {
        tags = [];
      }
      this._tags = tags;
      if (this.onTagsUpdated) {
        this.onTagsUpdated(tags);
      }
    },
  
    // Do nothing for topics other than 'me'
    _processMetaCreds: function(creds) {},
  
    // Delete cached messages and update cached transaction IDs
    _processDelMessages: function(clear, delseq) {
      this._maxDel = Math.max(clear, this._maxDel);
      this.clear = Math.max(clear, this.clear);
      const topic = this;
      let count = 0;
      if (Array.isArray(delseq)) {
        delseq.map(function(range) {
          if (!range.hi) {
            count++;
            topic.flushMessage(range.low);
          } else {
            for (let i = range.low; i < range.hi; i++) {
              count++;
              topic.flushMessage(i);
            }
          }
        });
      }
  
      if (count > 0) {
        this._updateDeletedRanges();
  
        if (this.onData) {
          this.onData();
        }
      }
    },
  
    // Topic is informed that the entire response to {get what=data} has been received.
    _allMessagesReceived: function(count) {
      this._updateDeletedRanges();
  
      if (this.onAllMessagesReceived) {
        this.onAllMessagesReceived(count);
      }
    },
  
    // Reset subscribed state
    _resetSub: function() {
      this._subscribed = false;
    },
  
    // This topic is either deleted or unsubscribed from.
    _gone: function() {
      this._messages.reset();
      this._users = {};
      this.acs = new AccessMode(null);
      this.private = null;
      this.public = null;
      this._maxSeq = 0;
      this._minSeq = 0;
      this._subscribed = false;
  
      const me = this._tinode.getMeTopic();
      if (me) {
        me._routePres({
          _noForwarding: true,
          what: 'gone',
          topic: TOPIC_ME,
          src: this.name
        });
      }
      if (this.onDeleteTopic) {
        this.onDeleteTopic();
      }
    },
  
    // Update global user cache and local subscribers cache.
    // Don't call this method for non-subscribers.
    _updateCachedUser: function(uid, obj) {
      // Fetch user object from the global cache.
      // This is a clone of the stored object
      let cached = this._cacheGetUser(uid);
      cached = mergeObj(cached || {}, obj);
      // Save to global cache
      this._cachePutUser(uid, cached);
      // Save to the list of topic subsribers.
      return mergeToCache(this._users, uid, cached);
    },
  
    // Get local seqId for a queued message.
    _getQueuedSeqId: function() {
      return this._queuedSeqId++;
    },
  
    // Calculate ranges of missing messages.
    _updateDeletedRanges: function() {
      const ranges = [];
  
      let prev = null;
      // Check for gap in the beginning, before the first message.
      const first = this._messages.getAt(0);
      if (first && this._minSeq > 1 && !this._noEarlierMsgs) {
        // Some messages are missing in the beginning.
        if (first.hi) {
          // The first message already represents a gap.
          if (first.seq > 1) {
            first.seq = 1;
          }
          if (first.hi < this._minSeq - 1) {
            first.hi = this._minSeq - 1;
          }
          prev = first;
        } else {
          // Create new gap.
          prev = {
            seq: 1,
            hi: this._minSeq - 1
          };
          ranges.push(prev);
        }
      } else {
        // No gap in the beginning.
        prev = {
          seq: 0,
          hi: 0
        };
      }
  
      // Find gaps in the list of received messages. The list contains messages-proper as well
      // as placeholers for deleted ranges.
      // The messages are iterated by seq ID in ascending order.
      this._messages.forEach((data) => {
        // Do not create a gap between the last sent message and the first unsent.
        if (data.seq >= LOCAL_SEQID) {
          return;
        }
  
        // New message is reducing the existing gap
        if (data.seq == (prev.hi || prev.seq) + 1) {
          // No new gap. Replace previous with current.
          prev = data;
          return;
        }
  
        // Found a new gap.
        if (prev.hi) {
          // Previous is also a gap, alter it.
          prev.hi = data.hi || data.seq;
          return;
        }
  
        // Previous is not a gap. Create a new gap.
        prev = {
          seq: (prev.hi || prev.seq) + 1,
          hi: data.hi || data.seq
        };
        ranges.push(prev);
      });
  
      // Check for missing messages at the end.
      // All messages could be missing or it could be a new topic with no messages.
      const last = this._messages.getLast();
      const maxSeq = Math.max(this.seq, this._maxSeq) || 0;
      if ((maxSeq > 0 && !last) || (last && ((last.hi || last.seq) < maxSeq))) {
        if (last && last.hi) {
          // Extend existing gap
          last.hi = maxSeq;
        } else {
          // Create new gap.
          ranges.push({
            seq: last ? last.seq + 1 : 1,
            hi: maxSeq
          });
        }
      }
  
      // Insert new gaps into cache.
      ranges.map((gap) => {
        this._messages.put(gap);
      });
    }
  };
  
  /**
   * @class TopicMe - special case of {@link Tinode.Topic} for
   * managing data of the current user, including contact list.
   * @extends Tinode.Topic
   * @memberof Tinode
   *
   * @param {TopicMe.Callbacks} callbacks - Callbacks to receive various events.
   */
  var TopicMe = function(callbacks) {
    Topic.call(this, TOPIC_ME, callbacks);
    // List of contacts (topic_name -> Contact object)
    this._contacts = {};
  
    // me-specific callbacks
    if (callbacks) {
      this.onContactUpdate = callbacks.onContactUpdate;
    }
  };
  
  // Inherit everyting from the generic Topic
  TopicMe.prototype = Object.create(Topic.prototype, {
    // Override the original Topic._processMetaDesc.
    _processMetaDesc: {
      value: function(desc) {
        // Check if online contacts need to be turned off because P permission was removed.
        const turnOff = (desc.acs && !desc.acs.isPresencer()) && (this.acs && this.acs.isPresencer());
  
        // Copy parameters from desc object to this topic.
        mergeObj(this, desc);
        // String datetime headers to Date() objects.
        stringToDate(this);
  
        // 'P' permission was removed. All topics are offline now.
        if (turnOff) {
          Object.values(this._contacts).map((cont) => {
            if (cont.online) {
              cont.online = false;
              if (cont.seen) {
                cont.seen.when = new Date();
              } else {
                cont.seen = {
                  when: new Date()
                };
              }
              if (this.onContactUpdate) {
                this.onContactUpdate('off', cont);
              }
            }
          });
        }
  
        if (this.onMetaDesc) {
          this.onMetaDesc(this);
        }
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    // Override the original Topic._processMetaSub
    _processMetaSub: {
      value: function(subs) {
        let updateCount = 0;
        for (let idx in subs) {
          const sub = subs[idx];
          const topicName = sub.topic;
  
          // Don't show 'me' and 'fnd' topics in the list of contacts.
          if (topicName == TOPIC_FND || topicName == TOPIC_ME) {
            continue;
          }
          sub.updated = new Date(sub.updated);
          sub.touched = sub.touched ? new Date(sub.touched) : undefined;
          sub.deleted = sub.deleted ? new Date(sub.deleted) : null;
  
          let cont = null;
          if (sub.deleted) {
            cont = sub;
            delete this._contacts[topicName];
            this._tinode.cacheDel('topic', topicName);
          } else {
            // Ensure the values are defined and are integers.
            if (typeof sub.seq != 'undefined') {
              sub.seq = sub.seq | 0;
              sub.recv = sub.recv | 0;
              sub.read = sub.read | 0;
              sub.unread = sub.seq - sub.read;
            }
  
            if (sub.seen && sub.seen.when) {
              sub.seen.when = new Date(sub.seen.when);
            }
            cont = mergeToCache(this._contacts, topicName, sub);
  
            if (Tinode.isP2PTopicName(topicName)) {
              this._cachePutUser(topicName, cont);
            }
            // Notify topic of the update if it's an external update.
            if (!sub._noForwarding) {
              const topic = this._tinode.getTopic(topicName);
              if (topic) {
                sub._noForwarding = true;
                topic._processMetaDesc(sub);
              }
            }
          }
  
          updateCount++;
  
          if (this.onMetaSub) {
            this.onMetaSub(cont);
          }
        }
  
        if (this.onSubsUpdated) {
          this.onSubsUpdated(Object.keys(this._contacts), updateCount);
        }
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    // Called by Tinode when meta.sub is recived.
    _processMetaCreds: {
      value: function(creds, upd) {
        if (creds.length == 1 && creds[0] == Tinode.DEL_CHAR) {
          creds = [];
        }
        if (upd) {
          creds.map((cr) => {
            if (cr.val) {
              // Adding a credential.
              let idx = this._credentials.findIndex((el) => {
                return el.meth == cr.meth && el.val == cr.val;
              });
              if (idx < 0) {
                // Not found.
                if (!cr.done) {
                  // Unconfirmed credential replaces previous unconfirmed credential of the same method.
                  idx = this._credentials.findIndex((el) => {
                    return el.meth == cr.meth && !el.done;
                  });
                  if (idx >= 0) {
                    // Remove previous unconfirmed credential.
                    this._credentials.splice(idx, 1);
                  }
                }
                this._credentials.push(cr);
              } else {
                // Found. Maybe change 'done' status.
                this._credentials[idx].done = cr.done;
              }
            } else if (cr.resp) {
              // Handle credential confirmation.
              const idx = this._credentials.findIndex((el) => {
                return el.meth == cr.meth && !el.done;
              });
              if (idx >= 0) {
                this._credentials[idx].done = true;
              }
            }
          });
        } else {
          this._credentials = creds;
        }
        if (this.onCredsUpdated) {
          this.onCredsUpdated(this._credentials);
        }
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    // Process presence change message
    _routePres: {
      value: function(pres) {
        if (pres.what == 'term') {
          // The 'me' topic itself is detached. Mark as unsubscribed.
          this._resetSub();
          return;
        }
  
        if (pres.what == 'upd' && pres.src == TOPIC_ME) {
          // Update to me's description. Request updated value.
          this.getMeta(this.startMetaQuery().withDesc().build());
          return;
        }
  
        const cont = this._contacts[pres.src];
        if (cont) {
          switch (pres.what) {
            case 'on': // topic came online
              cont.online = true;
              break;
            case 'off': // topic went offline
              if (cont.online) {
                cont.online = false;
                if (cont.seen) {
                  cont.seen.when = new Date();
                } else {
                  cont.seen = {
                    when: new Date()
                  };
                }
              }
              break;
            case 'msg': // new message received
              cont.touched = new Date();
              cont.seq = pres.seq | 0;
              // Check if message is sent by the current user. If so it's been read already.
              if (!pres.act || this._tinode.isMe(pres.act)) {
                cont.read = cont.read ? Math.max(cont.read, cont.seq) : cont.seq;
                cont.recv = cont.recv ? Math.max(cont.read, cont.recv) : cont.read;
              }
              cont.unread = cont.seq - (cont.read | 0);
              break;
            case 'upd': // desc updated
              // Request updated subscription.
              this.getMeta(this.startMetaQuery().withLaterOneSub(pres.src).build());
              break;
            case 'acs': // access mode changed
              if (cont.acs) {
                cont.acs.updateAll(pres.dacs);
              } else {
                cont.acs = new AccessMode().updateAll(pres.dacs);
              }
              cont.touched = new Date();
              break;
            case 'ua': // user agent changed
              cont.seen = {
                when: new Date(),
                ua: pres.ua
              };
              break;
            case 'recv': // user's other session marked some messges as received
              pres.seq = pres.seq | 0;
              cont.recv = cont.recv ? Math.max(cont.recv, pres.seq) : pres.seq;
              break;
            case 'read': // user's other session marked some messages as read
              pres.seq = pres.seq | 0;
              cont.read = cont.read ? Math.max(cont.read, pres.seq) : pres.seq;
              cont.recv = cont.recv ? Math.max(cont.read, cont.recv) : cont.recv;
              cont.unread = cont.seq - cont.read;
              break;
            case 'gone': // topic deleted or unsubscribed from
              delete this._contacts[pres.src];
              this._tinode.cacheDel('topic', pres.src);
              break;
            case 'del':
              // Update topic.del value.
              break;
            default:
              this._tinode.logger("INFO: Unsupported presence update in 'me'", pres.what);
          }
  
          if (this.onContactUpdate) {
            this.onContactUpdate(pres.what, cont);
          }
        } else {
          if (pres.what == 'acs') {
            // New subscriptions and deleted/banned subscriptions have full
            // access mode (no + or - in the dacs string). Changes to known subscriptions are sent as
            // deltas, but they should not happen here.
            const acs = new AccessMode(pres.dacs);
            if (!acs || acs.mode == AccessMode._INVALID) {
              this._tinode.logger("ERROR: Invalid access mode update", pres.src, pres.dacs);
              return;
            } else if (acs.mode == AccessMode._NONE) {
              this._tinode.logger("WARNING: Removing non-existent subscription", pres.src, pres.dacs);
              return;
            } else {
              // New subscription. Send request for the full description.
              // Using .withOneSub (not .withLaterOneSub) to make sure IfModifiedSince is not set.
              this.getMeta(this.startMetaQuery().withOneSub(undefined, pres.src).build());
              // Create a dummy entry to catch online status update.
              this._contacts[pres.src] = {
                touched: new Date(),
                topic: pres.src,
                online: false,
                acs: acs
              };
            }
          } else if (pres.what == 'tags') {
            this.getMeta(this.startMetaQuery().withTags().build());
          }
        }
  
        if (this.onPres) {
          this.onPres(pres);
        }
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * Publishing to TopicMe is not supported. {@link Topic#publish} is overriden and thows an {Error} if called.
     * @memberof Tinode.TopicMe#
     * @throws {Error} Always throws an error.
     */
    publish: {
      value: function() {
        return Promise.reject(new Error("Publishing to 'me' is not supported"));
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * Delete validation credential.
     * @memberof Tinode.TopicMe#
     *
     * @param {String} topic - Name of the topic to delete
     * @param {String} user - User ID to remove.
     * @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
     */
    delCredential: {
      value: function(method, value) {
        if (!this._subscribed) {
          return Promise.reject(new Error("Cannot delete credential in inactive 'me' topic"));
        }
        // Send {del} message, return promise
        return this._tinode.delCredential(method, value).then((ctrl) => {
          // Remove deleted credential from the cache.
          const index = this._credentials.findIndex((el) => {
            return el.meth == method && el.val == value;
          });
          if (index > -1) {
            this._credentials.splice(index, 1);
          }
          // Notify listeners
          if (this.onCredsUpdated) {
            this.onCredsUpdated(this._credentials);
          }
          return ctrl;
        });
  
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * Iterate over cached contacts.
     *
     * @function
     * @memberof Tinode.TopicMe#
     * @param {TopicMe.ContactCallback} callback - Callback to call for each contact.
     * @param {function=} filter - Optionally filter contacts; include all if filter is false-ish, otherwise include those for
     *                            which filter returns true-ish.
     * @param {Object=} context - Context to use for calling the `callback`, i.e. the value of `this` inside the callback.
     */
    contacts: {
      value: function(callback, filter, context) {
        for (let idx in this._contacts) {
          const c = this._contacts[idx];
          if (!filter || filter(c)) {
            callback.call(context, c, idx, this._contacts);
          }
        }
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * Update a cached contact with new read/received/message count.
     * @function
     * @memberof Tinode.TopicMe#
     *
     * @param {String} contactName - UID of contact to update.
     * @param {String} what - Which count to update, one of <tt>"read", "recv", "msg"</tt>
     * @param {Number} seq - New value of the count.
     * @param {Date} ts - Timestamp of the update.
     */
    setMsgReadRecv: {
      value: function(contactName, what, seq, ts) {
        const cont = this._contacts[contactName];
        let oldVal, doUpdate = false;
        let mode = null;
        if (cont) {
          seq = seq | 0;
          cont.seq = cont.seq | 0;
          cont.read = cont.read | 0;
          cont.recv = cont.recv | 0;
          switch (what) {
            case 'recv':
              oldVal = cont.recv;
              cont.recv = Math.max(cont.recv, seq);
              doUpdate = (oldVal != cont.recv);
              break;
            case 'read':
              oldVal = cont.read;
              cont.read = Math.max(cont.read, seq);
              doUpdate = (oldVal != cont.read);
              break;
            case 'msg':
              oldVal = cont.seq;
              cont.seq = Math.max(cont.seq, seq);
              if (!cont.touched || cont.touched < ts) {
                cont.touched = ts;
              }
              doUpdate = (oldVal != cont.seq);
              break;
          }
  
          // Sanity checks.
          if (cont.recv < cont.read) {
            cont.recv = cont.read;
            doUpdate = true;
          }
          if (cont.seq < cont.recv) {
            cont.seq = cont.recv;
            if (!cont.touched || cont.touched < ts) {
              cont.touched = ts;
            }
            doUpdate = true;
          }
          cont.unread = cont.seq - cont.read;
  
          if (doUpdate && (!cont.acs || !cont.acs.isMuted()) && this.onContactUpdate) {
            this.onContactUpdate(what, cont);
          }
        }
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * Get cached read/received/message count for the given contact.
     * @function
     * @memberof Tinode.TopicMe#
     *
     * @param {String} contactName - UID of contact to read.
     * @param {String} what - Which count to read, one of <tt>"read", "recv", "msg"</tt>
     */
    getMsgReadRecv: {
      value: function(contactName, what) {
        const cont = this._contacts[contactName];
        if (cont) {
          switch (what) {
            case 'recv':
              return cont.recv | 0;
            case 'read':
              return cont.read | 0;
            case 'msg':
              return cont.seq | 0;
          }
        }
        return 0;
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * Get a contact from cache.
     * @memberof Tinode.TopicMe#
     *
     * @param {string} name - Name of the contact to get, either a UID (for p2p topics) or a topic name.
     * @returns {Tinode.Contact} - Contact or `undefined`.
     */
    getContact: {
      value: function(name) {
        return this._contacts[name];
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * Get access mode of a given contact from cache.
     * @memberof Tinode.TopicMe#
     *
     * @param {String} name - Name of the contact to get access mode for, either a UID (for p2p topics)
     *        or a topic name; if missing, access mode for the 'me' topic itself.
     * @returns {string} - access mode, such as `RWP`.
     */
    getAccessMode: {
      value: function(name) {
        if (name) {
          const cont = this._contacts[name];
          return cont ? cont.acs : null;
        }
        return this.acs;
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * Check if contact is archived, i.e. contact.private.arch == true.
     * @memberof Tinode.TopicMe#
     *
     * @param {String} name - Name of the contact to check archived status, either a UID (for p2p topics) or a topic name.
     * @returns {boolean} - true if contact is archived, false otherwise.
     */
    isArchived: {
      value: function(name) {
        const cont = this._contacts[name];
        return cont ? ((cont.private && cont.private.arch) ? true : false) : null;
      },
      enumerable: true,
      configurable: true,
      writable: true
    },
  
    /**
     * @typedef Tinode.Credential
     * @memberof Tinode
     * @type Object
     * @property {string} meth - validation method such as 'email' or 'tel'.
     * @property {string} val - credential value, i.e. 'jdoe@example.com' or '+17025551234'
     * @property {boolean} done - true if credential is validated.
     */
    /**
     * Get the user's credentials: email, phone, etc.
     * @memberof Tinode.TopicMe#
     *
     * @returns {Tinode.Credential[]} - array of credentials.
     */
    getCredentials: {
      value: function() {
        return this._credentials;
      },
      enumerable: true,
      configurable: true,
      writable: true
    }
  });
  TopicMe.prototype.constructor = TopicMe;
  
  /**
   * @class TopicFnd - special case of {@link Tinode.Topic} for searching for
   * contacts and group topics.
   * @extends Tinode.Topic
   * @memberof Tinode
   *
   * @param {TopicFnd.Callbacks} callbacks - Callbacks to receive various events.
   */
  var TopicFnd = function(callbacks) {
    Topic.call(this, TOPIC_FND, callbacks);
    // List of users and topics uid or topic_name -> Contact object)
    this._contacts = {};
  };
  
  // Inherit everyting from the generic Topic
  TopicFnd.prototype = Object.create(Topic.prototype, {
    // Override the original Topic._processMetaSub
    _processMetaSub: {
      value: function(subs) {
        let updateCount = Object.getOwnPropertyNames(this._contacts).length;
        // Reset contact list.
        this._contacts = {};
        for (let idx in subs) {
          let sub = subs[idx];
          const indexBy = sub.topic ? sub.topic : sub.user;
  
          sub.updated = new Date(sub.updated);
          if (sub.seen && sub.seen.when) {
            sub.seen.when = new Date(sub.seen.when);
          }
  
          sub = mergeToCache(this._contacts, indexBy, sub);
          updateCount++;
  
          if (this.onMetaSub) {
            this.onMetaSub(sub);
          }
        }
  
        if (updateCount > 0 && this.onSubsUpdated) {
          this.onSubsUpdated(Object.keys(this._contacts));
        }
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * Publishing to TopicFnd is not supported. {@link Topic#publish} is overriden and thows an {Error} if called.
     * @memberof Tinode.TopicFnd#
     * @throws {Error} Always throws an error.
     */
    publish: {
      value: function() {
        return Promise.reject(new Error("Publishing to 'fnd' is not supported"));
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * setMeta to TopicFnd resets contact list in addition to sending the message.
     * @memberof Tinode.TopicFnd#
     * @param {Tinode.SetParams} params parameters to update.
     * @returns {Promise} Promise to be resolved/rejected when the server responds to request.
     */
    setMeta: {
      value: function(params) {
        const instance = this;
        return Object.getPrototypeOf(TopicFnd.prototype).setMeta.call(this, params).then(function() {
          if (Object.keys(instance._contacts).length > 0) {
            instance._contacts = {};
            if (instance.onSubsUpdated) {
              instance.onSubsUpdated([]);
            }
          }
        });
      },
      enumerable: true,
      configurable: true,
      writable: false
    },
  
    /**
     * Iterate over found contacts. If callback is undefined, use {@link this.onMetaSub}.
     * @function
     * @memberof Tinode.TopicFnd#
     * @param {TopicFnd.ContactCallback} callback - Callback to call for each contact.
     * @param {Object} context - Context to use for calling the `callback`, i.e. the value of `this` inside the callback.
     */
    contacts: {
      value: function(callback, context) {
        const cb = (callback || this.onMetaSub);
        if (cb) {
          for (let idx in this._contacts) {
            cb.call(context, this._contacts[idx], idx, this._contacts);
          }
        }
      },
      enumerable: true,
      configurable: true,
      writable: true
    }
  });
  TopicFnd.prototype.constructor = TopicFnd;
  
  /**
   * @class LargeFileHelper - collection of utilities for uploading and downloading files
   * out of band. Don't instantiate this class directly. Use {Tinode.getLargeFileHelper} instead.
   * @memberof Tinode
   *
   * @param {Tinode} tinode - the main Tinode object.
   */
  var LargeFileHelper = function(tinode) {
    this._tinode = tinode;
  
    this._apiKey = tinode._apiKey;
    this._authToken = tinode.getAuthToken();
    this._msgId = tinode.getNextUniqueId();
    this.xhr = new XMLHttpRequest();
  
    // Promise
    this.toResolve = null;
    this.toReject = null;
  
    // Callbacks
    this.onProgress = null;
    this.onSuccess = null;
    this.onFailure = null;
  }
  
  LargeFileHelper.prototype = {
    /**
     * Start uploading the file to a non-default endpoint.
     *
     * @memberof Tinode.LargeFileHelper#
     *
     * @param {String} baseUrl alternative base URL of upload server.
     * @param {File|Blob} data to upload
     * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
     * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
     * @param {Callback} onFailure callback. Called in case of a failure.
     *
     * @returns {Promise} resolved/rejected when the upload is completed/failed.
     */
    uploadWithBaseUrl: function(baseUrl, data, onProgress, onSuccess, onFailure) {
      if (!this._authToken) {
        throw new Error("Must authenticate first");
      }
      const instance = this;
  
      let url = '/v' + PROTOCOL_VERSION + '/file/u/';
      if (baseUrl) {
        if (baseUrl.indexOf('http://') == 0 || baseUrl.indexOf('https://') == 0) {
          url = baseUrl + url;
        } else {
          throw new Error("Invalid base URL '" + baseUrl + "'");
        }
      }
      this.xhr.open('POST', url, true);
      this.xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
      this.xhr.setRequestHeader('X-Tinode-Auth', 'Token ' + this._authToken.token);
      const result = new Promise((resolve, reject) => {
        this.toResolve = resolve;
        this.toReject = reject;
      });
  
      this.onProgress = onProgress;
      this.onSuccess = onSuccess;
      this.onFailure = onFailure;
  
      this.xhr.upload.onprogress = (e) => {
        if (e.lengthComputable && instance.onProgress) {
          instance.onProgress(e.loaded / e.total);
        }
      }
  
      this.xhr.onload = function() {
        let pkt;
        try {
          pkt = JSON.parse(this.response, jsonParseHelper);
        } catch (err) {
          instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.response);
          pkt = {
            ctrl: {
              code: this.status,
              text: this.statusText
            }
          };
        }
  
        if (this.status >= 200 && this.status < 300) {
          if (instance.toResolve) {
            instance.toResolve(pkt.ctrl.params.url);
          }
          if (instance.onSuccess) {
            instance.onSuccess(pkt.ctrl);
          }
        } else if (this.status >= 400) {
          if (instance.toReject) {
            instance.toReject(new Error(pkt.ctrl.text + " (" + pkt.ctrl.code + ")"));
          }
          if (instance.onFailure) {
            instance.onFailure(pkt.ctrl)
          }
        } else {
          instance._tinode.logger("ERROR: Unexpected server response status", this.status, this.response);
        }
      };
  
      this.xhr.onerror = function(e) {
        if (instance.toReject) {
          instance.toReject(new Error("failed"));
        }
        if (instance.onFailure) {
          instance.onFailure(null);
        }
      };
  
      this.xhr.onabort = function(e) {
        if (instance.toReject) {
          instance.toReject(new Error("upload cancelled by user"));
        }
        if (instance.onFailure) {
          instance.onFailure(null);
        }
      };
  
      try {
        const form = new FormData();
        form.append('file', data);
        form.set('id', this._msgId);
        this.xhr.send(form);
      } catch (err) {
        if (this.toReject) {
          this.toReject(err);
        }
        if (this.onFailure) {
          this.onFailure(null);
        }
      }
  
      return result;
    },
  
    /**
     * Start uploading the file to default endpoint.
     *
     * @memberof Tinode.LargeFileHelper#
     *
     * @param {File|Blob} data to upload
     * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
     * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
     * @param {Callback} onFailure callback. Called in case of a failure.
     *
     * @returns {Promise} resolved/rejected when the upload is completed/failed.
     */
    upload: function(data, onProgress, onSuccess, onFailure) {
      return this.uploadWithBaseUrl(undefined, data, onProgress, onSuccess, onFailure);
    },
  
    /**
     * Download the file from a given URL using GET request. This method works with the Tinode server only.
     *
     * @memberof Tinode.LargeFileHelper#
     *
     * @param {String} relativeUrl - URL to download the file from. Must be relative url, i.e. must not contain the host.
     * @param {String=} filename - file name to use for the downloaded file.
     *
     * @returns {Promise} resolved/rejected when the download is completed/failed.
     */
    download: function(relativeUrl, filename, mimetype, onProgress, onError) {
      if (!Tinode.isRelativeURL(relativeUrl)) {
        // As a security measure refuse to download from an absolute URL.
        if (onError) {
          onError("The URL '" + relativeUrl + "' must be relative, not absolute");
        }
        return;
      }
      if (!this._authToken) {
        if (onError) {
          onError("Must authenticate first");
        }
        return;
      }
      const instance = this;
      // Get data as blob (stored by the browser as a temporary file).
      this.xhr.open('GET', relativeUrl, true);
      this.xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
      this.xhr.setRequestHeader('X-Tinode-Auth', 'Token ' + this._authToken.token);
      this.xhr.responseType = 'blob';
  
      this.onProgress = onProgress;
      this.xhr.onprogress = function(e) {
        if (instance.onProgress) {
          // Passing e.loaded instead of e.loaded/e.total because e.total
          // is always 0 with gzip compression enabled by the server.
          instance.onProgress(e.loaded);
        }
      };
  
      const result = new Promise((resolve, reject) => {
        this.toResolve = resolve;
        this.toReject = reject;
      });
  
      // The blob needs to be saved as file. There is no known way to
      // save the blob as file other than to fake a click on an <a href... download=...>.
      this.xhr.onload = function() {
        if (this.status == 200) {
          const link = document.createElement('a');
          // URL.createObjectURL is not available in non-browser environment. This call will fail.
          link.href = window.URL.createObjectURL(new Blob([this.response], {
            type: mimetype
          }));
          link.style.display = 'none';
          link.setAttribute('download', filename);
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
          window.URL.revokeObjectURL(link.href);
          if (instance.toResolve) {
            instance.toResolve();
          }
        } else if (this.status >= 400 && instance.toReject) {
          // The this.responseText is undefined, must use this.response which is a blob.
          // Need to convert this.response to JSON. The blob can only be accessed by the
          // FileReader.
          const reader = new FileReader();
          reader.onload = function() {
            try {
              const pkt = JSON.parse(this.result, jsonParseHelper);
              instance.toReject(new Error(pkt.ctrl.text + " (" + pkt.ctrl.code + ")"));
            } catch (err) {
              instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.result);
              instance.toReject(err);
            }
          };
          reader.readAsText(this.response);
        }
      };
  
      this.xhr.onerror = function(e) {
        if (instance.toReject) {
          instance.toReject(new Error("failed"));
        }
      };
  
      this.xhr.onabort = function() {
        if (instance.toReject) {
          instance.toReject(null);
        }
      };
  
      try {
        this.xhr.send();
      } catch (err) {
        if (this.toReject) {
          this.toReject(err);
        }
      }
  
      return result;
    },
  
    /**
     * Try to cancel an ongoing upload or download.
     * @memberof Tinode.LargeFileHelper#
     */
    cancel: function() {
      if (this.xhr && this.xhr.readyState < 4) {
        this.xhr.abort();
      }
    },
  
    /**
     * Get unique id of this request.
     * @memberof Tinode.LargeFileHelper#
     *
     * @returns {string} unique id
     */
    getId: function() {
      return this._msgId;
    }
  };
  
  /**
   * @class Message - definition a communication message.
   * Work in progress.
   * @memberof Tinode
   *
   * @param {string} topic_ - name of the topic the message belongs to.
   * @param {string | Drafty} content_ - message contant.
   */
  var Message = function(topic_, content_) {
    this.status = Message.STATUS_NONE;
    this.topic = topic_;
    this.content = content_;
  }
  
  Message.STATUS_NONE = MESSAGE_STATUS_NONE;
  Message.STATUS_QUEUED = MESSAGE_STATUS_QUEUED;
  Message.STATUS_SENDING = MESSAGE_STATUS_SENDING;
  Message.STATUS_FAILED = MESSAGE_STATUS_FAILED;
  Message.STATUS_SENT = MESSAGE_STATUS_SENT;
  Message.STATUS_RECEIVED = MESSAGE_STATUS_RECEIVED;
  Message.STATUS_READ = MESSAGE_STATUS_READ;
  Message.STATUS_TO_ME = MESSAGE_STATUS_TO_ME;
  
  Message.prototype = {
    /**
     * Convert message object to {pub} packet.
     */
    toJSON: function() {
  
    },
    /**
     * Parse JSON into message.
     */
    fromJSON: function(json) {
  
    }
  }
  Message.prototype.constructor = Message;
  
  if (typeof module != 'undefined') {
    module.exports = Tinode;
    module.exports.Drafty = Drafty;
    module.exports.AccessMode = AccessMode;
  }
  