diff --git a/index.html b/index.html index e6e35c3b..caf87972 100644 --- a/index.html +++ b/index.html @@ -116,6 +116,15 @@ return card; } + // construct email params + function email(emailAddress) { + var tags = []; + if(emailAddress) { + tags.push("email:" + emailAddress); + } + return tags + } + /* BEGIN Popup/dropdown menu */ var ContextMenu = React.createClass({ componentWillUnmount: function() { @@ -650,6 +659,7 @@ login: '', password: '', password2: '', + email: '', fn: '', // full/formatted name imageDataUrl: null, errorCleared: false @@ -664,6 +674,9 @@ handlePassword2Change: function(e) { this.setState({password2: e.target.value}); }, + handleEmailChange: function(e) { + this.setState({email: e.target.value}) + }, handleFnChange: function(e) { this.setState({fn: e.target.value}); }, @@ -679,7 +692,9 @@ this.props.onCreateAccount( this.state.login.trim(), this.state.password.trim(), - vcard(this.state.fn, this.state.imageDataUrl)); + vcard(this.state.fn, this.state.imageDataUrl), + null, + email(this.state.email)); } }, render: function() { @@ -700,9 +715,13 @@ +
+ +
+ value={this.state.fn} onChange={this.handleFnChange} required/>
@@ -2502,12 +2521,13 @@ this.setState({sidePanelSelected: 'register'}); }, // Actual registration of a new account. - handleNewAccountRequest: function(login_, password_, public_, private_) { + handleNewAccountRequest: function(login_, password_, public_, private_, tags_) { var instance = this; + tags_ = tags_ || []; // init tags_ value just in case undefined Tinode.connect(this.state.serverAddress) .then(function() { return Tinode.createAccountBasic(login_, password_, - {login: true, public: public_, private: private_}); + {defacs: {auth: "JRWPS", anon: "N"}, public: public_, private: private_, tags: tags_}); }) .then(function() { instance.handleLoginSuccessful(instance); diff --git a/tinode.js b/tinode.js new file mode 100755 index 00000000..19f80d06 --- /dev/null +++ b/tinode.js @@ -0,0 +1,2735 @@ +/** + * @file All the logic need to connect to Tinode chat server. Tinode is a single js + * file with no dependencies. Just include tinode.js into your project. + * It will add a singleton Tinode object to the top level object, usually window. + * See https://github.com/tinode/example-react-js for real-life usage. + * + * @copyright 2015-2017 Tinode + * @summary Javascript bindings for Tinode. + * @license Apache 2.0 + * @version 0.13 + * + * @example + * + * + * + * + * + * ... + * + * + */ + +/** + * Tinode object is automatically accessible in the top scope, usually window. + * DO NOT call the constructor. It's shown here due to a + * deficiency in jsdoc. + * + * @class Tinode + */ +(function(environment) { // closure for web browsers + 'use strict'; + + // Global constants + var PROTOVERSION = "0"; + var VERSION = "0.13"; + var LIBRARY = "tinodejs/" + VERSION; + + var TOPIC_NEW = "new"; + var TOPIC_ME = "me"; + var TOPIC_FND = "fnd"; + var USER_NEW = "new"; + + // Utility functions + + // RFC3339 formater of Date + var rfc3339DateString = function(d) { + function pad(n) { + return n < 10 ? '0' + n : n; + } + return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) + + 'T' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) + 'Z'; + } + + // Copy src's own properties to dst. + var mergeObj = function(dst, src) { + for (var prop in src) { + if (src.hasOwnProperty(prop) && src[prop]) { + dst[prop] = src[prop]; + } + } + } + + // Update object stored in a cache. Returns updated value. + var mergeToCache = function(cache, key, newval) { + var oldval = cache[key]; + if (oldval) { + mergeObj(oldval, newval); + } else { + cache[key] = newval; + oldval = newval; + } + + return oldval; + } + + // Basic cross-domain requester. Supports normal browsers and IE8+ + var xdreq = function() { + var xdreq = null; + + // Detect browser support for CORS + if ('withCredentials' in new XMLHttpRequest()) { + // Support for standard cross-domain requests + xdreq = new XMLHttpRequest(); + } else if (typeof XDomainRequest !== "undefined") { + // IE-specific "CORS" with XDR + xdreq = new XDomainRequest(); + } else { + // Browser without CORS support, don't know how to handle + throw new Error("browser not supported"); + } + + return xdreq; + } + + // JSON stringify helper - pre-processor for JSON.stringify + var jsonBuildHelper = function(key, val) { + // strip out empty elements while serializing objects to JSON + if (val === undefined || val === null || val === false || val.length === 0 || + ((typeof val === "object") && (Object.keys(val).length === 0))) { + return undefined; + // Convert javascript Date objects to rfc3339 strings + } else if (val instanceof Date) { + val = rfc3339DateString(val); + } + return val; + } + + // Attempt to convert date strings to objects. + var jsonParseHelper = function(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) { + var date = new Date(val); + if (date) { + return date; + } + } + return val; + }; + + // Trims very long strings (encoded images) to make logged packets more readable. + var jsonLoggerHelper = function(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); + }; + + /** + * Circular buffer: holds at most 'size' objects. Once the limit is reached, + * the new object overwrites the oldest one. + * + * @class CBuffer + * @memberof Tinode + * @protected + * + * @param {number} size - Maximum number of elements to hold in the buffer. + */ + var CBuffer = function(size) { + var base = 0, + buffer = [], + contains = 0; + + return { + /** + * Get an element at the given position. + * @memberof Tinode.CBuffer# + * @param {number} position - Position to fetch from. + * @returns {Object} Element at the given position or undefined + */ + getAt: function(at) { + if (at >= contains || at < 0) return undefined; + return buffer[(at + base) % size]; + }, + + /** 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 {(Array|...Object)} - One or more objects to insert. + */ + put: function() { + var insert; + // inspect arguments: if array, insert its elements, if one or more arguments, insert them one by one + if (arguments.length === 1 && Array.isArray(arguments[0])) { + insert = arguments[0]; + } else { + insert = arguments; + } + for (var idx in insert) { + buffer[(base + contains) % size] = insert[idx]; + contains += 1; + if (contains > size) { + contains = size; + base = (base + 1) % size; + } + } + }, + + /** + * Return the maximum number of element the buffer can hold + * @memberof Tinode.CBuffer# + * @return {number} The size of the buffer. + */ + size: function() { + return size; + }, + + /** + * Return the number of elements the buffer holds + * @memberof Tinode.CBuffer# + * @returns {number} Number of elements in the buffer. + */ + contains: function() { + return contains; + }, + + /** + * Discard all elements and reset the buffer to the new size (maximum number of elements). + * @memberof Tinode.CBuffer# + * @param {number} newSize - New size of the buffer. + */ + reset: function(newSize) { + if (newSize) { + size = newSize; + } + base = 0; + contains = 0; + }, + + /** + * 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 {ForEachCallbackType} callback - Function to call for each element. + * @param {Object} context - calling context (i.e. value of 'this' in callback) + */ + forEach: function(callback, context) { + for (var i = 0; i < contains; i++) { + callback.call(context, buffer[(i + base) % size], i); + } + } + } + } + + // Helper function for creating an endpoint URL + function makeBaseUrl(host, protocol, apiKey) { + var url = null; + + if (protocol === 'http' || protocol === 'https' || protocol === 'ws' || protocol === 'wss') { + url = protocol + '://'; + url += host; + if (url.charAt(url.length - 1) !== '/') { + url += '/'; + } + url += "v" + PROTOVERSION + "/channels"; + if (protocol === "http" || protocol === "https") { + // Long polling endpoint end with "lp", i.e. + // '/v0/channels/lp' vs just '/v0/channels' + url += "/lp"; + } + url += "?apikey=" + apiKey; + } + + return url; + } + + /** + * An abstraction for a websocket or a long polling connection. + * + * @class Connection + * @memberof Tinode + * @protected + * + * @param {string} transport_ - network transport to use, either `ws`/`wss` for websocket or `lp` for long polling. + * @returns a connection object. + */ + var Connection = (function(transport_, autoreconnect_) { + var instance; + + var host; + var secure; + var apiKey; + + var 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 + var _boffTimer = null; + var _boffIteration = 0; + var _boffClosed = false; // Indicator if the socket was manually closed - don't autoreconnect if true. + + function log(text) { + if (instance.logger) { + instance.logger(text); + } + } + + // Reconnect after a timeout. + function reconnect() { + // Clear timer + window.clearTimeout(_boffTimer); + // Calculate when to fire the reconnect attempt + var 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); + _boffTimer = setTimeout(function() { + console.log("Reconnecting, iter=" + _boffIteration + ", timeout=" + timeout); + // Maybe the socket was closed while we waited for the timer? + if (!_boffClosed) { + instance.connect().catch(function(){/* do nothing */}); + } + }, timeout); + } + + // Initialization for Websocket + function init_ws() { + var _socket = null; + + return { + /** + * Initiate a new connection + * @memberof Tinode.Connection# + * @return {Promise} Promise resolved/rejected when the connection call completes, + resolution is called without parameters, rejection passes the {Error} as parameter. + */ + connect: function(host_) { + if (_socket && _socket.readyState === 1) { + return Promise.resolve(); + } + + if (host_) { + host = host_; + } + + return new Promise(function(resolve, reject) { + var url = makeBaseUrl(host, secure ? "wss" : "ws", apiKey); + + log("Connecting to: " + url); + + var conn = new WebSocket(url); + + conn.onopen = function(evt) { + _boffClosed = false; + + if (instance.onOpen) { + instance.onOpen(); + } + resolve(); + + if (autoreconnect) { + window.clearTimeout(_boffTimer); + _boffTimer = null; + _boffIteration = 0; + } + } + + conn.onclose = function(evt) { + _socket = null; + + if (instance.onDisconnect) { + instance.onDisconnect(null); + } + + if (!_boffClosed && autoreconnect) { + reconnect(); + } + } + + conn.onerror = function(err) { + reject(err); + } + + conn.onmessage = function(evt) { + if (instance.onMessage) { + instance.onMessage(evt.data); + } + } + _socket = conn; + }); + }, + + /** Terminate the network connection + * @memberof Tinode.Connection# + */ + disconnect: function() { + if (_socket) { + _boffClosed = true; + _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. + */ + 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 + */ + isConnected: function() { + return (_socket && (_socket.readyState === 1)); + } + } + } + + // Initialization for long polling. + function init_lp() { + // Fully composed endpoint URL, with API key & SID + var _lpURL = null; + + var _poller = null; + var _sender = null; + + function lp_sender(url_) { + var sender = xdreq(); + sender.open('POST', url_, true); + + sender.onreadystatechange = function(evt) { + if (sender.readyState == 4 && sender.status >= 400) { + // Some sort of error response + throw new Error("LP sender failed, " + sender.status); + } + } + + return sender; + } + + function lp_poller(url_, resolve, reject) { + var poller = xdreq(); + poller.open('GET', url_, true); + + poller.onreadystatechange = function(evt) { + if (poller.readyState == 4) { // 4 == DONE + console.log("got response: " + poller.responseText); + if (poller.status == 201) { // 201 == HTTP.Created, get SID + var pkt = JSON.parse(poller.responseText); + var text = poller.responseText; + + _lpURL = url_ + "&sid=" + pkt.ctrl.params.sid + poller = lp_poller(_lpURL); + poller.send(null) + if (resolve) { + resolve(); + } + if (instance.onMessage) { + onMessage(text); + } + } else if (poller.status == 200) { // 200 = HTTP.OK + 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) { + reject(poller.responseText); + } + if (instance.onMessage) { + instance.onMessage(poller.responseText); + } + if (instance.onDisconnect) { + instance.onDisconnect(new Error("" + poller.status + " " + poller.responseText)); + } + } + } + } + + return poller; + } + + return { + connect: function(host_) { + if (host_) { + host = host_; + } + + return new Promise(function(resolve, reject){ + var url = makeBaseUrl(host, secure ? "https" : "http", apiKey); + log("Connecting to: " + url); + _poller = lp_poller(url, resolve, reject); + _poller.send(null) + }).catch(function() { + + }); + }, + disconnect: function() { + if (_sender) { + _sender.abort(); + _sender = null; + } + if (_poller) { + _poller.abort(); + _poller = null; + } + if (instance.onDisconnect) { + instance.onDisconnect(null); + } + // Ensure it's reconstructed + _lpURL = null; + }, + 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"); + } + }, + isConnected: function() { + return (_poller && true); + } + }; + } + + if (transport_ === "lp") { + // explicit request to use long polling + instance = init_lp(); + } else if (transport_ === "ws") { + // explicit request to use web socket + // if websockets are not available, horrible things will happen + instance = init_ws(); + } else { + // Default transport selection + if (!window["WebSocket"]) { + // The browser has no websockets + instance = init_lp(); + } else { + // Using web sockets -- default + instance = init_ws(); + } + } + + instance.setup = function(host_, secure_, apiKey_) { + host = host_; + secure = secure_; + apiKey = apiKey_; + }; + + // 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# + */ + instance.onMessage = undefined; + + /** + * A callback for reporting a dropped connection. + * @type {function} + * @memberof Tinode.Connection# + */ + instance.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# + */ + instance.onOpen = undefined; + + /** + * A callback to log events from Connection. See {@link Tinode.Connection#logger}. + * @callback LoggerCallbackType + * @memberof Tinode.Connection + * @param {string} event - Event to log. + */ + /** + * A callback to report logging events. + * @memberof Tinode.Connection# + * @type {Tinode.Connection.LoggerCallbackType} + */ + instance.logger = undefined; + + return instance; + }); + + + // Core Tinode functionality. + var Tinode = (function() { + var instance; + + // Initialize Tinode instance + function init() { + // Private variables + + // Client-provided application name, format / + var _appName = "Undefined"; + var _platform = navigator.platform; + // Logging to console enabled + var _loggingEnabled = false; + // When logging, trip long strings (base64-encoded images) for readability + var _trimLongStrings = false; + // A connection object, see Connection above. + var _connection = null; + // UID of the currently authenticated user + var _myUID = null; + // Login used in the last successful basic authentication + var _login = null; + // Token which can be used for login instead of login/password. + var _loginToken = null; + // Counter of received packets + var _inPacketCount = 0; + // Counter for generating unique message IDs + var _messageId = 0; + + // Information about the server, if connected + var _serverInfo = null; + + // Generic cache, currently used for topics/users + var _cache = {}; + // Cache of pending promises + var _pendingPromises = {}; + + // Private methods + + // Console logger + function log(str) { + if (_loggingEnabled) { + var d = new Date() + var dateString = ('0' + d.getUTCHours()).slice(-2) + ':' + + ('0' + d.getUTCMinutes()).slice(-2) + ':' + + ('0' + d.getUTCSeconds()).slice(-2) + ':' + + ('0' + d.getUTCMilliseconds()).slice(-3); + + console.log('[' + dateString + '] ' + str); + } + } + + // Access to Tinode's cache of objects + function cachePut(type, name, obj) { + _cache[type + ":" + name] = obj; + } + + function cacheGet(type, name) { + return _cache[type + ":" + name]; + } + + function cacheDel(type, name) { + delete _cache[type + ":" + name]; + } + // Enumerate all items in cache, call func for each item. + // Enumeration stops if func returns true. + function cacheMap(func, context) { + for (var idx in _cache) { + if (func(_cache[idx], idx, context)) { + break; + } + } + } + + // Make limited cache management available to topic. + function attachCacheToTopic(topic) { + topic._cacheGetUser = function(uid) { + return cacheGet("user", uid); + }; + topic._cachePutUser = function(uid, user) { + return cachePut("user", uid, user); + }; + topic._cacheDelUser = function(uid) { + return cacheDel("user", uid); + }; + topic._cachePutSelf = function() { + return cachePut("topic", topic.name, topic); + } + topic._cacheDelSelf = function() { + return cacheDel("topic", topic.name); + } + } + + // Resolve or reject a pending promise. + // Unresolved promises are stored in _pendingPromises. + function execPromise(id, code, onOK, errorText) { + var callbacks = _pendingPromises[id]; + if (callbacks) { + delete _pendingPromises[id]; + if (code >= 200 && code < 400) { + if (callbacks.resolve) { + callbacks.resolve(onOK); + } + } else if (callbacks.reject) { + callbacks.reject(new Error("" + code + " " + errorText)); + } + } + } + + // Generator of default promises for sent packets + var makePromise = function(id) { + var promise = null; + if (id) { + var promise = new Promise(function(resolve, reject) { + // Stored callbacks will be called when the response packet with this Id arrives + _pendingPromises[id] = { + "resolve": resolve, + "reject": reject + }; + }) + } + return promise; + } + + // Generates unique message IDs + function getNextMessageId() { + return (_messageId != 0) ? '' + _messageId++ : undefined; + } + + // Get User Agent string + function getUserAgent() { + return _appName + " (" + _platform + ") " + LIBRARY; + } + + // Generator of packets stubs + function initPacket(type, topic) { + var pkt = null; + switch (type) { + case "hi": + return { + "hi": { + "id": getNextMessageId(), + "ver": VERSION, + "ua": getUserAgent(), + } + }; + + case "acc": + return { + "acc": { + "id": getNextMessageId(), + "user": null, + "scheme": null, + "secret": null, + "login": false, + "desc": {} + } + }; + + case "login": + return { + "login": { + "id": getNextMessageId(), + "scheme": null, + "secret": null + } + }; + + case "sub": + return { + "sub": { + "id": getNextMessageId(), + "topic": topic, + "set": {}, + "get": {} + } + }; + + case "leave": + return { + "leave": { + "id": getNextMessageId(), + "topic": topic, + "unsub": false + } + }; + + case "pub": + return { + "pub": { + "id": getNextMessageId(), + "topic": topic, + "params": {}, + "content": {} + } + }; + + case "get": + return { + "get": { + "id": getNextMessageId(), + "topic": topic, + "what": null, // data, sub, desc, space separated list; unknown strings are ignored + "desc": {}, + "sub": {}, + "data": {} + } + }; + + case "set": + return { + "set": { + "id": getNextMessageId(), + "topic": topic, + "desc": {}, + "sub": {} + } + }; + + case "del": + return { + "del": { + "id": getNextMessageId(), + "topic": topic + } + }; + + 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. + function sendBasic(pkt) { + var msg = JSON.stringify(pkt, jsonBuildHelper); + log("out: " + (_trimLongStrings ? JSON.stringify(pkt, jsonLoggerHelper) : msg)); + _connection.sendText(msg); + } + + // Send a packet returning a promise. + function sendWithPromise(pkt, id) { + var promise = makePromise(id); + var msg = JSON.stringify(pkt, jsonBuildHelper); + log("out: " + (_trimLongStrings ? JSON.stringify(pkt, jsonLoggerHelper) : msg)); + _connection.sendText(msg); + return promise; + } + + // The main message dispatcher. + function dispatchMessage(data) { + // Skip empty response. This happens when LP times out. + if (!data) return; + + _inPacketCount++; + + // Send raw message to listener + if (instance.onRawMessage) { + instance.onRawMessage(data); + } + + var pkt = JSON.parse(data, jsonParseHelper); + if (!pkt) { + log("in: " + data); + log("ERROR: failed to parse data"); + } else { + log("in: " + (_trimLongStrings ? JSON.stringify(pkt, jsonLoggerHelper) : data)); + + // Send complete packet to listener + if (instance.onMessage) { + instance.onMessage(pkt); + } + + if (pkt.ctrl) { + // Handling {ctrl} message + if (instance.onCtrlMessage) { + instance.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); + } + } else if (pkt.meta) { + // Handling a {meta} message. + + // Preferred API: Route meta to topic, if one is registered + var topic = cacheGet("topic", pkt.meta.topic); + if (topic) { + topic._routeMeta(pkt.meta); + } + + // Secondary API: callback + if (instance.onMetaMessage) { + instance.onMetaMessage(pkt.meta); + } + } else if (pkt.data) { + // Handling {data} message + + // Preferred API: Route data to topic, if one is registered + var topic = cacheGet("topic", pkt.data.topic); + if (topic) { + topic._routeData(pkt.data); + } + + // Secondary API: Call callback + if (instance.onDataMessage) { + instance.onDataMessage(pkt.data); + } + } else if (pkt.pres) { + // Handling {pres} message + + // Preferred API: Route presence to topic, if one is registered + var topic = cacheGet("topic", pkt.pres.topic); + if (topic) { + topic._routePres(pkt.pres); + } + + // Secondary API - callback + if (instance.onPresMessage) { + instance.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 + var topic = cacheGet("topic", pkt.info.topic); + if (topic) { + topic._routeInfo(pkt.info); + } + + // Secondary API - callback + if (instance.onInfoMessage) { + instance.onInfoMessage(pkt.info); + } + } else { + log("ERROR: Unknown packet received."); + } + } + } + + // Helper function to construct {get} and {sub.get} packet. + function parseGetParams(params) { + var what = []; + var result; + + if (params) { + result = {}; + ["data", "sub", "desc"].map(function(key) { + if (params.hasOwnProperty(key)) { + what.push(key); + result[key] = params[key]; + } + }); + } + + if (what.length > 0) { + result.what = what.join(" "); + } else { + result = undefined; + } + + return result; + } + + function handleReadyToSend() { + instance.hello(); + } + + function handleDisconnect(err) { + _inPacketCount = 0; + _myUID = null; + _serverInfo = null; + + cacheMap(function(obj, key) { + if (key.lastIndexOf("topic:", 0) === 0) { + obj._resetSub(); + } + }); + + if (instance.onDisconnect) { + instance.onDisconnect(err); + } + } + + function loginSuccessful(ctrl) { + // This is a response to a successful login, + // extract UID and security token, save it in Tinode module + _myUID = ctrl.params.user; + if (ctrl.params && ctrl.params.token && ctrl.params.expires) { + _loginToken = { + token: ctrl.params.token, + expires: new Date(ctrl.params.expires) + }; + } else { + _loginToken = null; + } + + if (instance.onLogin) { + instance.onLogin(ctrl.code, ctrl.text); + } + } + // Returning an initialized instance with public methods; + return { + + /** Instance configuration. Can be calle dmultiple times. + * @memberof 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}. + */ + setup: function(appname_, host_, apiKey_, transport_) { + // Initialize with a random id each time, to avoid confusing with a packet + // from a previous session. + _messageId = Math.floor((Math.random() * 0xFFFF) + 0xFFFF); + + if (appname_) { + _appName = appname_; + } + + if (_connection) { + _connection.disconnect(); + } + + _connection = Connection(transport_, true); + _connection.logger = log; + _connection.onMessage = dispatchMessage; + _connection.onDisconnect = handleDisconnect; + _connection.onOpen = handleReadyToSend; + _connection.setup(host_, false, apiKey_); + }, + + /** + * Connect to the server. + * @memberof Tinode# + * + * @return {Promise} Promise resolved/rejected when the connection call completes: + * resolve() is called without parameters, reject() receives the Error as a single parameter. + */ + connect: function(host_) { + return _connection.connect(host_); + }, + + /** + * Disconnect from the server. + * @memberof Tinode# + */ + disconnect: function() { + if (_connection) { + _connection.disconnect(); + } + }, + + /** + * Check for live connection to server + * @memberof Tinode# + * @returns {boolean} true if there is a live connection, false otherwise. + */ + isConnected: function() { + return _connection && _connection.isConnected(); + }, + + /** + * @typedef AccountCreationParams + * @memberof Tinode + * @type Object + * @property {string} login - Name of the scheme to use to authenticate the session with the newly created account. + * @property {Tinode.DefAcs=} defacs - Default access parameters for user's me topic. + * @property {Object=} public - Public application-defined data exposed on me topic. + * @property {Object=} private - Private application-defined data accessible on me topic. + */ + /** + * @typedef DefAcs + * @memberof Tinode + * @type Object + * @property {string=} auth - Access mode for me for authenticated users. + * @property {string=} anon - Access mode for me anonymous users. + */ + + /** + * Create or update an account. + * @memberof Tinode# + * + * @param {string} uid - User id to update + * @param {string} scheme - Authentication scheme; "basic" is the only currently supported scheme. + * @param {string} secret - Authentication secret, assumed to be already base64 encoded. + * @param {boolean} login - Use new account to authenticate current session + * @param {Tinode.AccountCreationParams} params - User data to pass to the server. + */ + account: function(uid, scheme, secret, login, params) { + var pkt = 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; + } + + return sendWithPromise(pkt, pkt.acc.id); + }, + + /** + * Create a new user. + * @memberof Tinode# + * + * @param {string} scheme - Authentication scheme; "basic" is the only currently supported scheme. + * @param {string} secret - Authentication. + * @param {boolean} login - Use new account to authenticate current session + * @param {Tinode.AccountCreationParams} params - User data to pass to the server. + */ + createAccount: function(scheme, secret, login, params) { + var promise = instance.account(USER_NEW, scheme, secret, login, params); + if (login) { + promise = promise.then(function(ctrl) { + loginSuccessful(ctrl); + }); + } + return promise; + }, + + /** + * Create user with 'basic' authentication scheme and immediately + * use it for authentication. + * + * @memberof Tinode# + */ + createAccountBasic: function(username, password, params) { + return instance.createAccount("basic", btoa(username + ":" + password), true, params); + }, + + /** + * Update user's credentials for 'basic' authentication scheme. + * + * @memberof Tinode# + */ + updateAccountBasic: function(uid, username, password) { + return instance.account(uid, "basic", btoa(username + ":" + password), false, null); + }, + + /** + * Send handshake to the server. + * @memberof Tinode# + * + * @returns {Promise} Promise which will be resolved/rejected when server reply is received. + */ + hello: function() { + var pkt = initPacket("hi"); + + return sendWithPromise(pkt, pkt.hi.id) + .then(function(ctrl) { + // Server response contains server protocol version, build, + // and session ID for long polling. Save them. + if (ctrl.params) { + _serverInfo = ctrl.params; + } + + if (instance.onConnect) { + instance.onConnect(); + } + }).catch(function(err) { + if (instance.onDisconnect) { + instance.onDisconnect(err); + } + }); + }, + + /** + * Authenticate current session. + * @memberof Tinode# + * + * @param {string} scheme - Authentication scheme; "basic" 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) { + var pkt = initPacket("login"); + pkt.login.scheme = scheme; + pkt.login.secret = secret; + + return sendWithPromise(pkt, pkt.login.id) + .then(function(ctrl) { + loginSuccessful(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) { + return instance.login("basic", btoa(uname + ":" + password)) + .then(function(ctrl) { + _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) { + return instance.login("token", token); + }, + + getLoginToken: function() { + if (_loginToken && (_loginToken.expires.getTime() > Date.now())) { + return _loginToken; + } else { + _loginToken = null; + } + return null; + }, + /** + * @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.SubscriptionParams=} params - Optional subscription parameters: + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + subscribe: function(topicName, params) { + var pkt = initPacket("sub", topicName) + if (!topicName) { + topicName = TOPIC_NEW; + } + + if (params) { + pkt.sub.get = parseGetParams(params.get); + + if (params.set) { + if (params.set.sub) { + pkt.sub.set.sub = params.set.sub; + } + + if (topicName === TOPIC_NEW) { + // set.desc params are used for new topics only + if (params.set.desc) { + pkt.sub.set.desc = params.set.desc + } + } else { + // browse makes sense only in context of an existing topic + if (params.set.data) { + pkt.sub.set.data = params.set.data; + } + } + } + } + + return sendWithPromise(pkt, pkt.sub.id); + }, + + /** + * Detach and optionally unsubscribe from the topic + * @memberof Tinode# + * + * @param {string} topic - Topic to detach from. + * @param {boolean} unsub - If true, detach and unsubscribe, otherwise just detach. + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + leave: function(topic, unsub) { + var pkt = initPacket("leave", topic); + pkt.leave.unsub = unsub; + + return sendWithPromise(pkt, pkt.leave.id); + }, + + /** + * 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 true, do not echo the message to the original session. + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + publish: function(topic, data, noEcho) { + var pkt = initPacket("pub", topic); + pkt.pub.noecho = noEcho; + pkt.pub.content = data; + + return sendWithPromise(pkt, pkt.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. + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + getMeta: function(topic, params) { + var pkt = initPacket("get", topic); + + mergeObj(pkt.get, parseGetParams(params)); + + return sendWithPromise(pkt, pkt.get.id); + }, + + /** + * Update topic's metadata: description (desc), subscribtions (sub). + * @memberof Tinode# + * + * @param {string} topic - Topic to update. + * @param {Object} desc - Update to topic description. + * @param {Object} sub - Update to a subscription. + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + setMeta: function(topic, params) { + var pkt = initPacket("set", topic); + var what = []; + + if (params) { + ["desc", "sub"].map(function(key){ + if (params.hasOwnProperty(key)) { + what.push(key); + pkt.set[key] = params[key]; + } + }); + } + + if (what.length == 0) { + throw new Error("Invalid {set} parameters."); + } + + return sendWithPromise(pkt, pkt.set.id); + }, + + /** + * Delete some or all messages in a topic. + * @memberof Tinode# + * + * @param {string} topic - Topic name to delete messages from. + * @param {Object} params - What messages to delete. + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + delMessages: function(topic, params) { + var pkt = initPacket("del", topic); + + pkt.del.what = "msg"; + pkt.del.before = params.before; + pkt.del.list = params.list; + pkt.del.hard = params.hard; + + return sendWithPromise(pkt, pkt.del.id); + }, + + /** + * Delete the topic alltogether. Requires Owner permission. + * @memberof Tinode# + * + * @param {string} topic - Name of the topic to delete + * @returns {Promise} Promise which will be resolved/rejected on receiving server reply. + */ + delTopic: function(topic) { + var pkt = initPacket("del", topic); + pkt.del.what = "topic"; + + return sendWithPromise(pkt, pkt.del.id).then(function(ctrl) { + cacheDel("topic", topic); + return 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) { + var pkt = initPacket("del", topic); + pkt.del.what = "sub"; + pkt.del.user = user; + + return sendWithPromise(pkt, pkt.del.id); + }, + + /** + * Notify server that a message or messages were a read or received. + * @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) { + throw new Error("Invalid message id"); + } + var pkt = initPacket("note", topic); + pkt.note.what = what; + pkt.note.seq = seq; + sendBasic(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) { + var pkt = initPacket("note", topic); + pkt.note.what = "kp"; + sendBasic(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} topic - Name of the topic to get. + * @returns {Tinode.Topic} Requested or newly created topic or undefined if topic name is invalid. + */ + getTopic: function(name) { + var topic = cacheGet("topic", name); + if (!topic && name) { + if (name === TOPIC_ME) { + topic = new TopicMe(); + } else { + topic = new Topic(name); + } + cachePut("topic", name, topic); + } + if (topic) { + attachCacheToTopic(topic); + } + return topic; + }, + + /** + * Instantiate a new unnamed topic. 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) { + var topic = new Topic(undefined, callbacks); + attachCacheToTopic(topic); + return topic; + }, + + /** + * 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) { + var topic = new Topic(peer, callbacks); + attachCacheToTopic(topic); + return topic; + }, + + + /** + * Instantiate 'me' topic or get it from cache. + * @memberof Tinode# + * + * @returns {Tinode.TopicMe} Instance of 'me' topic. + */ + getMeTopic: function() { + return instance.getTopic(TOPIC_ME); + }, + + /** + * Instantiate 'fnd' (find) topic or get it from cache. + * @memberof Tinode# + * + * @returns {Tinode.Topic} Instance of 'fnd' topic. + */ + getFndTopic: function() { + return instance.getTopic(TOPIC_FND); + }, + + /** + * Get the UID of the the current authenticated user. + * @memberof Tinode# + * @returns {string} UID of the current user or undefined if the session is not yet authenticated or if there is no session. + */ + getCurrentUserID: function() { + return _myUID; + }, + + /** + * Get login used for last successful authentication. + * @memberof Tinode# + * @returns {string} login last used successfully or undefined. + */ + getCurrentLogin: function() { + return _login; + }, + + /** + * Return information about the server: protocol version and build timestamp. + * @memberof Tinode# + * @returns {Object} build and version of the server or null if there is no connection or if the first server response has not been received yet. + */ + getServerInfo: function() { + return _serverInfo; + }, + + /** + * Return information about the current version of this Tinode client library. + * @memberof Tinode# + * @returns {string} current version in the MAJOR.MINOR format, e.g. '0.8'. + */ + getVersion: function() { + return VERSION; + }, + + /** + * Toggle console logging. Logging is off by default. + * @memberof Tinode# + * @param {boolean} enabled - Set to true to enable logging to console. + */ + enableLogging: function(enabled, trimLongStrings) { + _loggingEnabled = enabled; + _trimLongStrings = trimLongStrings; + }, + + /** + * Determine topic type from topic's name: grp, p2p, me, fnd. + * @memberof Tinode + * + * @param {string} name - Name of the topic to test. + * @returns {string} One of 'me', 'grp', 'p2p' or undefined. + */ + getTopicType: function(name) { + var tp = (typeof name === "string") ? name.substring(0, 3) : undefined; + if (tp === 'usr') return 'p2p'; + return (tp === "me" || tp === "fnd" || tp === "grp") ? tp : undefined; + }, + + /** + * Request server to aknowledge messages. Required for promises to function. Default "on". + * @memberof Tinode# + * + * @param {boolean} status - Turn aknowledgemens on or off. + * @deprecated + */ + wantAkn: function(status) { + if (status) { + _messageId = Math.floor((Math.random() * 0xFFFFFF) + 0xFFFFFF); + } else { + _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 + }; + } + + return { + // Get the Singleton instance if one exists or create one if it doesn't. + getInstance: function() { + if (!instance) { + instance = init(); + } + return instance; + } + }; + })(); + + var AccessMode = function(acs) { + if (acs != null) { + 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 = typeof acs.mode === 'number' ? acs.mode : AccessMode.decode(acs.mode); + } + }; + + AccessMode._MODE_NONE = 0x00; + AccessMode._MODE_JOIN = 0x01; + AccessMode._MODE_READ = 0x02; + AccessMode._MODE_WRITE = 0x04; + AccessMode._MODE_PRES = 0x08; + AccessMode._MODE_APPROVE = 0x10; + AccessMode._MODE_SHARE = 0x20; + AccessMode._MODE_DELETE = 0x40; + AccessMode._MODE_OWNER = 0x80; + AccessMode._MODE_INVALID = 0x100000; + + /** + * Parse string into an access mode value. + * + * @memberof Tinode.AccessMode + * @static + * + * @param {string} mode - String representation of the access mode to parse. + * @returns {number} - Access mode as a numeric value. + */ + AccessMode.decode = function(str) { + if (!str) { + return null; + } else if (str === 'N' || str === 'n') { + return AccessMode._MODE_NONE; + } + + var bitmask = { + 'J': AccessMode._MODE_JOIN, + 'R': AccessMode._MODE_READ, + 'W': AccessMode._MODE_WRITE, + 'P': AccessMode._MODE_PRES, + 'A': AccessMode._MODE_APPROVE, + 'S': AccessMode._MODE_SHARE, + 'D': AccessMode._MODE_DELETE, + 'O': AccessMode._MODE_OWNER + }; + + var m0 = AccessMode._MODE_NONE; + + for (var i=0; itrue server will not echo message back to originating session. + * @returns {Promise} Promise to be resolved/rejected when the server responds to the request. + */ + publish: function(data, noEcho) { + if (!this._subscribed) { + throw new Error("Cannot publish on inactive topic"); + } + // Send data + return Tinode.getInstance().publish(this.name, data, noEcho); + }, + + /** + * 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) { + // FIXME(gene): It's possible to unsubscribe (unsub==true) from inactive topic. + if (!this._subscribed && !unsub) { + throw new Error("Cannot leave inactive topic"); + } + // Send a 'leave' message, handle async response + var topic = this; + return Tinode.getInstance().leave(this.name, unsub).then(function(ctrl) { + topic._resetSub(); + 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) { + if (!this._subscribed) { + throw new Error("Cannot query inactive topic"); + } + // Send {get} message, return promise. + return Tinode.getInstance().getMeta(this.name, params); + }, + + /** + * Update topic metadata. + * @memberof Tinode.Topic# + * + * @param {Object} parameters to update + * @returns {Promise} Promise to be resolved/rejected when the server responds to request. + */ + setMeta: function(params) { + var topic = this; + if (!this._subscribed) { + throw new Error("Cannot update inactive topic"); + } + + // Send Set message, handle async response. + return Tinode.getInstance().setMeta(this.name, params) + .then(function(ctrl) { + if (ctrl.params && ctrl.params.acs) { + topic.acs = new AccessMode(ctrl.params.acs); + } + return ctrl; + }); + }, + + /** + * @typedef Tinode.Topic.DelMessages + * @type Object + * @property {number} before - Delete messages with id less or equal to before. + * @property {boolean} hard - Hard-delete messages, i.e. delete from storage. Otherwise just hide them. Requires Owner permission. + */ + /** + * Delete messages. Hard-deleting messages requires Owner permission. Wrapper for {@link Tinode#delMessages}. + * @memberof Tinode.Topic# + * + * @param {Tinode.Topic.DelMessages} params - What messages to delete. + */ + delMessages: function(params) { + if (!this._subscribed) { + throw new Error("Cannot delete messages in inactive topic"); + } + // Send {del} message, return promise + return Tinode.getInstance().delMessages(this.name, params); + }, + + /** + * Delete all messages. Hard-deleting messages requires Owner permission. + * + * @memberof Tinode.Topic# + * + * @param {boolean} hardDel - true if messages should be hard-deleted. + */ + delMessagesAll: function(hardDel) { + var topic = this; + // Send {del} message, return promise + return this.delMessages({before: this._seq, hard: hardDel}) + .then(function(ctrl) { + topic._messages.reset(); + if (topic.onData) { + // Calling with no parameters to indicate the messages were deleted. + topic.onData(); + } + return ctrl; + }); + }, + + /** + * Delete all messages. Hard-deleting messages requires Owner permission. + * + * @memberof Tinode.Topic# + * + * @param {array} listToDelete - list of seq IDs to delete + * @param {boolean} hardDel - true if messages should be hard-deleted. + */ + delMessagesList: function(listToDelete, hardDel) { + var topic = this; + // Send {del} message, return promise + return this.delMessages({list: listToDelete, "hard": hardDel}) + .then(function(ctrl) { + // Remove from the buffer messages with matching ids: + // create an empty buffer and copy messages we want to to keep. + var messages = new CBuffer(100); + topic.messages(function(msg) { + if (listToDelete.indexOf(msg.seq) == -1) { + messages.put(msg); + } + }); + topic._messages = messages; + if (topic.onData) { + // Calling with no parameters to indicate that messages were deleted. + topic.onData(); + } + return ctrl; + }); + }, + + /** + * Delete topic. Requires Owner permission. Wrapper for {@link Tinode#delTopic}. + * @memberof Tinode.Topic# + * + * @returns {Promise} Promise to be resolved/rejected when the server responds to the request. + */ + delTopic: function() { + var topic = this; + var tinode = Tinode.getInstance(); + return tinode.delTopic(this.name).then(function(obj) { + topic._resetSub(); + + var me = tinode.getMeTopic(); + if (me) { + me._routePres({ + what: "gone", + topic: "me", + src: topic.name + }); + } + if (topic.onDeleteTopic) { + topic.onDeleteTopic(); + } + }); + }, + + /** + * Delete subscription. Requires Share permission. Wrapper for {@link Tinode#delSubscription}. + * @memberof Tinode.Topic# + * + * @param {String} user - ID of the user to remove subscription for. + */ + delSubscription: function(user) { + if (!this._subscribed) { + throw new Error("Cannot delete subscription in inactive topic"); + } + var topic = this; + // Send {del} message, return promise + return Tinode.getInstance().delSubscription(this.name, user).then(function(obj) { + // Remove the object from the subscription cache; + delete topic._users[user]; + }); + }, + + // Send a read/recv notification + note: function(what, seq) { + var tinode = Tinode.getInstance(); + var user = this._users[tinode.getCurrentUserID()]; + if (user) { + if (!user[what] || user[what] < seq) { + if (!this._subscribed) { + throw new Error("Cannot send notification in inactive topic"); + } + tinode.note(this.name, what, seq); + } + user[what] = seq; + } else { + console.log("note(): user not found " + tinode.getCurrentUserID()); + } + + // Update locally cached contact with the new count + var me = tinode.getMeTopic(); + 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. + */ + noteRead: function(seq) { + this.note("read", seq); + }, + + /** + * Send a key-press notification. Wrapper for {@link Tinode#noteKeyPress}. + * @memberof Tinode.Topic# + */ + noteKeyPress: function() { + if (!this._subscribed) { + throw new Error("Cannot send notification in inactive topic"); + } + Tinode.getInstance().noteKeyPress(this.name); + }, + + // Get user description + userDesc: function(uid) { + // TODO(gene): handle asynchronous requests + + var user = this._cacheGetUser(uid); + if (user) { + return user; // Promise.resolve(user) + } + //return Tinode.getInstance().get(uid); + }, + + /** + * 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) { + var cb = (callback || this.onMetaSub); + if (cb) { + for (var idx in this._users) { + cb.call(context, this._users[idx], idx, this._users); + } + } + }, + /** + * 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 {Object} context - Value of `this` inside the `callback`. + */ + messages: function(callback, context) { + var cb = (callback || this.onData); + if (cb) { + this._messages.forEach(cb, context); + } + }, + + // Get the number of topic subscribers who marked this message as either recv or read + // Current user is excluded from the count. + msgReceiptCount: function(what, seq) { + var count = 0; + var me = Tinode.getInstance().getCurrentUserID(); + if (seq > 0) { + for (var idx in this._users) { + var 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); + }, + + /** + * Get type of the topic: me, p2p, grp, fnd... + * @memberof Tinode.Topic# + * + * @returns {string} One of 'me', 'p2p', 'grp', 'fnd' or undefined. + */ + getType: function() { + return Tinode.getInstance().getTopicType(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; + }, + + // Process data message + _routeData: function(data) { + this._lastUpdate = data.ts; + + if (Object.keys(this._users).indexOf(data.from) < 0) { + this._users[data.from] = {user: data.from}; + } + + this._messages.put(data); + + if (data.seq > this._seq) { + this._seq = data.seq; + } + + if (this.onData) { + this.onData(data); + } + + // Update locally cached contact with the new message count + var me = Tinode.getInstance().getMeTopic(); + if (me) { + me.setMsgReadRecv(this.name, "msg", data.seq); + } + }, + + // Process metadata message + _routeMeta: function(meta) { + this._lastUpdate = meta.ts; + + if (meta.desc) { + this._processMetaDesc(meta.desc); + } + if (meta.sub && meta.sub.length > 0) { + this._processMetaSub(meta.sub); + } + if (this.onMeta) { + this.onMeta(meta); + } + }, + + // Process presence change message + _routePres: function(pres) { + if (this.onPres) { + this.onPres(pres); + } + }, + + // Process {info} message + _routeInfo: function(info) { + if (info.what !== "kp") { + var user = this._users[info.from]; + if (user) { + user[info.what] = info.seq; + } + } + if (this.onInfo) { + this.onInfo(info); + } + }, + + // Called by Tinode when meta.desc packet is received. + _processMetaDesc: function(desc) { + // Copy parameters from desc object to this topic. + mergeObj(this, desc); + + if (typeof this.created === "string") { + this.created = new Date(this.created); + } + if (typeof this.updated === "string") { + this.updated = new Date(this.updated); + } + // Ensure that the acs is properly parsed. + this.acs = new AccessMode(this.acs); + + // Update relevant contact in the me topic, if available: + if (this.name !== 'me') { + var me = Tinode.getInstance().getMeTopic(); + if (me) { + me._processMetaSub([{ + topic: this.name, + updated: this.updated, + acs: this.acs, + public: this.public, + private: this.private + }]); + } + } + + if (this.onMetaDesc) { + this.onMetaDesc(desc); + } + }, + + // Called by Tinode when meta.sub is recived. + _processMetaSub: function(subs) { + for (var idx in subs) { + var sub = subs[idx]; + sub.acs = new AccessMode(sub.acs); + if (sub.user) { // Response to get.sub on 'me' topic does not have .user set + // Save the object to global cache. + sub.updated = new Date(sub.updated); + sub.deleted = sub.deleted ? new Date(sub.deleted) : null; + + var cached = null; + if (!sub.deleted) { + // Cache user in the topic + cached = mergeToCache(this._users, sub.user, sub); + this._cachePutUser(sub.user, cached); + } else { + // Subscription is deleted, remove it from topic (but leave in Users cache) + delete this._users[sub.user]; + cached = sub; + } + } + + if (this.onMetaSub) { + this.onMetaSub(cached); + } + } + + if (this.onSubsUpdated) { + this.onSubsUpdated(Object.keys(this._users)); + } + }, + + // Reset subscribed state + // TODO(gene): should it also clear the message cache? + _resetSub: function() { + this._subscribed = false; + }, + + /** + * Get topic's default access mode. + * @memberof Tinode.Topic# + * + * @returns {Object} - access mode, such as {auth: `RWP`, anon: `N`}. + */ + getDefaultAccess: function() { + return this.defacs; + } + }; + + /** + * @class TopicMe - special case of {@link Tinode.Topic} for receiving and confirming invitations, + * 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._processMetaSub + _processMetaSub: { + value: function(subs) { + var updateCount = 0; + for (var idx in subs) { + var sub = subs[idx]; + var topic = sub.topic; + // Don't show 'fnd' topic in the list of contacts + if (topic === TOPIC_FND) { + continue; + } + sub.updated = new Date(sub.updated); + sub.deleted = sub.deleted ? new Date(sub.deleted) : null; + + var cont = null; + if (!sub.deleted) { + sub.acs = new AccessMode(sub.acs); + if (sub.seen && sub.seen.when) { + sub.seen.when = new Date(sub.seen.when); + } + cont = mergeToCache(this._contacts, topic, sub); + } else { + cont = sub; + delete this._contacts[topic]; + } + + updateCount ++; + + if (this.onMetaSub) { + this.onMetaSub(cont); + } + } + + if (updateCount > 0 && this.onSubsUpdated) { + this.onSubsUpdated(Object.keys(this._contacts)); + } + }, + enumerable: true, + configurable: true, + writable: true + }, + + // Process presence change message + _routePres: { + value: function(pres) { + var cont = this._contacts[pres.src]; + if (cont) { + switch(pres.what) { + case "on": // topic came online + cont.online = true; + break; + case "off": // topic went offline + cont.online = false; + if (cont.seen) { + cont.seen.when = new Date(); + } else { + cont.seen = {when: new Date()}; + } + break; + case "msg": // new message received + cont.seq = pres.seq; + break; + case "upd": // desc updated + // TODO(gene): request updated description + 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 + cont.recv = cont.recv ? Math.max(cont.recv, pres.seq) : pres.seq; + break; + case "read": // user's other session marked some messages as read + cont.read = cont.read ? Math.max(cont.read, pres.seq) : pres.seq; + break; + case "gone": // topic deleted or unsubscribed from + delete this._contacts[pres.src]; + break; + case "del": + // Handle message deletion + break; + } + if (this.onContactUpdate) { + this.onContactUpdate(pres.what, cont); + } + } + if (this.onPres) { + this.onPres(pres); + } + }, + enumerable: true, + configurable: true, + writable: true + }, + + /** + * 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(data, noEcho) { + throw new Error("Publishing to 'me' is not supported"); + }, + enumerable: true, + configurable: true, + writable: true + }, + + /** + * Iterate over cached contacts. If callback is undefined, use {@link this.onMetaSub}. + * @function + * @memberof Tinode.TopicMe# + * @param {TopicMe.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) { + var cb = (callback || this.onMetaSub); + if (cb) { + for (var idx in this._contacts) { + cb.call(context, this._contacts[idx], 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 - Whach count to update, one of "read", "recv", "msg" + * @param {number} seq - New value of the count. + */ + setMsgReadRecv: { + value: function(contactName, what, seq) { + var cont = this._contacts[contactName]; + var oldVal, newVal; + var mode = null; + if (cont) { + if (what === "recv") { + oldVal = cont.recv; + cont.recv = cont.recv ? Math.max(cont.recv, seq) : seq; + newVal = cont.recv; + } else if (what === "read") { + oldVal = cont.read; + cont.read = cont.read ? Math.max(cont.read, seq) : seq; + newVal = cont.read; + } else if (what === "msg") { + oldVal = cont.seq; + cont.seq = cont.seq ? Math.max(cont.seq, seq) : seq; + newVal = cont.seq; + } + + if ((oldVal != newVal) && (!cont.acs || !cont.acs.isMuted()) && this.onContactUpdate) { + this.onContactUpdate(what, cont); + } + } + }, + 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. + * @memberof Tinode.TopicMe# + * + * @param {string} name - Name of the contact to get access mode for, aither a UID (for p2p topics) or a topic name. + * @returns {string} - access mode, such as `RWP`. + */ + getAccessMode: { + value: function(name) { + var cont = this._contacts[name]; + return cont ? cont.acs : null; + }, + enumerable: true, + configurable: true, + writable: true + } + }); + TopicMe.prototype.constructor = TopicMe; + + // Export for the window object or node; Check that is not already defined. + if (typeof(environment.Tinode) === 'undefined') { + environment.Tinode = Tinode.getInstance(); + } + +})(this); // 'this' will be window object for the browsers.