Home Reference Source

src/contacts/ContactSynchronizer.js

import {EventEmitter} from '../eventemitter';
import {ContactRetriever} from './ContactRetriever';
import {
	isDevice,
	getDeviceId,
} from '../utils';
import * as ContactCache from './ContactCache';
import {isEqual} from 'lodash-es';
import Logs from '../Logs';

let log = Logs.instance.getLogger('SippoJS/ContactSynchronizer');
const NUM_CONTACTS_ALLOW_UPLOAD_ONE_BY_ONE = 30;

/**
 * Get an object without some fields
 * @param {Object} contact
 * @return {Object} New object without some fields
 */
function _getContactToCompare(contact) {
	let objContact = Object.assign({}, contact);
	delete objContact.user;
	delete objContact.favorite;
	delete objContact.id;
	delete objContact.unnormalizedPhones;
	objContact.avatar = null;
	objContact.phones = contact.unnormalizedPhones;
	return objContact;
}

function _prepareToUpdate(oldContact, newContact) {
	return Object.assign(oldContact, newContact);
}

const _isNeededUploadCompleteListAgain = (arrayOfArrays) => {
	return arrayOfArrays.reduce((counter, actual) => {
		counter += actual.length;
		return counter;
	}, 0) >= NUM_CONTACTS_ALLOW_UPLOAD_ONE_BY_ONE;
};

const _onErrorSyncContacts = (err) => {
	return err.message === 'not-sync-avatars' ?
		Promise.resolve() :
		Promise.reject(err);
};

/**
 * Keeps the wac contacts up to date with device contacts.
 */
export class ContactSynchronizer extends EventEmitter {
	constructor(stack, favoriteContact) {
		super();
		this._stack = stack;
		this._retriever = new ContactRetriever(stack);
		this._deviceId = getDeviceId();
		this._favoriteContacts = favoriteContact;
	}

	/**
	 * Init the contact sync at first time
	 * @param {*} deviceContacts : <W3cContact>[]
	 */
	syncContacts(deviceContacts) {
		if (this._isAllowDeviceContacts()) {
			return this._invalidateAndSyncContacts(deviceContacts);
		}
		this.emit('stop-synchronizing');
	}

	/**
	 * Launch the synchronizing to check if a change has occurred
	 */
	launchSynchronize() {
		let devSync = Promise.resolve();
		if (this._isAllowDeviceContacts()) {
			devSync = this._retriever.getContactsDevice().then((deviceContacts) => {
				if (!ContactCache.isCacheUpdated(deviceContacts)) {
					this.emit('start-synchronizing');
					let deviceContactsStored = ContactCache.getDeviceWacContacts();
					ContactCache.invalidateCache();
					let contactsToDelete = this._getContactsToDeleted(deviceContactsStored, deviceContacts);
					let [contactsToUpdate, contactsToInsert] = this._getContactsToSynchronize(deviceContactsStored, deviceContacts);
					return _isNeededUploadCompleteListAgain([contactsToDelete, contactsToUpdate, contactsToInsert]) ?
						this._uploadAllContactsAgain(deviceContacts) :
						this._sendUpdatesToWac(contactsToDelete, contactsToUpdate, contactsToInsert, deviceContacts);
				}
				this.emit('stop-synchronizing');
			})
				.then(() => this.launchSynchronizeAvatars())
				.catch(_onErrorSyncContacts);
		}
		return devSync;
	}

	/**
	 * Get an array with contacts to update and to insert
	 * @param {Array} oldListContacts
	 * @param {Array} newListContacts
	 * @return {Array} Two elements. First a list of contacts to update and
	 *                 second element with a list of contact to insert
	 */
	_getContactsToSynchronize(oldListContacts, newListContacts) {
		let mapOldDeviceContacts = new Map(oldListContacts.map(contact => [contact.contactDeviceId, contact]));
		let contactsToUpdate = [];
		let contactsToInsert = [];
		newListContacts.forEach((contact) => {
			if (mapOldDeviceContacts.has(contact.contactDeviceId)) {
				let oContactStored = mapOldDeviceContacts.get(contact.contactDeviceId);
				return !isEqual(contact, _getContactToCompare(oContactStored)) ?
					contactsToUpdate.push(_prepareToUpdate(oContactStored, contact)) :
					undefined;
			}
			return contactsToInsert.push(contact);
		});
		return [contactsToUpdate, contactsToInsert];
	}

