/**
* @module tinode-sdk
*
* @copyright 2015-2022 Tinode LLC.
* @summary Javascript bindings for Tinode.
* @license Apache 2.0
* @version 0.20
*
* See <a href="https://github.com/tinode/webapp">https://github.com/tinode/webapp</a> for real-life usage.
*
* @example
* <head>
* <script src=".../tinode.js"></script>
* </head>
*
* <body>
* ...
* <script>
* // Instantiate tinode.
* const tinode = new Tinode(config, _ => {
* // Called on init completion.
* });
* tinode.enableLogging(true);
* tinode.onDisconnect = err => {
* // Handle disconnect.
* };
* // Connect to the server.
* tinode.connect('https://example.com/').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'.
import AccessMode from './access-mode.js';
import * as Const from './config.js';
import CommError from './comm-error.js';
import Connection from './connection.js';
import DBCache from './db.js';
import Drafty from './drafty.js';
import LargeFileHelper from './large-file.js';
import MetaGetBuilder from './meta-builder.js';
import {
Topic,
TopicMe,
TopicFnd
} from './topic.js';
import {
isUrlRelative,
jsonParseHelper,
mergeObj,
rfc3339DateString,
simplify
} from './utils.js';
// Re-export AccessMode
export {
AccessMode
};
let WebSocketProvider;
if (typeof WebSocket != 'undefined') {
WebSocketProvider = WebSocket;
}
let XHRProvider;
if (typeof XMLHttpRequest != 'undefined') {
XHRProvider = XMLHttpRequest;
}
let IndexedDBProvider;
if (typeof indexedDB != 'undefined') {
IndexedDBProvider = indexedDB;
}
// Re-export Drafty.
export {
Drafty
}
initForNonBrowserApp();
// Utility functions
// Polyfill for non-browser context, e.g. NodeJs.
function initForNonBrowserApp() {
// Tinode requirement in native mode because react native doesn't provide Base64 method
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
if (typeof btoa == 'undefined') {
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,
indexedDB: IndexedDBProvider,
URL: {
createObjectURL: function() {
throw new Error("Unable to use URL.createObjectURL in a non-browser application");
}
}
}
}
Connection.setNetworkProviders(WebSocketProvider, XHRProvider);
LargeFileHelper.setNetworkProvider(XHRProvider);
DBCache.setDatabaseProvider(IndexedDBProvider);
}
// Detect find most useful network transport.
function detectTransport() {
if (typeof window == 'object') {
if (window['WebSocket']) {
return 'ws';
} else if (window['XMLHttpRequest']) {
// The browser or node has no websockets, using long polling.
return 'lp';
}
}
return null;
}
// 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);
}));
}
// 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;
};
// 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; ';
}
let result;
// Remove useless string.
ua = ua.replace(' (KHTML, like Gecko)', '');
// Test for WebKit-based browser.
let m = ua.match(/(AppleWebKit\/[.\d]+)/i);
if (m) {
// List of common strings, from more useful to less useful.
// All unknown strings get the highest (-1) priority.
const priority = ['edg', 'chrome', 'safari', 'mobile', 'version'];
let tmp = ua.substr(m.index + m[0].length).split(' ');
let tokens = [];
let version; // 1.0 in Version/1.0 or undefined;
// Split string like 'Name/0.0.0' into ['Name', '0.0.0', 3] where the last element is the priority.
for (let i = 0; i < tmp.length; i++) {
let m2 = /([\w.]+)[\/]([\.\d]+)/.exec(tmp[i]);
if (m2) {
// Unknown values are highest priority (-1).
tokens.push([m2[1], m2[2], priority.findIndex((e) => {
return m2[1].toLowerCase().startsWith(e);
})]);
if (m2[1] == 'Version') {
version = m2[2];
}
}
}
// Sort by priority: more interesting is earlier than less interesting.
tokens.sort((a, b) => {
return a[2] - b[2];
});
if (tokens.length > 0) {
// Return the least common browser string and version.
if (tokens[0][0].toLowerCase().startsWith('edg')) {
tokens[0][0] = 'Edge';
} else if (tokens[0][0] == 'OPR') {
tokens[0][0] = 'Opera';
} else if (tokens[0][0] == 'Safari' && version) {
tokens[0][1] = version;
}
result = tokens[0][0] + '/' + tokens[0][1];
} else {
// Failed to ID the browser. Return the webkit version.
result = m[1];
}
} else if (/firefox/i.test(ua)) {
m = /Firefox\/([.\d]+)/g.exec(ua);
if (m) {
result = 'Firefox/' + m[1];
} else {
result = 'Firefox/?';
}
} else {
// Neither AppleWebKit nor Firefox. 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) {
const v = m[1].split('.');
const minor = v[1] ? '.' + v[1].substr(0, 2) : '';
result = `${m[0]}/${v[0]}${minor}`;
}
return reactnative + result;
}
/**
* The main class for interacting with Tinode server.
*/
export class Tinode {
_host;
_secure;
_appName;
// API Key.
_apiKey;
// Name and version of the browser.
_browser = '';
_platform;
// Hardware
_hwos = 'undefined';
_humanLanguage = 'xx';
// Logging to console enabled
_loggingEnabled = false;
// When logging, trip long strings (base64-encoded images) for readability
_trimLongStrings = false;
// UID of the currently authenticated user.
_myUID = null;
// Status of connection: authenticated or not.
_authenticated = false;
// Login used in the last successful basic authentication
_login = null;
// Token which can be used for login instead of login/password.
_authToken = null;
// Counter of received packets
_inPacketCount = 0;
// Counter for generating unique message IDs
_messageId = Math.floor((Math.random() * 0xFFFF) + 0xFFFF);
// Information about the server, if connected
_serverInfo = null;
// Push notification token. Called deviceToken for consistency with the Android SDK.
_deviceToken = null;
// Cache of pending promises by message id.
_pendingPromises = {};
// The Timeout object returned by the reject expired promises setInterval.
_expirePromises = null;
// Websocket or long polling connection.
_connection = null;
// Use indexDB for caching topics and messages.
_persist = false;
// IndexedDB wrapper object.
_db = null;
// Tinode's cache of objects
_cache = {};
/**
* Create Tinode object.
*
* @param {Object} config - configuration parameters.
* @param {string} config.appName - Name of the calling application to be reported in the User Agent.
* @param {string} config.host - Host name and optional port number to connect to.
* @param {string} config.apiKey - API key generated by <code>keygen</code>.
* @param {string} config.transport - See {@link Tinode.Connection#transport}.
* @param {boolean} config.secure - Use Secure WebSocket if <code>true</code>.
* @param {string} config.platform - Optional platform identifier, one of <code>"ios"</code>, <code>"web"</code>, <code>"android"</code>.
* @param {boolen} config.persist - Use IndexedDB persistent storage.
* @param {function} onComplete - callback to call when initialization is completed.
*/
constructor(config, onComplete) {
this._host = config.host;
this._secure = config.secure;
// Client-provided application name, format <Name>/<version number>
this._appName = config.appName || "Undefined";
// API Key.
this._apiKey = config.apiKey;
// Name and version of the browser.
this._platform = config.platform || 'web';
// 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';
}
Connection.logger = this.logger;
Drafty.logger = this.logger;
// WebSocket or long polling network connection.
if (config.transport != 'lp' && config.transport != 'ws') {
config.transport = detectTransport();
}
this._connection = new Connection(config, Const.PROTOCOL_VERSION, /* autoreconnect */ true);
this._connection.onMessage = (data) => {
// Call the main message dispatcher.
this.#dispatchMessage(data);
}
// Ready to start sending.
this._connection.onOpen = _ => this.#connectionOpen();
this._connection.onDisconnect = (err, code) => this.#disconnected(err, code);
// Wrapper for the reconnect iterator callback.
this._connection.onAutoreconnectIteration = (timeout, promise) => {
if (this.onAutoreconnectIteration) {
this.onAutoreconnectIteration(timeout, promise);
}
}
this._persist = config.persist;
// Initialize object regardless. It simplifies the code.
this._db = new DBCache(err => {
this.logger('DB', err);
}, this.logger);
if (this._persist) {
// Create the persistent cache.
// Store promises to be resolved when messages load into memory.
const prom = [];
this._db.initDatabase().then(_ => {
// First load topics into memory.
return this._db.mapTopics((data) => {
let topic = this.#cacheGet('topic', data.name);
if (topic) {
return;
}
if (data.name == Const.TOPIC_ME) {
topic = new TopicMe();
} else if (data.name == Const.TOPIC_FND) {
topic = new TopicFnd();
} else {
topic = new Topic(data.name);
}
this._db.deserializeTopic(topic, data);
this.#attachCacheToTopic(topic);
topic._cachePutSelf();
// Topic loaded from DB is not new.
delete topic._new;
// Request to load messages and save the promise.
prom.push(topic._loadMessages(this._db));
});
}).then(_ => {
// Then load users.
return this._db.mapUsers((data) => {
this.#cachePut('user', data.uid, mergeObj({}, data.public));
});
}).then(_ => {
// Now wait for all messages to finish loading.
return Promise.all(prom);
}).then(_ => {
if (onComplete) {
onComplete();
}
this.logger("Persistent cache initialized.");
}).catch(err => {
if (onComplete) {
onComplete(err);
}
this.logger("Failed to initialize persistent cache:", err);
});
} else {
this._db.deleteDatabase().then(_ => {
if (onComplete) {
onComplete();
}
});
}
}
// Private methods.
// Console logger. Babel somehow fails to parse '...rest' parameter.
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(' '));
}
}
// Generator of default promises for sent packets.
#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;
};
// Resolve or reject a pending promise.
// Unresolved promises are stored in _pendingPromises.
#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 CommError(errorText, code));
}
}
}
// Send a packet. If packet id is provided return a promise.
#send(pkt, id) {
let promise;
if (id) {
promise = this.#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) {
this.#execPromise(id, Connection.NETWORK_ERROR, null, err.message);
} else {
throw err;
}
}
return promise;
}
// The main message dispatcher.
#dispatchMessage(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) {
this.#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 = this.#cacheGet('topic', pkt.ctrl.topic);
if (topic) {
topic._resetSub();
if (pkt.ctrl.params && pkt.ctrl.params.unsub) {
topic._gone();
}
}
} else if (pkt.ctrl.code < 300 && pkt.ctrl.params) {
if (pkt.ctrl.params.what == 'data') {
// code=208, all messages received: "params":{"count":11,"what":"data"},
const topic = this.#cacheGet('topic', pkt.ctrl.topic);
if (topic) {
topic._allMessagesReceived(pkt.ctrl.params.count);
}
} else if (pkt.ctrl.params.what == 'sub') {
// code=204, the topic has no (refreshed) subscriptions.
const topic = this.#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 = this.#cacheGet('topic', pkt.meta.topic);
if (topic) {
topic._routeMeta(pkt.meta);
}
if (pkt.meta.id) {
this.#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 = this.#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 = this.#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 = this.#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);
}
}
}
// Connection open, ready to start sending.
#connectionOpen() {
if (!this._expirePromises) {
// Reject promises which have not been resolved for too long.
this._expirePromises = setInterval(_ => {
const err = new CommError("timeout", 504);
const expires = new Date(new Date().getTime() - Const.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);
}
}
}
}, Const.EXPIRE_PROMISES_PERIOD);
}
this.hello();
}
#disconnected(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
this.#cacheMap('topic', (topic, key) => {
topic._resetSub();
});
// Reject all pending promises
for (let key in this._pendingPromises) {
const callbacks = this._pendingPromises[key];
if (callbacks && callbacks.reject) {
callbacks.reject(err);
}
}
this._pendingPromises = {};
if (this.onDisconnect) {
this.onDisconnect(err);
}
}
// Get User Agent string
#getUserAgent() {
return this._appName + ' (' + (this._browser ? this._browser + '; ' : '') + this._hwos + '); ' + Const.LIBRARY;
}
// Generator of packets stubs
#initPacket(type, topic) {
switch (type) {
case 'hi':
return {
'hi': {
'id': this.getNextUniqueId(),
'ver': Const.VERSION,
'ua': this.#getUserAgent(),
'dev': this._deviceToken,
'lang': this._humanLanguage,
'platf': this._platform
}
};
case 'acc':
return {
'acc': {
'id': this.getNextUniqueId(),
'user': null,
'scheme': null,
'secret': null,
'tmpscheme': null,
'tmpsecret': null,
'login': false,
'tags': null,
'desc': {},
'cred': {}
}
};
case 'login':
return {
'login': {
'id': this.getNextUniqueId(),
'scheme': null,
'secret': null
}
};
case 'sub':
return {
'sub': {
'id': this.getNextUniqueId(),
'topic': topic,
'set': {},
'get': {}
}
};
case 'leave':
return {
'leave': {
'id': this.getNextUniqueId(),
'topic': topic,
'unsub': false
}
};
case 'pub':
return {
'pub': {
'id': this.getNextUniqueId(),
'topic': topic,
'noecho': false,
'head': null,
'content': {}
}
};
case 'get':
return {
'get': {
'id': this.getNextUniqueId(),
'topic': topic,
'what': null,
'desc': {},
'sub': {},
'data': {}
}
};
case 'set':
return {
'set': {
'id': this.getNextUniqueId(),
'topic': topic,
'desc': {},
'sub': {},
'tags': [],
'ephemeral': {}
}
};
case 'del':
return {
'del': {
'id': this.getNextUniqueId(),
'topic': topic,
'what': null,
'delseq': null,
'user': null,
'hard': false
}
};
case 'note':
return {
'note': {
// no id by design (except calls).
'topic': topic,
'what': null, // one of "recv", "read", "kp", "call"
'seq': undefined // the server-side message id acknowledged as received or read.
}
};
default:
throw new Error(`Unknown packet type requested: ${type}`);
}
}
// Cache management
#cachePut(type, name, obj) {
this._cache[type + ':' + name] = obj;
}
#cacheGet(type, name) {
return this._cache[type + ':' + name];
}
#cacheDel(type, name) {
delete this._cache[type + ':' + name];
}
// Enumerate all items in cache, call func for each item.
// Enumeration stops if func returns true.
#cacheMap(type, func, context) {
const key = type ? type + ':' : undefined;
for (let idx in this._cache) {
if (!key || idx.indexOf(key) == 0) {
if (func.call(context, this._cache[idx], idx)) {
break;
}
}
}
}
// Make limited cache management available to topic.
// Caching user.public only. Everything else is per-topic.
#attachCacheToTopic(topic) {
topic._tinode = this;
topic._cacheGetUser = (uid) => {
const pub = this.#cacheGet('user', uid);
if (pub) {
return {
user: uid,
public: mergeObj({}, pub)
};
}
return undefined;
};
topic._cachePutUser = (uid, user) => {
this.#cachePut('user', uid, mergeObj({}, user.public));
};
topic._cacheDelUser = (uid) => {
this.#cacheDel('user', uid);
};
topic._cachePutSelf = _ => {
this.#cachePut('topic', topic.name, topic);
};
topic._cacheDelSelf = _ => {
this.#cacheDel('topic', topic.name);
};
}
// On successful login save server-provided data.
#loginSuccessful(ctrl) {
if (!ctrl.params || !ctrl.params.user) {
return ctrl;
}
// 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: ctrl.params.expires
};
} else {
this._authToken = null;
}
if (this.onLogin) {
this.onLogin(ctrl.code, ctrl.text);
}
return ctrl;
}
// Static methods.
/**
* Helper method to package account credential.
*
* @param {string | Credential} 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.<Credential>} array with a single credential or <code>null</code> if no valid credentials were given.
*/
static credential(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, sys.
* @param {string} name - Name of the topic to test.
* @returns {string} One of <code>"me"</code>, <code>"fnd"</code>, <code>"sys"</code>, <code>"grp"</code>,
* <code>"p2p"</code> or <code>undefined</code>.
*/
static topicType(name) {
return Topic.topicType(name);
}
/**
* Check if the given topic name is a name of a 'me' topic.
* @param {string} name - Name of the topic to test.
* @returns {boolean} <code>true</code> if the name is a name of a 'me' topic, <code>false</code> otherwise.
*/
static isMeTopicName(name) {
return Topic.isMeTopicName(name);
}
/**
* Check if the given topic name is a name of a group topic.
* @param {string} name - Name of the topic to test.
* @returns {boolean} <code>true</code> if the name is a name of a group topic, <code>false</code> otherwise.
*/
static isGroupTopicName(name) {
return Topic.isGroupTopicName(name);
}
/**
* Check if the given topic name is a name of a p2p topic.
* @param {string} name - Name of the topic to test.
* @returns {boolean} <code>true</code> if the name is a name of a p2p topic, <code>false</code> otherwise.
*/
static isP2PTopicName(name) {
return Topic.isP2PTopicName(name);
}
/**
* Check if the given topic name is a name of a communication topic, i.e. P2P or group.
* @param {string} name - Name of the topic to test.
* @returns {boolean} <code>true</code> if the name is a name of a p2p or group topic, <code>false</code> otherwise.
*/
static isCommTopicName(name) {
return Topic.isCommTopicName(name);
}
/**
* Check if the topic name is a name of a new topic.
* @param {string} name - topic name to check.
* @returns {boolean} <code>true</code> if the name is a name of a new topic, <code>false</code> otherwise.
*/
static isNewGroupTopicName(name) {
return Topic.isNewGroupTopicName(name);
}
/**
* Check if the topic name is a name of a channel.
* @param {string} name - topic name to check.
* @returns {boolean} <code>true</code> if the name is a name of a channel, <code>false</code> otherwise.
*/
static isChannelTopicName(name) {
return Topic.isChannelTopicName(name);
}
/**
* Get information about the current version of this Tinode client library.
* @returns {string} semantic version of the library, e.g. <code>"0.15.5-rc1"</code>.
*/
static getVersion() {
return Const.VERSION;
}
/**
* To use Tinode in a non browser context, supply WebSocket and XMLHttpRequest providers.
* @static
*
* @param wsProvider <code>WebSocket</code> provider, e.g. for nodeJS , <code>require('ws')</code>.
* @param xhrProvider <code>XMLHttpRequest</code> provider, e.g. for node <code>require('xhr')</code>.
*/
static setNetworkProviders(wsProvider, xhrProvider) {
WebSocketProvider = wsProvider;
XHRProvider = xhrProvider;
Connection.setNetworkProviders(WebSocketProvider, XHRProvider);
LargeFileHelper.setNetworkProvider(XHRProvider);
}
/**
* To use Tinode in a non browser context, supply <code>indexedDB</code> provider.
* @static
*
* @param idbProvider <code>indexedDB</code> provider, e.g. for nodeJS , <code>require('fake-indexeddb')</code>.
*/
static setDatabaseProvider(idbProvider) {
IndexedDBProvider = idbProvider;
DBCache.setDatabaseProvider(IndexedDBProvider);
}
/**
* Return information about the current name and version of this Tinode library.
* @static
*
* @returns {string} the name of the library and it's version.
*/
static getLibrary() {
return Const.LIBRARY;
}
/**
* Check if the given string represents <code>NULL</code> value as defined by Tinode (<code>'\u2421'</code>).
* @param {string} str - string to check for <code>NULL</code> value.
* @returns {boolean} <code>true</code> if string represents <code>NULL</code> value, <code>false</code> otherwise.
*/
static isNullValue(str) {
return str === Const.DEL_CHAR;
}
// Instance methods.
// Generates unique message IDs
getNextUniqueId() {
return (this._messageId != 0) ? '' + this._messageId++ : undefined;
};
/**
* Connect to the server.
*
* @param {string} host_ - name of the host to connect to.
* @return {Promise} Promise resolved/rejected when the connection call completes:
* <code>resolve()</code> is called without parameters, <code>reject()</code> receives the
* <code>Error</code> as a single parameter.
*/
connect(host_) {
return this._connection.connect(host_);
}
/**
* Attempt to reconnect to the server immediately.
*
* @param {string} force - if <code>true</code>, reconnect even if there is a connection already.
*/
reconnect(force) {
this._connection.reconnect(force);
}
/**
* Disconnect from the server.
*/
disconnect() {
this._connection.disconnect();
}
/**
* Clear persistent cache: remove IndexedDB.
*
* @return {Promise} Promise resolved/rejected when the operation is completed.
*/
clearStorage() {
if (this._db.isReady()) {
return this._db.deleteDatabase();
}
return Promise.resolve();
}
/**
* Initialize persistent cache: create IndexedDB cache.
*
* @return {Promise} Promise resolved/rejected when the operation is completed.
*/
initStorage() {
if (!this._db.isReady()) {
return this._db.initDatabase();
}
return Promise.resolve();
}
/**
* Send a network probe message to make sure the connection is alive.
*/
networkProbe() {
this._connection.probe();
}
/**
* Check for live connection to server.
*
* @returns {boolean} <code>true</code> if there is a live connection, <code>false</code> otherwise.
*/
isConnected() {
return this._connection.isConnected();
}
/**
* Check if connection is authenticated (last login was successful).
*
* @returns {boolean} <code>true</code> if authenticated, <code>false</code> otherwise.
*/
isAuthenticated() {
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 <code>HTTP GET</code> request.
*
* @param {string} URL - URL to wrap.
* @returns {string} URL with appended API key and token, if valid token is present.
*/
authorizeURL(url) {
if (typeof url != 'string') {
return url;
}
if (isUrlRelative(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 && 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
* @type {Object}
* @property {DefAcs=} defacs - Default access parameters for user's <code>me</code> topic.
* @property {Object=} public - Public application-defined data exposed on <code>me</code> topic.
* @property {Object=} private - Private application-defined data accessible on <code>me</code> topic.
* @property {Object=} trusted - Trusted user data which can be set by a root user only.
* @property {Array.<string>} tags - array of string tags for user discovery.
* @property {string} scheme - Temporary authentication scheme for password reset.
* @property {string} secret - Temporary authentication secret for password reset.
* @property {Array.<string>=} attachments - Array of references to out of band attachments used in account description.
*/
/**
* @typedef DefAcs
* @type {Object}
* @property {string=} auth - Access mode for <code>me</code> for authenticated users.
* @property {string=} anon - Access mode for <code>me</code> for anonymous users.
*/
/**
* Create or update an account.
*
* @param {string} uid - User id to update
* @param {string} scheme - Authentication scheme; <code>"basic"</code> and <code>"anonymous"</code> 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 {AccountParams=} params - User data to pass to the server.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
account(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.desc.trusted = params.trusted;
pkt.acc.tags = params.tags;
pkt.acc.cred = params.cred;
pkt.acc.tmpscheme = params.scheme;
pkt.acc.tmpsecret = params.secret;
if (Array.isArray(params.attachments) && params.attachments.length > 0) {
pkt.extra = {
attachments: params.attachments.filter(ref => isUrlRelative(ref))
};
}
}
return this.#send(pkt, pkt.acc.id);
}
/**
* Create a new user. Wrapper for {@link Tinode#account}.
*
* @param {string} scheme - Authentication scheme; <code>"basic"</code> is the only currently supported scheme.
* @param {string} secret - Authentication.
* @param {boolean=} login - Use new account to authenticate current session
* @param {AccountParams=} params - User data to pass to the server.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
createAccount(scheme, secret, login, params) {
let promise = this.account(Const.USER_NEW, scheme, secret, login, params);
if (login) {
promise = promise.then(ctrl => this.#loginSuccessful(ctrl));
}
return promise;
}
/**
* Create user with <code>'basic'</code> authentication scheme and immediately
* use it for authentication. Wrapper for {@link Tinode#account}.
*
* @param {string} username - Login to use for the new account.
* @param {string} password - User's password.
* @param {AccountParams=} params - User data to pass to the server.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
createAccountBasic(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 <code>'basic'</code> authentication scheme. Wrapper for {@link Tinode#account}.
*
* @param {string} uid - User ID to update.
* @param {string} username - Login to use for the new account.
* @param {string} password - User's password.
* @param {AccountParams=} params - data to pass to the server.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
updateAccountBasic(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.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
hello() {
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.
*
* @param {string} dt - token obtained from the provider or <code>false</code>,
* <code>null</code> or <code>undefined</code> to clear the token.
*
* @returns <code>true</code> if attempt was made to send the update to the server.
*/
setDeviceToken(dt) {
let sent = false;
// Convert any falsish value to null.
dt = dt || null;
if (dt != this._deviceToken) {
this._deviceToken = dt;
if (this.isConnected() && this.isAuthenticated()) {
this.#send({
'hi': {
'dev': dt || Tinode.DEL_CHAR
}
});
sent = true;
}
}
return sent;
}
/**
* @typedef Credential
* @type {Object}
* @property {string} meth - validation method.
* @property {string} val - value to validate (e.g. email or phone number).
* @property {string} resp - validation response.
* @property {Object} params - validation parameters.
*/
/**
* Authenticate current session.
*
* @param {string} scheme - Authentication scheme; <code>"basic"</code> is the only currently supported scheme.
* @param {string} secret - Authentication secret, assumed to be already base64 encoded.
* @param {Credential=} cred - credential confirmation, if required.
*
* @returns {Promise} Promise which will be resolved/rejected when server reply is received.
*/
login(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));
}
/**
* Wrapper for {@link Tinode#login} with basic authentication
*
* @param {string} uname - User name.
* @param {string} password - Password.
* @param {Credential=} cred - credential confirmation, if required.
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
loginBasic(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
*
* @param {string} token - Token received in response to earlier login.
* @param {Credential=} cred - credential confirmation, if required.
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
loginToken(token, cred) {
return this.login('token', token, cred);
}
/**
* Send a request for resetting an authentication secret.
*
* @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(scheme, method, value) {
return this.login('reset', b64EncodeUnicode(scheme + ':' + method + ':' + value));
}
/**
* @typedef AuthToken
* @type {Object}
* @property {string} token - Token value.
* @property {Date} expires - Token expiration time.
*/
/**
* Get stored authentication token.
*
* @returns {AuthToken} authentication token.
*/
getAuthToken() {
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.
*
* @param {AuthToken} token - authentication token.
*/
setAuthToken(token) {
this._authToken = token;
}
/**
* @typedef SetParams
* @type {Object}
* @property {SetDesc=} desc - Topic initialization parameters when creating a new topic or a new subscription.
* @property {SetSub=} sub - Subscription initialization parameters.
* @property {Array.<string>=} attachments - URLs of out of band attachments used in parameters.
*/
/**
* @typedef SetDesc
* @type {Object}
* @property {DefAcs=} defacs - Default access mode.
* @property {Object=} public - Free-form topic description, publically accessible.
* @property {Object=} private - Free-form topic description accessible only to the owner.
* @property {Object=} trusted - Trusted user data which can be set by a root user only.
*/
/**
* @typedef SetSub
* @type {Object}
* @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.
*/
/**
* Send a topic subscription request.
*
* @param {string} topic - Name of the topic to subscribe to.
* @param {GetQuery=} getParams - Optional subscription metadata query
* @param {SetParams=} setParams - Optional initialization parameters
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
subscribe(topicName, getParams, setParams) {
const pkt = this.#initPacket('sub', topicName)
if (!topicName) {
topicName = Const.TOPIC_NEW;
}
pkt.sub.get = getParams;
if (setParams) {
if (setParams.sub) {
pkt.sub.set.sub = setParams.sub;
}
if (setParams.desc) {
const desc = setParams.desc;
if (Tinode.isNewGroupTopicName(topicName)) {
// Full set.desc params are used for new topics only
pkt.sub.set.desc = desc;
} else if (Tinode.isP2PTopicName(topicName) && desc.defacs) {
// Use optional default permissions only.
pkt.sub.set.desc = {
defacs: desc.defacs
};
}
}
// See if external objects were used in topic description.
if (Array.isArray(setParams.attachments) && setParams.attachments.length > 0) {
pkt.extra = {
attachments: setParams.attachments.filter(ref => isUrlRelative(ref))
};
}
if (setParams.tags) {
pkt.sub.set.tags = setParams.tags;
}
}
return this.#send(pkt, pkt.sub.id);
}
/**
* Detach and optionally unsubscribe from the topic
*
* @param {string} topic - Topic to detach from.
* @param {boolean} unsub - If <code>true</code>, detach and unsubscribe, otherwise just detach.
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
leave(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.
*
* @param {string} topic - Name of the topic to publish to.
* @param {Object} content - Payload to publish.
* @param {boolean=} noEcho - If <code>true</code>, 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(topic, content, noEcho) {
const pkt = this.#initPacket('pub', topic);
let dft = typeof content == 'string' ? Drafty.parse(content) : content;
if (dft && !Drafty.isPlainText(dft)) {
pkt.pub.head = {
mime: Drafty.getContentType()
};
content = dft;
}
pkt.pub.noecho = noEcho;
pkt.pub.content = content;
return pkt.pub;
}
/**
* Publish {data} message to topic.
*
* @param {string} topicName - Name of the topic to publish to.
* @param {Object} content - Payload to publish.
* @param {boolean=} noEcho - If <code>true</code>, 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(topicName, content, noEcho) {
return this.publishMessage(
this.createMessage(topicName, content, noEcho)
);
}
/**
* Publish message to topic. The message should be created by {@link Tinode#createMessage}.
*
* @param {Object} pub - Message to publish.
* @param {Array.<string>=} attachments - array of URLs with attachments.
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
publishMessage(pub, attachments) {
// 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;
const msg = {
pub: pub,
};
if (attachments) {
msg.extra = {
attachments: attachments.filter(ref => isUrlRelative(ref))
};
}
return this.#send(msg, pub.id);
}
/**
* Out of band notification: notify topic that an external (push) notification was recived by the client.
*
* @param {object} data - notification payload.
* @param {string} data.what - notification type, 'msg', 'read', 'sub'.
* @param {string} data.topic - name of the updated topic.
* @param {number=} data.seq - seq ID of the affected message.
* @param {string=} data.xfrom - UID of the sender.
* @param {object=} data.given - new subscription 'given', e.g. 'ASWP...'.
* @param {object=} data.want - new subscription 'want', e.g. 'RWJ...'.
*/
oobNotification(data) {
this.logger('oob: ' + (this._trimLongStrings ? JSON.stringify(data, jsonLoggerHelper) : data));
switch (data.what) {
case 'msg':
if (!data.seq || data.seq < 1 || !data.topic) {
// Server sent invalid data.
break;
}
if (!this.isConnected()) {
// Let's ignore the message if there is no connection: no connection means there are no open
// tabs with Tinode.
break;
}
const topic = this.#cacheGet('topic', data.topic);
if (!topic) {
// TODO: check if there is a case when a message can arrive from an unknown topic.
break;
}
if (topic.isSubscribed()) {
// No need to fetch: topic is already subscribed and got data through normal channel.
break;
}
if (topic.maxMsgSeq() < data.seq) {
if (topic.isChannelType()) {
topic._updateReceived(data.seq, 'fake-uid');
}
// New message.
if (data.xfrom && !this.#cacheGet('user', data.xfrom)) {
// Message from unknown sender, fetch description from the server.
// Sending asynchronously without a subscription.
this.getMeta(data.xfrom, new MetaGetBuilder().withDesc().build()).catch(err => {
this.logger("Failed to get the name of a new sender", err);
});
}
topic.subscribe(null).then(_ => {
return topic.getMeta(new MetaGetBuilder(topic).withLaterData(24).withLaterDel(24).build());
}).then(_ => {
// Allow data fetch to complete and get processed successfully.
topic.leaveDelayed(false, 1000);
}).catch(err => {
this.logger("On push data fetch failed", err);
}).finally(_ => {
this.getMeTopic()._refreshContact('msg', topic);
});
}
break;
case 'read':
this.getMeTopic()._routePres({
what: 'read',
seq: data.seq
});
break;
case 'sub':
if (!this.isMe(data.xfrom)) {
// TODO: handle updates from other users.
break;
}
const mode = {
given: data.modeGiven,
want: data.modeWant
};
const acs = new AccessMode(mode);
const pres = (!acs.mode || acs.mode == AccessMode._NONE) ?
// Subscription deleted.
{
what: 'gone',
src: data.topic
} :
// New subscription or subscription updated.
{
what: 'acs',
src: data.topic,
dacs: mode
};
this.getMeTopic()._routePres(pres);
break;
default:
this.logger("Unknown push type ignored", data.what);
}
}
/**
* @typedef GetQuery
* @type {Object}
* @property {GetOptsType=} desc - If provided (even if empty), fetch topic description.
* @property {GetOptsType=} sub - If provided (even if empty), fetch topic subscriptions.
* @property {GetDataType=} data - If provided (even if empty), get messages.
*/
/**
* @typedef GetOptsType
* @type {Object}
* @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}
* @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
*
* @param {string} topic - Name of the topic to query.
* @param {GetQuery} params - Parameters of the query. Use {@link Tinode.MetaGetBuilder} to generate.
*
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
getMeta(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.
*
* @param {string} topic - Topic to update.
* @param {SetParams} params - topic metadata to update.
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
setMeta(topic, params) {
const pkt = this.#initPacket('set', topic);
const what = [];
if (params) {
['desc', 'sub', 'tags', 'cred', 'ephemeral'].forEach(function(key) {
if (params.hasOwnProperty(key)) {
what.push(key);
pkt.set[key] = params[key];
}
});
if (Array.isArray(params.attachments) && params.attachments.length > 0) {
pkt.extra = {
attachments: params.attachments.filter(ref => isUrlRelative(ref))
};
}
}
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}
* @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.
*
* @param {string} topic - Topic name to delete messages from.
* @param {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(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.
*
* @param {string} topicName - 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(topicName, hard) {
const pkt = this.#initPacket('del', topicName);
pkt.del.what = 'topic';
pkt.del.hard = hard;
return this.#send(pkt, pkt.del.id);
}
/**
* Delete subscription. Requires Share permission.
*
* @param {string} topicName - 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(topicName, user) {
const pkt = this.#initPacket('del', topicName);
pkt.del.what = 'sub';
pkt.del.user = user;
return this.#send(pkt, pkt.del.id);
}
/**
* Delete credential. Always sent on <code>'me'</code> topic.
*
* @param {string} method - validation method such as <code>'email'</code> or <code>'tel'</code>.
* @param {string} value - validation value, i.e. <code>'alice@example.com'</code>.
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
delCredential(method, value) {
const pkt = this.#initPacket('del', Const.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.
*
* @param {boolean} hard - hard-delete user.
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
delCurrentUser(hard) {
const pkt = this.#initPacket('del', null);
pkt.del.what = 'user';
pkt.del.hard = hard;
return this.#send(pkt, pkt.del.id).then(_ => {
this._myUID = null;
});
}
/**
* Notify server that a message or messages were read or received. Does NOT return promise.
*
* @param {string} topicName - Name of the topic where the mesage is being aknowledged.
* @param {string} what - Action being aknowledged, either <code>"read"</code> or <code>"recv"</code>.
* @param {number} seq - Maximum id of the message being acknowledged.
*/
note(topicName, what, seq) {
if (seq <= 0 || seq >= Const.LOCAL_SEQID) {
throw new Error(`Invalid message id ${seq}`);
}
const pkt = this.#initPacket('note', topicName);
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...".
*
* @param {string} topicName - Name of the topic to broadcast to.
* @param {string=} type - notification to send, default is 'kp'.
*/
noteKeyPress(topicName, type) {
const pkt = this.#initPacket('note', topicName);
pkt.note.what = type || 'kp';
this.#send(pkt);
}
/**
* Send a video call notification to topic subscribers (including dialing,
* hangup, etc.).
*
* @param {string} topicName - Name of the topic to broadcast to.
* @param {int} seq - ID of the call message the event pertains to.
* @param {string} evt - Call event.
* @param {string} payload - Payload associated with this event (e.g. SDP string).
*
* @returns {Promise} Promise (for some call events) which will
* be resolved/rejected on receiving server reply
*/
videoCall(topicName, seq, evt, payload) {
const pkt = this.#initPacket('note', topicName);
pkt.note.seq = seq;
pkt.note.what = 'call';
pkt.note.event = evt;
pkt.note.payload = payload;
this.#send(pkt, pkt.note.id);
}
/**
* Get a named topic, either pull it from cache or create a new instance.
* There is a single instance of topic for each name.
*
* @param {string} topicName - Name of the topic to get.
*
* @returns {Topic} Requested or newly created topic or <code>undefined</code> if topic name is invalid.
*/
getTopic(topicName) {
let topic = this.#cacheGet('topic', topicName);
if (!topic && topicName) {
if (topicName == Const.TOPIC_ME) {
topic = new TopicMe();
} else if (topicName == Const.TOPIC_FND) {
topic = new TopicFnd();
} else {
topic = new Topic(topicName);
}
// Cache management.
this.#attachCacheToTopic(topic);
topic._cachePutSelf();
// Don't save to DB here: a record will be added when the topic is subscribed.
}
return topic;
}
/**
* Get a named topic from cache.
*
* @param {string} topicName - Name of the topic to get.
*
* @returns {Topic} Requested topic or <code>undefined</code> if topic is not found in cache.
*/
cacheGetTopic(topicName) {
return this.#cacheGet('topic', topicName);
}
/**
* Remove named topic from cache.
*
* @param {string} topicName - Name of the topic to remove from cache.
*/
cacheRemTopic(topicName) {
this.#cacheDel('topic', topicName);
}
/**
* Iterate over cached topics.
*
* @param {Function} func - callback to call for each topic.
* @param {Object} context - 'this' inside the 'func'.
*/
mapTopics(func, context) {
this.#cacheMap('topic', func, context);
}
/**
* Check if named topic is already present in cache.
*
* @param {string} topicName - Name of the topic to check.
* @returns {boolean} true if topic is found in cache, false otherwise.
*/
isTopicCached(topicName) {
return !!this.#cacheGet('topic', topicName);
}
/**
* Generate unique name like <code>'new123456'</code> suitable for creating a new group topic.
*
* @param {boolean} isChan - if the topic is channel-enabled.
* @returns {string} name which can be used for creating a new group topic.
*/
newGroupTopicName(isChan) {
return (isChan ? Const.TOPIC_NEW_CHAN : Const.TOPIC_NEW) + this.getNextUniqueId();
}
/**
* Instantiate <code>'me'</code> topic or get it from cache.
*
* @returns {TopicMe} Instance of <code>'me'</code> topic.
*/
getMeTopic() {
return this.getTopic(Const.TOPIC_ME);
}
/**
* Instantiate <code>'fnd'</code> (find) topic or get it from cache.
*
* @returns {Topic} Instance of <code>'fnd'</code> topic.
*/
getFndTopic() {
return this.getTopic(Const.TOPIC_FND);
}
/**
* Create a new {@link LargeFileHelper} instance
*
* @returns {LargeFileHelper} instance of a {@link Tinode.LargeFileHelper}.
*/
getLargeFileHelper() {
return new LargeFileHelper(this, Const.PROTOCOL_VERSION);
}
/**
* Get the UID of the the current authenticated user.
*
* @returns {string} UID of the current user or <code>undefined</code> if the session is not yet
* authenticated or if there is no session.
*/
getCurrentUserID() {
return this._myUID;
}
/**
* Check if the given user ID is equal to the current user's UID.
*
* @param {string} uid - UID to check.
*
* @returns {boolean} true if the given UID belongs to the current logged in user.
*/
isMe(uid) {
return this._myUID === uid;
}
/**
* Get login used for last successful authentication.
*
* @returns {string} login last used successfully or <code>undefined</code>.
*/
getCurrentLogin() {
return this._login;
}
/**
* Return information about the server: protocol version and build timestamp.
*
* @returns {Object} build and version of the server or <code>null</code> if there is no connection or
* if the first server response has not been received yet.
*/
getServerInfo() {
return this._serverInfo;
}
/**
* Report a topic for abuse. Wrapper for {@link Tinode#publish}.
*
* @param {string} action - the only supported action is 'report'.
* @param {string} target - name of the topic being reported.
*
* @returns {Promise} Promise to be resolved/rejected when the server responds to request.
*/
report(action, target) {
return this.publish(Const.TOPIC_SYS, Drafty.attachJSON(null, {
'action': action,
'target': target
}));
}
/**
* Return server-provided configuration value.
*
* @param {string} name of the value to return.
* @param {Object} defaultValue to return in case the parameter is not set or not found.
*
* @returns {Object} named value.
*/
getServerParam(name, defaultValue) {
return this._serverInfo && this._serverInfo[name] || defaultValue;
}
/**
* Toggle console logging. Logging is off by default.
*
* @param {boolean} enabled - Set to <code>true</code> to enable logging to console.
* @param {boolean} trimLongStrings - Set to <code>true</code> to trim long strings.
*/
enableLogging(enabled, trimLongStrings) {
this._loggingEnabled = enabled;
this._trimLongStrings = enabled && trimLongStrings;
}
/**
* Set UI language to report to the server. Must be called before <code>'hi'</code> is sent, otherwise it will not be used.
*
* @param {string} hl - human (UI) language, like <code>"en_US"</code> or <code>"zh-Hans"</code>.
*/
setHumanLanguage(hl) {
if (hl) {
this._humanLanguage = hl;
}
}
/**
* Check if given topic is online.
*
* @param {string} name of the topic to test.
* @returns {boolean} true if topic is online, false otherwise.
*/
isTopicOnline(name) {
const topic = this.#cacheGet('topic', name);
return topic && topic.online;
}
/**
* Get access mode for the given contact.
*
* @param {string} name of the topic to query.
* @returns {AccessMode} access mode if topic is found, null otherwise.
*/
getTopicAccessMode(name) {
const topic = this.#cacheGet('topic', name);
return topic ? topic.acs : null;
}
/**
* Include message ID into all subsequest messages to server instructin it to send aknowledgemens.
* Required for promises to function. Default is <code>"on"</code>.
*
* @param {boolean} status - Turn aknowledgemens on or off.
* @deprecated
*/
wantAkn(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.
*
* @type {onWebsocketOpen}
*/
onWebsocketOpen = undefined;
/**
* @typedef ServerParams
*
* @type {Object}
* @property {string} ver - Server version
* @property {string} build - Server build
* @property {string=} sid - Session ID, long polling connections only.
*/
/**
* @callback onConnect
* @param {number} code - Result code
* @param {string} text - Text epxplaining the completion, i.e "OK" or an error message.
* @param {ServerParams} params - Parameters returned by the server.
*/
/**
* Callback to report when connection with Tinode server is established.
* @type {onConnect}
*/
onConnect = undefined;
/**
* Callback to report when connection is lost. The callback has no parameters.
* @type {onDisconnect}
*/
onDisconnect = undefined;
/**
* @callback 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.
* @type {onLogin}
*/
onLogin = undefined;
/**
* Callback to receive <code>{ctrl}</code> (control) messages.
* @type {onCtrlMessage}
*/
onCtrlMessage = undefined;
/**
* Callback to recieve <code>{data}</code> (content) messages.
* @type {onDataMessage}
*/
onDataMessage = undefined;
/**
* Callback to receive <code>{pres}</code> (presence) messages.
* @type {onPresMessage}
*/
onPresMessage = undefined;
/**
* Callback to receive all messages as objects.
* @type {onMessage}
*/
onMessage = undefined;
/**
* Callback to receive all messages as unparsed text.
* @type {onRawMessage}
*/
onRawMessage = undefined;
/**
* Callback to receive server responses to network probes. See {@link Tinode#networkProbe}
* @type {onNetworkProbe}
*/
onNetworkProbe = undefined;
/**
* Callback to be notified when exponential backoff is iterating.
* @type {onAutoreconnectIteration}
*/
onAutoreconnectIteration = undefined;
};
// Exported constants
Tinode.MESSAGE_STATUS_NONE = Const.MESSAGE_STATUS_NONE;
Tinode.MESSAGE_STATUS_QUEUED = Const.MESSAGE_STATUS_QUEUED;
Tinode.MESSAGE_STATUS_SENDING = Const.MESSAGE_STATUS_SENDING;
Tinode.MESSAGE_STATUS_FAILED = Const.MESSAGE_STATUS_FAILED;
Tinode.MESSAGE_STATUS_FATAL = Const.MESSAGE_STATUS_FATAL;
Tinode.MESSAGE_STATUS_SENT = Const.MESSAGE_STATUS_SENT;
Tinode.MESSAGE_STATUS_RECEIVED = Const.MESSAGE_STATUS_RECEIVED;
Tinode.MESSAGE_STATUS_READ = Const.MESSAGE_STATUS_READ;
Tinode.MESSAGE_STATUS_TO_ME = Const.MESSAGE_STATUS_TO_ME;
// Unicode [del] symbol.
Tinode.DEL_CHAR = Const.DEL_CHAR;
// 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';
Tinode.REQ_CRED_VALIDATORS = 'reqCred';