Home Reference Source

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;
	}
}