	/**
	 * Get the list of contacts to delete
	 * @param {Array} oldListContacts
	 * @param {Array} newListContacts
	 * @return {Array} A list with contacts to delete
	 */
	_getContactsToDeleted(oldListContacts, newListContacts) {
		let mapNewListContacts = new Map(newListContacts.map(contact => [contact.contactDeviceId, contact]));
		return oldListContacts.map((contact) => {
			if (!mapNewListContacts.has(contact.contactDeviceId)) {
				return contact;
			}
		}).filter(contact => !!contact);
	}

	/**
	 * Update the new changes to the WAC
	 * @param {Array} contactsToDelete
	 * @param {Array} contactsToUpdate
	 * @param {Array} contactsToInsert
	 * @param {Array} deviceContacts
	 * @return {Promise} Fulfilled if success
	 */
	_sendUpdatesToWac(contactsToDelete, contactsToUpdate, contactsToInsert, deviceContacts) {
		contactsToUpdate = this._favoriteContacts.updateContactsWithFavoriteFlag(contactsToUpdate);
		return Promise.all([
			this._removeContacts(contactsToDelete),
			this._addContacts(contactsToInsert),
			this._updateContacts(contactsToUpdate),
		]).then(() => {
			ContactCache.setDeviceContacts(deviceContacts);
			ContactCache.validateCache();
		});
	}


	/**
	 * Sync the avatars of the device contacts
	 */
	launchSynchronizeAvatars() {
		if (this._isAllowDeviceContacts()) {
			return this._retriever.getAvatarDeviceContacts().then((avatars) => {
				ContactCache.setAvatarDeviceContacts(Array.from(avatars));
				this.emit('update-avatars-contacts', avatars);
			});
		}
	}

	/**
	 * Invalidate list of contacts for this device and upload all contacts
	 */
	_invalidateAndSyncContacts(deviceContacts) {
		log.info('device contacts out of sync, uploading new list');
		this.emit('sync-all-device-contacts');
		return this._stack.invalidateContacts(this._deviceId)
			.then(() => this._uploadContacts(deviceContacts))
			.then(() => {
				ContactCache.setDeviceContacts(deviceContacts);
				ContactCache.validateCache();
			});
	}

	/**
	 * Invalidate list of contacts for this device,
	 * upload all contacts and avoid to sync avatars
	 */
	_uploadAllContactsAgain(deviceContacts) {
		return this._invalidateAndSyncContacts(deviceContacts)
			.then(() => {
				throw new Error('not-sync-avatars');
			});
	}


	/**
	 * Upload a list of contacts to the WAC.
	 * @param {*} contactList
	 */
	_uploadContacts(contactList) {
		return contactList.length ? this._stack.insertMany({
			contacts: contactList,
			source: 'device',
		}) : Promise.resolve();
	}

	/**
	 * Add new contacts to the WAC.
	 * @param {*} contactList
	 */
	_addContacts(contactList) {
		if (contactList.length) {
			return Promise.all(contactList.map(contact => this._stack.addContact(contact)));
		}
	}

	/**
	 * Update a list of contacts to the WAC.
	 * @param {*} contactList
	 */
	_updateContacts(contactList) {
		if (contactList.length) {
			return Promise.all(contactList.map(contact => this._stack.updateContact(contact)));
		}
	}

	/**
	 * Remove contacts in the WAC.
	 * @param {*} contactsList
	 */
	_removeContacts(contactsList) {
		if (contactsList.length) {
			return Promise.all(contactsList.map(contact => this._stack.removeContact(contact)));
		}
	}

	_isAllowDeviceContacts() {
		return isDevice() && this._stack.getCapabilities().includes('w3c-contacts-api');
	}
}