src/contacts/ContactManager.js
import {EventEmitter} from '../eventemitter';
import {
bindMethods,
isDevice,
getDeviceId,
} from '../utils';
import {Presence} from './Presence';
import {OwnPresence} from './OwnPresence';
import {Contact} from './Contact';
import {ContactSynchronizer as Synchronizer} from './ContactSynchronizer';
import {ContactRetriever} from './ContactRetriever';
import {ContactFactory} from './ContactFactory';
import {FavoriteContact} from './FavoriteContact';
import {GroupContact} from './GroupContact';
import * as ContactCache from './ContactCache';
import Logs from '../Logs';
let log = Logs.instance.getLogger('SippoJS/ContactManager');
// https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-contacts/#contacterror
const CODE_ERRORS_CORDOVA = [0, 1, 2, 3, 4, 5, 6, 20];
/**
* Check if a contact belongs to a device
* @param contact
* @returns {boolean}
* @private
*/
function _isFromDevice(contact) {
return contact.deviceId !== '';
}
/**
* Check if the error was emitted by cordova
* @param err
* @return boolean
* https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-contacts/#contacterror
*/
function errorGettingDeviceContacts(err) {
// When the app is used in a android device, cordova returns a number primitive
// instead of an object
let code = typeof err === 'number' ? err : err.code;
return code ? CODE_ERRORS_CORDOVA.includes(code) : false;
}
/**
* Remove device contacts from the list
* @param {Array} contacts
*/
function removeDeviceContactFromList(contacts) {
return contacts.filter(contact => contact.source !== 'device');
}
/**
* ContactManager instance is obtained by calling the {@link Session#getContactManager}
* method of {@link Session} and must not be directly instantiated.
* Once the ContactManager instance is obtained {@link ContactManager#init} method
* must be called to initialize it.
*
* ## Events
*
* - **`create`** is emitted every time a new contact is added to the contact list.
* - {@link Contact} `contact` The contact that has just been created.
* - **`contact-added`** is emitted every time a new contact is added to the cache after have been created
* - {@link Contact} `contact` The contact that has just been created.
* - **`update`** is emitted when some contact is updated.
* - {@link Contact} `contact` The contact that has just been updated.
* - **`delete`** is emitted when some contact is deleted.
* - {@link Contact} `contact` The contact that has just been deleted.
* - ** start-synchronizing ** is emitted when a synchronizing process starts
* - ** start-synchronizing-background ** is emitted when a synchronizing process starts in background
* - ** start-synchronizing-avatars ** is emitted when a avatar synchronizing process starts
* - ** stop-synchronizing ** is emitted when a synchronizing process ends
* - ** sync-all-device-contacts ** is emitted when all device contacts are going to be uploaded
*
* You can check EventEmitter2 documentation for additional information about
* adding and removing listeners for this events events.
*
* @see https://github.com/asyncly/EventEmitter2#api
*
* @example <caption>Get contact list</caption>
* contactManager.init().then(function() {
* contactManager.getContacts().forEach(function(contact) {
* console.log(contact.name);
* });
* });
*
* To sync contacts, a new function must be called (contact Manager has to be initialized previously)
* @example <caption>Try the sync process</caption>
* contactManager.launchSynchronize().then(function() {
* contactManager.getContacts().forEach(function(contact) {
* console.log(contact.name);
* });
* });
*/
export class ContactManager extends EventEmitter {
/**
* Constructs a contact manager
* @protected
* @param {WacProxy} stack
* @param {UserManager} userManager
* @param {PresenceManager} presenceManager
*/
constructor(stack, userManager, presenceManager) {
super();
/**
* @type {WacProxy}
*/
this._stack = stack;
/**
* @type {UserManager}
*/
this._userManager = userManager;
/**
* @type {PresenceManager}
*/
this._presenceManager = presenceManager;
/**
* @type {Array<Contact>}
*/
this._contacts = [];
/**
* @type {Promise}
*/
this._initializing = null;
/**
* @type {String}
*/
this._deviceId = getDeviceId();
const ownAddress = userManager.getAddress(stack.getCurrentSession().user);
/**
* Allows to change the presence of this session
* @type {OwnPresence}
*/
this.ownPresence = new OwnPresence(stack, presenceManager, ownAddress);
/**
* It's in charge of getting the contacts
* @type {ContactRetriever}
*/
this._retriever = new ContactRetriever(stack);
/**
* Manage the favorite contacts
* @type {FavoriteContact}
*/
this._favorite = new FavoriteContact();
/**
* It's in charge of synchronizing contacts
* @type {ContactSynchronizer}
*/
this._synchronizer = new Synchronizer(stack, this._favorite);
/**
* Create a contact factory
*/
this._contactFactory = ContactFactory({
stack: this._stack,
userManager: this._userManager,
presenceManager: this._presenceManager,
favoriteManager: this._favorite,
});
bindMethods(this, [
'onContactAdd',
'onContactUpdate',
'onContactRemove',
'onContactAddMany',
'onUpdateListContacts',
'onUpdateAvatarsContacts',
'_addContact',
'_onSyncProcessError',
]);
bindMethods(this, [
'onLocalContactRemove',
], true);
}
/**
* @returns {Object} Returns an object with the prototypes to allow customizing
* the getters with any kind of application specific behaviours
* @property {Object} Contact The prototype for Contact objects
* @property {Object} GroupContact The prototype for Group Contact objects
* @property {Object} Presence The prototype for Presence objects
* @property {Object} OwnPresence The prototype for Own Presence objects
*/
get prototypes() {
return {
Contact: Contact.prototype,
GroupContact: GroupContact.prototype,
Presence: Presence.prototype,
OwnPresence: OwnPresence.prototype,
};
}
/**
* Initializes the contact manager
* @returns {Promise<ContactManager, Error>} A promise that returns when fulfilled
* the contact manager instance
*/
init() {
if (!this._initializing) {
this._initializing = (async () => {
ContactCache.invalidateCacheIfNeeded(this._stack);
this._initCallbacks();
let savedContacts = ContactCache.getDeviceWacContacts();
const validCache = ContactCache.isValidCache();
const wacContacts = await this._retriever.getContactsWac();
ContactCache.setWacContacts(wacContacts);
if (savedContacts && validCache) {
savedContacts = this._favorite.updateContactsWithFavoriteFlag(savedContacts);
savedContacts = this._updateAvatarOfContacts(savedContacts, new Map(ContactCache.getAvatarDeviceContacts() || []));
const allContacts = savedContacts.concat(wacContacts);
allContacts.forEach(this._addContact);
} else {
this.emit('start-synchronizing');
this._insertWACContact(wacContacts);
try {
const deviceContacts = await this._retriever.getContactsDevice();
await this._synchronizer.syncContacts(deviceContacts);
} catch (err) {
this._onSyncProcessError(err);
}
}
})();
}
return this._initializing;
}
/**
* Uninitializes the contact manager
* @returns {Promise<undefined, Error>} A promise that is fulfilled when unitialization finishes
*/
uninit() {
if (this._initializing === null) {
return Promise.resolve();
}
return this._initializing.finally(() => {
this._unInitCallbacks();
this._contacts = [];
this._initializing = null;
});
}
/**
* Call stack.addContact once the manager is initialized and return a promise.
*
* If the contact is successfully created the promise will be resolved, otherwise will be rejected.
* The wac will send a qs-contact-add event to every session.
*
* @param {String} displayName The name of the new contact
* @param {string | {name: string, address: string}[]} phones Phone or phones of the new contact
* @param {{email: string}[]} emails Email or emails of the new contact
*/
createContact(displayName, phones, emails) {
if (!Array.isArray(phones)) {
phones = [{
name: '',
address: phones,
}];
}
let contactData = {
id: '',
phones,
emails,
name: displayName,
source: 'wac',
};
return this._initializing.then(() => this._stack.addContact(contactData));
}
/**
* If the contact is successfully created the promise will be resolved, otherwise will be rejected.
* The wac will send a qs-contact-add event to every session.
*
* @param {String} displayName The name of the new contact
* @param {Object} participants
* @param {String} participants.name The name associated to the phone of the participant
* @param {String} participants.address The phone of the participant
*/
createGroupContact(displayName, participants) {
if (!participants) {
return Promise.reject('a list of participants must be given');
}
let contactData = {
name: displayName,
source: 'group',
participants,
};
return this._initializing
.then(() => this._stack.addContact(contactData));
}
/**
* Retrieves a list with the available contacts
* @return {Array<Contact>}
*/
getContacts() {
return this._contacts;
}
/**
* Retrieves a contact given a WAC id
* @return {Contact}
*/
getContactByWacId(wacId) {
return this.getContactByAddress(this._userManager.getAddress(wacId));
}
/**
* Retrieves the presence of a user given its WAC id
* @return {Contact}
*/
getPresenceByWacId(wacId) {
return new Presence(this._presenceManager, this._userManager.getAddress(wacId));
}
/**
* Retrieves a contact given an email
* @param {String} email
* @return {Contact|undefined}
*/
getContactByEmail(email) {
return this._contacts.find((c) => {
return c.emails && c.emails.some(elem => elem.email === email);
});
}
/**
* Retrieves a contact given an address
* @param {String} address
* @param {boolean} [sync=true] If false returns a Promise of a contact
* @return {Contact}
*/
getContactByAddress(address, sync = true) {
let contact;
if (address.includes('@')) {
const [username, domain] = address.split('@');
contact = this._contacts.find(c => c._user && c._user.username.toLowerCase() === username.toLowerCase() && c._user.domain === domain);
} else if (this._userManager.isAddress(address)) {
let userId = this._userManager.getUserId(address);
contact = this._contacts.find(c => c._user && c._user.id === userId);
if (contact) {
log.warn(`getContacByAddress: ${address} is not a valid address. Deprecated fallback searching for old style address obtained a match.`);
}
} else {
contact = this._contacts.find((c) => {
return c.phones ? c.phones.some(phone => phone.address === address) : undefined;
});
if (contact) {
log.warn(`getContacByAddress: ${address} is not a valid address. Deprecated fallback searching in phones obtained a match.`);
}
}
if (contact) {
return sync ? contact : Promise.resolve(contact);
}
contact = this._contactFactory.create({});
let task = this._userManager.resolveUser(address).then((user) => {
let phone = user ? user.alias || user.username : address;
if (!user) {
contact.name = address;
}
contact.phone = phone;
});
if (sync) {
return contact;
} else {
return task.then(() => contact);
}
}
/**
* Retrieves the presence of a user given an address
* @return {Promise<Presence>}
*/
getPresenceByAddress(address) {
return this._userManager.resolveUser(address).then((user) => {
return user ? this.getPresenceByWacId(user.id) : undefined;
});
}
/**
* Returns a new local only contact with the specified parameters
* @param {object} options
* @param {string} options.id
* @param {string} [name]
* @param {Presence} [presence]
* @param {string} [avatar]
* @param {string} [phone]
* @param {string} [email]
*/
getFakeContact({
id,
name,
presence,
avatar,
phone,
email,
}) {
const phones = phone ? [{name: '', address: phone}] : [];
const emails = email ? [{type: '', email: email}] : [];
return new Contact(null, null, null, null, {
id,
name,
phones,
emails,
source: '',
favorite: false,
deviceId: '',
contactDeviceId: '',
avatar,
presence,
});
}
/**
* Launch the sinchronizing to check if a change has occured
* @return {Promise} if success
*/
launchSynchronize() {
return this._synchronizer.launchSynchronize()
.catch(this._onSyncProcessError);
}
/**
* Treat the error when a sync process fails
* @param err
* @returns {Promise}
* @private
*/
_onSyncProcessError(err) {
log.error('Failed sync process: ', err);
if (!errorGettingDeviceContacts(err)) {
log.error(err);
throw err;
}
this._contacts = removeDeviceContactFromList(this.getContacts());
return this._stack.invalidateContacts(this._deviceId).then(() => {
this.clearContactsDataFromPersistentStorage();
}).catch((invalidatingErr) => {
log.error('Failed invalidating contacts: ', invalidatingErr);
throw invalidatingErr;
}).then(() => {
throw err;
});
}
/**
* Internal callback for Contact event
* @private
* @param {Contact} contact
*/
onLocalContactRemove(contact) {
if (this._rmContact(contact.id) !== undefined) {
this.emit('delete', contact);
}
}
/**
* Internal callback for WAC event
*
* When the contacts arrive after a synchronizing, insert the contacts in the contacts list
* and update the contact list stored in the navigator when:
* - The contacts that arrive belong to the device that has done the synchronizing
* - We are on desktop.
* @private
* @param {Array<Contact>} contacts Contacts retrieved from the WAC
*/
onContactAddMany(contacts) {
this._emptyContactList();
let savedWacContacts = ContactCache.getWacContacts();
return Promise.resolve().then(() => {
if (!isDevice()) {
savedWacContacts = savedWacContacts.filter(contact => contact.deviceId !== contacts[0].deviceId);
savedWacContacts = savedWacContacts.concat(contacts);
ContactCache.setWacContacts(savedWacContacts);
savedWacContacts.forEach(this._addContact);
this.emit('stop-synchronizing');
} else if (this._isFromThisDevice(contacts[0])) {
ContactCache.setDeviceWacContacts(contacts);
contacts = contacts.concat(savedWacContacts);
contacts.forEach(this._addContact);
this.emit('stop-synchronizing');
this.emit('start-synchronizing-avatars');
this._synchronizer.launchSynchronizeAvatars();
}
});
}
/**
* Internal callback for WAC event
* @private
* @param {Contact} contact
*/
onContactAdd(contact) {
contact = this._addContact(contact);
if (this._isFromThisDevice(contact)) {
let deviceContacts = ContactCache.getDeviceWacContacts();
deviceContacts.push(contact);
ContactCache.setDeviceWacContacts(deviceContacts);
} else {
let savedWacContacts = ContactCache.getWacContacts();
savedWacContacts = savedWacContacts.concat(contact);
ContactCache.setWacContacts(savedWacContacts);
}
this.emit('contact-added', contact);
}
/**
* Internal callback for WAC event
* @private
* @param {Contact} contact
*/
onContactUpdate(contact) {
let contactToUpdate = this._contacts.find(el => contact.id === el.id);
if (contactToUpdate) {
contactToUpdate._update(contact);
if (this._isFromThisDevice(contact)) {
let deviceContacts = ContactCache.getDeviceWacContacts();
let index = deviceContacts.findIndex(deviceContact => deviceContact.id === contact.id);
deviceContacts[index] = contact;
ContactCache.setDeviceWacContacts(deviceContacts);
}
this.emit('update', contact);
}
}
/**
* Internal callback for Synchronizer event
* @param {Array} contacts To insert in contact list
*/
onUpdateListContacts(contacts) {
this._emptyContactList();
contacts = contacts.concat(ContactCache.getDeviceWacContacts());
contacts.forEach(this._addContact);
}
/**
* Internal callback for WAC event
* @private
* @param {Contact} contactDeleted
*/
onContactRemove(contactDeleted) {
let contact = this._rmContact(`${contactDeleted.id}@${contactDeleted.source}`);
if (contact !== undefined) {
contact._remove();
if (this._isFromThisDevice(contactDeleted)) {
let deviceContacts = ContactCache.getDeviceWacContacts();
deviceContacts = deviceContacts.filter(deviceContact => deviceContact.contactDeviceId !== contact.contactDeviceId);
ContactCache.setDeviceWacContacts(deviceContacts);
}
this.emit('delete', contact);
}
}
/**
* Internal callback for Synchronizer event. Update contacts
* with his avatar.
*
* @param {Map} avatarsContacts A map with contactDeviceId as key and avatar as value
*/
onUpdateAvatarsContacts(avatarsContacts) {
this._contacts = this._updateAvatarOfContacts(this._contacts, avatarsContacts);
this.emit('stop-synchronizing-avatars');
}
/**
* Clears all user contacts data and invalidates the cache from the persistent storage
*/
clearContactsDataFromPersistentStorage() {
ContactCache.emptyAllContactData();
}
/**
* Update contacts with the avatars stored
* @param {Array<Contact>} contacts
* @param {Map} avatarMap
* @return A list of contacts updated
*/
_updateAvatarOfContacts(contacts, avatarMap) {
if (!avatarMap.size) {
return contacts;
}
return contacts.map((contact) => {
if (avatarMap.has(contact.contactDeviceId)) {
contact.avatar = avatarMap.get(contact.contactDeviceId);
}
return contact;
});
}
/**
* Bind the callbacks
*/
_initCallbacks() {
this._stack.on('qs-contact-add', this.onContactAdd);
this._stack.on('qs-contact-add-many', this.onContactAddMany);
this._stack.on('qs-contact-update', this.onContactUpdate);
this._stack.on('qs-contact-remove', this.onContactRemove);
this._synchronizer.on('update-list-contacts', this.onUpdateListContacts);
this._synchronizer.on('update-avatars-contacts', this.onUpdateAvatarsContacts);
this._synchronizer.on('start-synchronizing', () => {
this.emit('start-synchronizing-background');
});
this._synchronizer.on('sync-all-device-contacts', () => {
this.emit('sync-all-device-contacts');
});
this._synchronizer.on('stop-synchronizing', () => {
this.emit('stop-synchronizing');
});
}
/**
* Unbind the callbacks
*/
_unInitCallbacks() {
this._stack.off('qs-contact-add', this.onContactAdd);
this._stack.off('qs-contact-add-many', this.onContactAddMany);
this._stack.off('qs-contact-update', this.onContactUpdate);
this._stack.off('qs-contact-remove', this.onContactRemove);
this._synchronizer.off('update-list-contacts', this.onUpdateListContacts);
this._synchronizer.off('update-avatars-contacts', this.onUpdateAvatarsContacts);
}
/**
* When the contacts arrive, insert the contacts in the contacts list when:
* - The contact is not from a device.
* - We are on desktop.
* @private
* @param {Array<Contact>} contacts Contacts retrieved from the WAC
*/
_insertWACContact(contacts) {
contacts.forEach((contact) => {
if (!_isFromDevice(contact) || !this._deviceId) {
this._addContact(contact);
}
});
}
/**
* Adds a new contact to the list
* @private
* @param {Object} data
* @param {string} data.id
* @param {string} data.name
* @param {Object[]} data.phones
* @param {Object[]} data.emails
* @param {string} data.source
* @param {boolean} data.favorite
* @param {string} data.deviceId
* @param {string} data.contactDeviceId
* @return {Contact} The new created contact
*/
_addContact(data) {
let contact = this._contactFactory.create(data);
this._contacts.push(contact);
contact.once('delete', this.onLocalContactRemove);
this.emit('create', contact);
return contact;
}
/**
* Remove an existing contact from the list
* @private
* @param {string} id ID of the contact to remove
* @return {Contact} The just removed contact if any
*/
_rmContact(id) {
let index = this._contacts.findIndex(contact => contact.id === id);
if (index < 0) {
return;
}
let contact = this._contacts.splice(index, 1)[0];
contact.off('delete', this.onLocalContactRemove);
return contact;
}
_emptyContactList() {
this._contacts.length = 0;
}
_isFromThisDevice(contact) {
return contact.deviceId === this._deviceId && !!this._deviceId;
}
}