/**
* @file Definition of 'me' topic.
*
* @copyright 2015-2026 Tinode LLC.
*/
'use strict';
import AccessMode from './access-mode.js';
import * as Const from './config.js';
import Topic from './topic.js';
import {
mergeObj
} from './utils.js';
/**
* Special case of {@link Tinode.Topic} for managing data of the current user,
* including contact list.
* @class TopicMe
* @extends Tinode.Topic
* @memberof Tinode
*
* @param {TopicMe.Callbacks} callbacks - Callbacks to receive various events.
*/
export default class TopicMe extends Topic {
onContactUpdate;
constructor(callbacks) {
super(Const.TOPIC_ME, callbacks);
// me-specific callbacks
if (callbacks) {
this.onContactUpdate = callbacks.onContactUpdate;
}
}
// Override the original Topic._processMetaDesc.
_processMetaDesc(desc) {
// Check if online contacts need to be turned off because P permission was removed.
const turnOff = (desc.acs && !desc.acs.isPresencer()) && (this.acs && this.acs.isPresencer());
// Copy parameters from desc object to this topic.
mergeObj(this, desc);
this._tinode._db.updTopic(this);
// Update current user's record in the global cache.
this._updateCachedUser(this._tinode._myUID, desc);
// 'P' permission was removed. All topics are offline now.
if (turnOff) {
this._tinode.mapTopics((cont) => {
if (cont.online) {
cont.online = false;
cont.seen = Object.assign(cont.seen || {}, {
when: new Date()
});
this._refreshContact('off', cont);
}
});
}
if (this.onMetaDesc) {
this.onMetaDesc(this);
}
}
// Override the original Topic._processMetaSubs
_processMetaSubs(subs) {
let updateCount = 0;
subs.forEach((sub) => {
const topicName = sub.topic;
// Don't show 'me' and 'fnd' topics in the list of contacts.
if (topicName == Const.TOPIC_FND || topicName == Const.TOPIC_ME) {
return;
}
sub.online = !!sub.online;
let cont = null;
if (sub.deleted) {
cont = sub;
this._tinode.cacheRemTopic(topicName);
this._tinode._db.remTopic(topicName);
} else {
// Ensure the values are defined and are integers.
if (typeof sub.seq != 'undefined') {
sub.seq = sub.seq | 0;
sub.recv = sub.recv | 0;
sub.read = sub.read | 0;
sub.unread = sub.seq - sub.read;
}
const topic = this._tinode.getTopic(topicName);
if (topic._new) {
delete topic._new;
}
cont = mergeObj(topic, sub);
this._tinode._db.updTopic(cont);
if (Topic.isP2PTopicName(topicName)) {
this._cachePutUser(topicName, cont);
this._tinode._db.updUser(topicName, cont.public);
}
// Notify topic of the update if it's an external update.
if (!sub._noForwarding && topic) {
sub._noForwarding = true;
topic._processMetaDesc(sub);
}
}
updateCount++;
if (this.onMetaSub) {
this.onMetaSub(cont);
}
});
if (this.onSubsUpdated && updateCount > 0) {
const keys = [];
subs.forEach((s) => {
keys.push(s.topic);
});
this.onSubsUpdated(keys, updateCount);
}
}
// Called by Tinode when meta.sub is received.
_processMetaCreds(creds, upd) {
if (creds.length == 1 && creds[0] == Const.DEL_CHAR) {
creds = [];
}
if (upd) {
creds.forEach((cr) => {
if (cr.val) {
// Adding a credential.
let idx = this._credentials.findIndex((el) => {
return el.meth == cr.meth && el.val == cr.val;
});
if (idx < 0) {
// Not found.
if (!cr.done) {
// Unconfirmed credential replaces previous unconfirmed credential of the same method.
idx = this._credentials.findIndex((el) => {
return el.meth == cr.meth && !el.done;
});
if (idx >= 0) {
// Remove previous unconfirmed credential.
this._credentials.splice(idx, 1);
}
}
this._credentials.push(cr);
} else {
// Found. Maybe change 'done' status.
this._credentials[idx].done = cr.done;
}
} else if (cr.resp) {
// Handle credential confirmation.
const idx = this._credentials.findIndex((el) => {
return el.meth == cr.meth && !el.done;
});
if (idx >= 0) {
this._credentials[idx].done = true;
}
}
});
} else {
this._credentials = creds;
}
if (this.onCredsUpdated) {
this.onCredsUpdated(this._credentials);
}
}
// Process presence change message
_routePres(pres) {
if (pres.what == 'term') {
// The 'me' topic itself is detached. Mark as unsubscribed.
this._resetSub();
return;
}
if (pres.what == 'upd' && pres.src == Const.TOPIC_ME) {
// Update to me's description. Request updated value.
this.getMeta(this.startMetaQuery().withDesc().build());
return;
}
const cont = this._tinode.cacheGetTopic(pres.src);
if (cont) {
switch (pres.what) {
case 'on': // topic came online
cont.online = true;
break;
case 'off': // topic went offline
if (cont.online) {
cont.online = false;
cont.seen = Object.assign(cont.seen || {}, {
when: new Date()
});
}
break;
case 'msg': // new message received
cont._updateReceived(pres.seq, pres.act);
break;
case 'upd': // desc updated
// Request updated subscription.
this.getMeta(this.startMetaQuery().withLaterOneSub(pres.src).build());
break;
case 'acs': // access mode changed
// If 'tgt' is not set then this is an update to the permissions of the current user.
// Otherwise it's an update to group topic subscriber permissions while the topic is offline.
// Just ignore it then.
if (!pres.tgt) {
if (cont.acs) {
cont.acs.updateAll(pres.dacs);
} else {
cont.acs = new AccessMode().updateAll(pres.dacs);
}
cont.touched = new Date();
}
break;
case 'ua':
// user agent changed.
cont.seen = {
when: new Date(),
ua: pres.ua
};
break;
case 'recv':
// user's other session marked some messges as received.
pres.seq = pres.seq | 0;
cont.recv = cont.recv ? Math.max(cont.recv, pres.seq) : pres.seq;
break;
case 'read':
// user's other session marked some messages as read.
pres.seq = pres.seq | 0;
cont.read = cont.read ? Math.max(cont.read, pres.seq) : pres.seq;
cont.recv = cont.recv ? Math.max(cont.read, cont.recv) : cont.recv;
cont.unread = cont.seq - cont.read;
break;
case 'gone':
// topic deleted or unsubscribed from.
this._tinode.cacheRemTopic(pres.src);
if (!cont._deleted) {
cont._deleted = true;
cont._attached = false;
this._tinode._db.markTopicAsDeleted(pres.src, true);
} else {
this._tinode._db.remTopic(pres.src);
}
break;
case 'del':
// Update topic.del value.
break;
default:
this._tinode.logger("INFO: Unsupported presence update in 'me'", pres.what);
}
this._refreshContact(pres.what, cont);
} else {
if (pres.what == 'acs') {
// New subscriptions and deleted/banned subscriptions have full
// access mode (no + or - in the dacs string). Changes to known subscriptions are sent as
// deltas, but they should not happen here.
const acs = new AccessMode(pres.dacs);
if (!acs || acs.mode == AccessMode._INVALID) {
this._tinode.logger("ERROR: Invalid access mode update", pres.src, pres.dacs);
return;
} else if (acs.mode == AccessMode._NONE) {
this._tinode.logger("WARNING: Removing non-existent subscription", pres.src, pres.dacs);
return;
} else {
// New subscription. Send request for the full description.
// Using .withOneSub (not .withLaterOneSub) to make sure IfModifiedSince is not set.
this.getMeta(this.startMetaQuery().withOneSub(undefined, pres.src).build());
// Create a dummy entry to catch online status update.
const dummy = this._tinode.getTopic(pres.src);
dummy.topic = pres.src;
dummy.online = false;
dummy.acs = acs;
this._tinode._db.updTopic(dummy);
}
} else if (pres.what == 'tags') {
this.getMeta(this.startMetaQuery().withTags().build());
} else if (pres.what == 'msg') {
// Message received for un unknown (previously deleted) topic.
this.getMeta(this.startMetaQuery().withOneSub(undefined, pres.src).build());
// Create an entry to catch updates and messages.
const dummy = this._tinode.getTopic(pres.src);
dummy._deleted = false;
this._tinode._db.updTopic(dummy);
}
this._refreshContact(pres.what, cont);
}
if (this.onPres) {
this.onPres(pres);
}
}
// Contact is updated, execute callbacks.
_refreshContact(what, cont) {
if (this.onContactUpdate) {
this.onContactUpdate(what, cont);
}
}
/**
* Publishing to TopicMe is not supported. {@link Topic#publish} is overridden and throws an {Error} if called.
* @memberof Tinode.TopicMe#
* @throws {Error} Always throws an error.
*/
publish() {
return Promise.reject(new Error("Publishing to 'me' is not supported"));
}
/**
* Delete validation credential.
* @memberof Tinode.TopicMe#
*
* @param {string} method - Validation method such as 'email' or 'tel'.
* @param {string} value - Credential value to delete.
* @returns {Promise} Promise which will be resolved/rejected on receiving server reply.
*/
delCredential(method, value) {
if (!this._attached) {
return Promise.reject(new Error("Cannot delete credential in inactive 'me' topic"));
}
// Send {del} message, return promise
return this._tinode.delCredential(method, value).then(ctrl => {
// Remove deleted credential from the cache.
const index = this._credentials.findIndex((el) => {
return el.meth == method && el.val == value;
});
if (index > -1) {
this._credentials.splice(index, 1);
}
// Notify listeners
if (this.onCredsUpdated) {
this.onCredsUpdated(this._credentials);
}
return ctrl;
});
}
/**
* @callback contactFilter
* @param {Object} contact - contact to check for inclusion.
* @returns {boolean} <code>true</code> if contact should be processed, <code>false</code> to exclude it.
*/
/**
* Iterate over cached contacts.
*
* @function
* @memberof Tinode.TopicMe#
* @param {TopicMe.ContactCallback} callback - Callback to call for each contact.
* @param {contactFilter=} filter - Optionally filter contacts; include all if filter is false-ish, otherwise
* include those for which filter returns true-ish.
* @param {Object=} context - Context to use for calling the `callback`, i.e. the value of `this` inside the callback.
*/
contacts(callback, filter, context) {
this._tinode.mapTopics((c, idx) => {
if (c.isCommType() && (!filter || filter(c))) {
callback.call(context, c, idx);
}
});
}
/**
* 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(name) {
return this._tinode.cacheGetTopic(name);
}
/**
* Get access mode of a given contact from cache.
* @memberof Tinode.TopicMe#
*
* @param {string} name - Name of the contact to get access mode for, either a UID (for p2p topics)
* or a topic name; if missing, access mode for the 'me' topic itself.
* @returns {string} - access mode, such as `RWP`.
*/
getAccessMode(name) {
if (name) {
const cont = this._tinode.cacheGetTopic(name);
return cont ? cont.acs : null;
}
return this.acs;
}
/**
* Check if contact is archived, i.e. contact.private.arch == true.
* @memberof Tinode.TopicMe#
*
* @param {string} name - Name of the contact to check archived status, either a UID (for p2p topics) or a topic name.
* @returns {boolean} - true if contact is archived, false otherwise.
*/
isArchived(name) {
const cont = this._tinode.cacheGetTopic(name);
return cont && cont.private && !!cont.private.arch;
}
/**
* @typedef Tinode.Credential
* @memberof Tinode
* @type Object
* @property {string} meth - validation method such as 'email' or 'tel'.
* @property {string} val - credential value, i.e. 'jdoe@example.com' or '+17025551234'
* @property {boolean} done - true if credential is validated.
*/
/**
* Get the user's credentials: email, phone, etc.
* @memberof Tinode.TopicMe#
*
* @returns {Tinode.Credential[]} - array of credentials.
*/
getCredentials() {
return this._credentials;
}
/**
* Pin topic to the top of the contact list.
* @memberof Tinode.TopicMe#
*
* @param {string} topic - Name of the topic to pin.
* @param {boolean} [pin=false] - If true, pin the topic, otherwise unpin.
*
* @returns {Promise} Promise to be resolved/rejected when the server responds to request.
*/
pinTopic(topic, pin) {
if (!this._attached) {
return Promise.reject(new Error("Cannot pin topic in inactive 'me' topic"));
}
if (!Topic.isCommTopicName(topic)) {
return Promise.reject(new Error("Invalid topic to pin"));
}
const tpin = Array.isArray(this.private && this.private.tpin) ? this.private.tpin : [];
const found = tpin.includes(topic);
if ((pin && found) || (!pin && !found)) {
// Nothing to do.
return Promise.resolve(tpin);
}
if (pin) {
// Add topic to the top of the pinned list.
tpin.unshift(topic);
} else {
// Remove topic from the pinned list.
tpin.splice(tpin.indexOf(topic), 1);
}
return this.setMeta({
desc: {
private: {
tpin: tpin.length > 0 ? tpin : Const.DEL_CHAR
}
}
});
}
/**
* Get the rank of the pinned topic.
* @memberof Tinode.TopicMe#
* @param {string} topic - Name of the topic to check.
*
* @returns {number} numeric rank of the pinned topic in the range 1..N (N being the top,
* N - the number of pinned topics) or 0 if not pinned.
*/
pinnedTopicRank(topic) {
if (!this.private || !this.private.tpin) {
return 0;
}
const idx = this.private.tpin.indexOf(topic);
return idx < 0 ? 0 : this.private.tpin.length - idx;
}
}