/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ var loop = loop || {}; loop.contacts = (function(_, mozL10n) { "use strict"; var sharedMixins = loop.shared.mixins; const Button = loop.shared.views.Button; const ButtonGroup = loop.shared.views.ButtonGroup; const CALL_TYPES = loop.shared.utils.CALL_TYPES; // Number of contacts to add to the list at the same time. const CONTACTS_CHUNK_SIZE = 100; // At least this number of contacts should be present for the filter to appear. const MIN_CONTACTS_FOR_FILTERING = 7; let getContactNames = function(contact) { // The model currently does not enforce a name to be present, but we're // going to assume it is awaiting more advanced validation of required fields // by the model. (See bug 1069918) // NOTE: this method of finding a firstname and lastname is not i18n-proof. let names = contact.name[0].split(" "); return { firstName: names.shift(), lastName: names.join(" ") }; }; /** Used to retrieve the preferred email or phone number * for the contact. Both fields are optional. * @param {object} contact * The contact object to get the field from. * @param {string} field * The field that should be read out of the contact object. * @returns {object} An object with a 'value' property that hold a string value. */ let getPreferred = function(contact, field) { if (!contact[field] || !contact[field].length) { return { value: "" }; } return contact[field].find(e => e.pref) || contact[field][0]; }; /** Used to set the preferred email or phone number * for the contact. Both fields are optional. * @param {object} contact * The contact object to get the field from. * @param {string} field * The field within the contact to set. * @param {string} value * The value that the field should be set to. */ let setPreferred = function(contact, field, value) { // Don't clear the field if it doesn't exist. if (!value && (!contact[field] || !contact[field].length)) { return; } if (!contact[field]) { contact[field] = []; } if (!contact[field].length) { contact[field][0] = {"value": value}; return; } // Set the value in the preferred tuple and return. for (let i in contact[field]) { if (contact[field][i].pref) { contact[field][i].value = value; return; } } contact[field][0].value = value; }; const GravatarPromo = React.createClass({ mixins: [sharedMixins.WindowCloseMixin], propTypes: { handleUse: React.PropTypes.func.isRequired }, getInitialState: function() { return { showMe: navigator.mozLoop.getLoopPref("contacts.gravatars.promo") && !navigator.mozLoop.getLoopPref("contacts.gravatars.show") }; }, handleCloseButtonClick: function() { navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false); this.setState({ showMe: false }); }, handleLinkClick: function(event) { if (!event.target || !event.target.href) { return; } event.preventDefault(); navigator.mozLoop.openURL(event.target.href); this.closeWindow(); }, handleUseButtonClick: function() { navigator.mozLoop.setLoopPref("contacts.gravatars.promo", false); navigator.mozLoop.setLoopPref("contacts.gravatars.show", true); this.setState({ showMe: false }); this.props.handleUse(); }, render: function() { if (!this.state.showMe) { return null; } let privacyUrl = navigator.mozLoop.getLoopPref("legal.privacy_url"); let message = mozL10n.get("gravatars_promo_message", { "learn_more": React.renderToStaticMarkup( {mozL10n.get("gravatars_promo_message_learnmore")} ) }); return (
); } }); const ContactDropdown = React.createClass({ propTypes: { // If the contact is blocked or not. blocked: React.PropTypes.bool, canEdit: React.PropTypes.bool, // Position of mouse when opening menu eventPosY: React.PropTypes.number.isRequired, // callback function that provides height and top coordinate for contacts container getContainerCoordinates: React.PropTypes.func.isRequired, handleAction: React.PropTypes.func.isRequired }, getInitialState: function() { return { openDirUp: false }; }, onItemClick: function(event) { this.props.handleAction(event.currentTarget.dataset.action); }, componentDidMount: function() { var menuNode = this.getDOMNode(); var menuNodeRect = menuNode.getBoundingClientRect(); var listNodeCoords = this.props.getContainerCoordinates(); // Click offset to not display the menu right next to the area clicked. var offset = 10; if (this.props.eventPosY + menuNodeRect.height >= listNodeCoords.top + listNodeCoords.height) { // Position above click area. menuNode.style.top = this.props.eventPosY - menuNodeRect.height - offset + "px"; } else { // Position below click area. menuNode.style.top = this.props.eventPosY + offset + "px"; } }, render: function() { var cx = React.addons.classSet; var dropdownClasses = cx({ "dropdown-menu": true, "dropdown-menu-up": this.state.openDirUp }); let blockAction = this.props.blocked ? "unblock" : "block"; let blockLabel = this.props.blocked ? "unblock_contact_menu_button" : "block_contact_menu_button"; return ( ); } }); const ContactDetail = React.createClass({ propTypes: { contact: React.PropTypes.object.isRequired, getContainerCoordinates: React.PropTypes.func.isRequired, handleContactAction: React.PropTypes.func }, mixins: [ sharedMixins.DropdownMenuMixin() ], getInitialState: function() { return { eventPosY: 0 }; }, handleShowDropdownClick: function(e) { e.preventDefault(); e.stopPropagation(); this.setState({ eventPosY: e.pageY }); this.toggleDropdownMenu(); }, hideDropdownMenuHandler: function() { // Since this call may be deferred, we need to guard it, for example in // case the contact was removed in the meantime. if (this.isMounted()) { this.hideDropdownMenu(); } }, shouldComponentUpdate: function(nextProps, nextState) { let currContact = this.props.contact; let nextContact = nextProps.contact; let currContactEmail = getPreferred(currContact, "email").value; let nextContactEmail = getPreferred(nextContact, "email").value; return ( currContact.name[0] !== nextContact.name[0] || currContact.blocked !== nextContact.blocked || currContactEmail !== nextContactEmail || nextState.showMenu !== this.state.showMenu ); }, handleAction: function(actionName) { if (this.props.handleContactAction) { this.props.handleContactAction(this.props.contact, actionName); this.hideDropdownMenuHandler(); } }, canEdit: function() { // We cannot modify imported contacts. For the moment, the check for // determining whether the contact is imported is based on its category. return this.props.contact.category[0] !== "google"; }, /** * Callback called when moving cursor away from the conversation entry. * Will close the dropdown menu. */ _handleMouseOut: function() { if (this.state.showMenu) { this.toggleDropdownMenu(); } }, render: function() { let names = getContactNames(this.props.contact); let email = getPreferred(this.props.contact, "email"); let avatarSrc = navigator.mozLoop.getUserAvatar(email.value); let cx = React.addons.classSet; let contactCSSClass = cx({ contact: true, blocked: this.props.contact.blocked }); let avatarCSSClass = cx({ avatar: true, defaultAvatar: !avatarSrc }); return (
  • {avatarSrc ? : null}
    {names.firstName} {names.lastName}
    {email.value}
    {this.state.showMenu ? : null }
  • ); } }); const ContactsList = React.createClass({ mixins: [ React.addons.LinkedStateMixin, loop.shared.mixins.WindowCloseMixin ], propTypes: { mozLoop: React.PropTypes.object.isRequired, notifications: React.PropTypes.instanceOf(loop.shared.models.NotificationCollection).isRequired, switchToContactAdd: React.PropTypes.func.isRequired, switchToContactEdit: React.PropTypes.func.isRequired }, /** * Contacts collection object */ contacts: null, /** * User profile */ _userProfile: null, getInitialState: function() { return { importBusy: false, filter: "" }; }, refresh: function(callback = function() {}) { let contactsAPI = this.props.mozLoop.contacts; this.handleContactRemoveAll(); contactsAPI.getAll((err, contacts) => { if (err) { callback(err); return; } // Add contacts already present in the DB. We do this in timed chunks to // circumvent blocking the main event loop. let addContactsInChunks = () => { contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => { this.handleContactAddOrUpdate(contact, false); }); if (contacts.length) { setTimeout(addContactsInChunks, 0); } else { callback(); } this.forceUpdate(); }; addContactsInChunks(contacts); }); }, componentWillMount: function() { // Take the time to initialize class variables that are used outside // `this.state`. this.contacts = {}; this._userProfile = this.props.mozLoop.userProfile; }, componentDidMount: function() { window.addEventListener("LoopStatusChanged", this._onStatusChanged); this.refresh(err => { if (err) { throw err; } let contactsAPI = this.props.mozLoop.contacts; // Listen for contact changes/ updates. contactsAPI.on("add", (eventName, contact) => { this.handleContactAddOrUpdate(contact); }); contactsAPI.on("remove", (eventName, contact) => { this.handleContactRemove(contact); }); contactsAPI.on("removeAll", () => { this.handleContactRemoveAll(); }); contactsAPI.on("update", (eventName, contact) => { this.handleContactAddOrUpdate(contact); }); }); }, componentWillUnmount: function() { window.removeEventListener("LoopStatusChanged", this._onStatusChanged); }, /* * Filter a user by name, email or phone number. * Takes in an input to filter by and returns a filter function which * expects a contact. * * @returns {Function} */ filterContact: function(filter) { return function(contact) { return getPreferred(contact, "name").toLocaleLowerCase().includes(filter) || getPreferred(contact, "email").value.toLocaleLowerCase().includes(filter) || getPreferred(contact, "tel").value.toLocaleLowerCase().includes(filter); }; }, /* * Takes all contacts, it groups and filters them before rendering. */ _filterContactsList: function() { let shownContacts = _.groupBy(this.contacts, function(contact) { return contact.blocked ? "blocked" : "available"; }); if (this._shouldShowFilter()) { let filter = this.state.filter.trim().toLocaleLowerCase(); let filterFn = this.filterContact(filter); if (filter) { if (shownContacts.available) { shownContacts.available = shownContacts.available.filter(filterFn); // Filter can return an empty array. if (!shownContacts.available.length) { shownContacts.available = null; } } if (shownContacts.blocked) { shownContacts.blocked = shownContacts.blocked.filter(filterFn); // Filter can return an empty array. if (!shownContacts.blocked.length) { shownContacts.blocked = null; } } } } return shownContacts; }, /* * Decide to render contacts filter based on the number of contacts. * * @returns {bool} */ _shouldShowFilter: function() { return Object.getOwnPropertyNames(this.contacts).length >= MIN_CONTACTS_FOR_FILTERING; }, _onStatusChanged: function() { let profile = this.props.mozLoop.userProfile; let currUid = this._userProfile ? this._userProfile.uid : null; let newUid = profile ? profile.uid : null; if (currUid !== newUid) { // On profile change (login, logout), reload all contacts. this._userProfile = profile; // The following will do a forceUpdate() for us. this.refresh(); } }, handleContactAddOrUpdate: function(contact, render = true) { let contacts = this.contacts; let guid = String(contact._guid); contacts[guid] = contact; if (render) { this.forceUpdate(); } }, handleContactRemove: function(contact) { let contacts = this.contacts; let guid = String(contact._guid); if (!contacts[guid]) { return; } delete contacts[guid]; this.forceUpdate(); }, handleContactRemoveAll: function() { // Do not allow any race conditions when removing all contacts. this.contacts = {}; this.forceUpdate(); }, handleImportButtonClick: function() { this.setState({ importBusy: true }); this.props.mozLoop.startImport({ service: "google" }, (err, stats) => { this.setState({ importBusy: false }); if (err) { console.error("Contact import error", err); this.props.notifications.errorL10n("import_contacts_failure_message"); return; } this.props.notifications.successL10n("import_contacts_success_message", { num: stats.success, total: stats.success }); }); }, handleAddContactButtonClick: function() { this.props.switchToContactAdd(); }, handleContactAction: function(contact, actionName) { switch (actionName) { case "edit": this.props.switchToContactEdit(contact); break; case "remove": this.props.mozLoop.confirm({ message: mozL10n.get("confirm_delete_contact_alert"), okButton: mozL10n.get("confirm_delete_contact_remove_button"), cancelButton: mozL10n.get("confirm_delete_contact_cancel_button") }, (error, result) => { if (error) { throw error; } if (!result) { return; } this.props.mozLoop.contacts.remove(contact._guid, err => { if (err) { throw err; } }); }); break; case "block": case "unblock": // Invoke the API named like the action. this.props.mozLoop.contacts[actionName](contact._guid, err => { if (err) { throw err; } }); break; case "video-call": if (!contact.blocked) { this.props.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO); this.closeWindow(); } break; case "audio-call": if (!contact.blocked) { this.props.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY); this.closeWindow(); } break; default: console.error("Unrecognized action: " + actionName); break; } }, handleUseGravatar: function() { // We got permission to use Gravatar icons now, so we need to redraw the // list entirely to show them. this.refresh(); }, /* * Callback triggered when clicking the `X` from the contacts filter. * Clears the search query. */ _handleFilterClear: function() { this.setState({ filter: "" }); }, sortContacts: function(contact1, contact2) { let comp = contact1.name[0].localeCompare(contact2.name[0]); if (comp !== 0) { return comp; } // If names are equal, compare against unique ids to make sure we have // consistent ordering. return contact1._guid - contact2._guid; }, getCoordinates: function() { // Returns coordinates for use by child elements to place menus etc that are absolutely positioned var domNode = this.getDOMNode(); var domNodeRect = domNode.getBoundingClientRect(); return { "top": domNodeRect.top, "height": domNodeRect.height }; }, _renderFilterClearButton: function() { if (this.state.filter) { return (