fune/browser/components/loop/content/js/contacts.jsx

1030 lines
33 KiB
JavaScript

/* 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(
<a href={privacyUrl} target="_blank">
{mozL10n.get("gravatars_promo_message_learnmore")}
</a>
)
});
return (
<div className="contacts-gravatar-promo">
<Button additionalClass="button-close"
caption=""
onClick={this.handleCloseButtonClick} />
<p dangerouslySetInnerHTML={{__html: message}}
onClick={this.handleLinkClick}></p>
<div className="contacts-gravatar-avatars">
<img src="loop/shared/img/avatars.svg#orange-avatar" />
<span className="contacts-gravatar-arrow" />
<img src="loop/shared/img/firefox-avatar.svg" />
</div>
<ButtonGroup additionalClass="contacts-gravatar-buttons">
<Button additionalClass="secondary"
caption={mozL10n.get("gravatars_promo_button_nothanks2")}
onClick={this.handleCloseButtonClick}/>
<Button additionalClass="secondary"
caption={mozL10n.get("gravatars_promo_button_use2")}
onClick={this.handleUseButtonClick}/>
</ButtonGroup>
</div>
);
}
});
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 (
<ul className={dropdownClasses}>
<li className={cx({ "dropdown-menu-item": true,
"disabled": this.props.blocked,
"video-call-item": true })}
data-action="video-call"
onClick={this.onItemClick}>
{mozL10n.get("video_call_menu_button")}
</li>
<li className={cx({ "dropdown-menu-item": true,
"disabled": this.props.blocked,
"audio-call-item": true })}
data-action="audio-call"
onClick={this.onItemClick}>
{mozL10n.get("audio_call_menu_button")}
</li>
<li className={cx({ "dropdown-menu-item": true,
"disabled": !this.props.canEdit })}
data-action="edit"
onClick={this.onItemClick}>
{mozL10n.get("edit_contact_title")}
</li>
<li className="dropdown-menu-item"
data-action={blockAction}
onClick={this.onItemClick}>
{mozL10n.get(blockLabel)}
</li>
<li className={cx({ "dropdown-menu-item": true,
"disabled": !this.props.canEdit })}
data-action="remove"
onClick={this.onItemClick}>
{mozL10n.get("confirm_delete_contact_remove_button")}
</li>
</ul>
);
}
});
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 (
<li className={contactCSSClass}
onMouseLeave={this._handleMouseOut}>
<div className={avatarCSSClass}>
{avatarSrc ? <img src={avatarSrc} /> : null}
</div>
<div className="details">
<div className="username"><strong>{names.firstName}</strong> {names.lastName}
<i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
</div>
<div className="email">{email.value}</div>
</div>
<div className="icons">
<i className="icon icon-contact-video-call"
onClick={this.handleAction.bind(null, "video-call")} />
<i className="icon icon-vertical-ellipsis icon-contact-menu-button"
onClick={this.handleShowDropdownClick} />
</div>
{this.state.showMenu
? <ContactDropdown blocked={this.props.contact.blocked}
canEdit={this.canEdit()}
eventPosY={this.state.eventPosY}
getContainerCoordinates={this.props.getContainerCoordinates}
handleAction={this.handleAction} />
: null
}
</li>
);
}
});
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 (
<button className="clear-search"
onClick={this._handleFilterClear} />
);
}
return null;
},
_renderContactsFilter: function() {
if (this._shouldShowFilter()) {
return (
<div className="contact-filter-container">
<input className="contact-filter"
placeholder={mozL10n.get("contacts_search_placesholder2")}
valueLink={this.linkState("filter")} />
{this._renderFilterClearButton()}
</div>
);
}
return null;
},
_renderContactsList: function() {
let cx = React.addons.classSet;
let shownContacts = this._filterContactsList();
let viewForItem = item => {
return (
<ContactDetail contact={item}
getContainerCoordinates={this.getCoordinates}
handleContactAction={this.handleContactAction}
key={item._guid} />
);
};
// If no contacts to show and filter is set, then none match the search.
if (!shownContacts.available && !shownContacts.blocked &&
this.state.filter) {
return (
<div className="contact-search-list-empty">
<p className="panel-text-medium">
{mozL10n.get("contacts_no_search_results")}
</p>
</div>
);
}
// If no contacts to show and filter is not set, we don't have contacts.
if (!shownContacts.available && !shownContacts.blocked &&
!this.state.filter) {
return (
<div className="contact-list-empty-container">
{this._renderGravatarPromoMessage()}
<div className="contact-list-empty">
<p className="panel-text-large">
{mozL10n.get("no_contacts_message_heading2")}
</p>
<p className="panel-text-medium">
{mozL10n.get("no_contacts_import_or_add2")}
</p>
</div>
</div>
);
}
return (
<div className="contact-list-container">
{!this.state.filter ? <div className="contact-list-title">
{mozL10n.get("contact_list_title")}
</div> : null}
<div className="contact-list-wrapper">
{this._renderGravatarPromoMessage()}
<ul className="contact-list">
{shownContacts.available ?
shownContacts.available.sort(this.sortContacts).map(viewForItem) :
null}
{shownContacts.blocked && shownContacts.blocked.length > 0 ?
<div className="contact-separator">{mozL10n.get("contacts_blocked_contacts")}</div> :
null}
{shownContacts.blocked ?
shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
null}
</ul>
</div>
</div>
);
},
_renderAddContactButtons: function() {
let cx = React.addons.classSet;
if (this.state.filter) {
return null;
}
return (
<ButtonGroup additionalClass="contact-controls">
<Button additionalClass="secondary"
caption={this.state.importBusy ? mozL10n.get("importing_contacts_progress_button") :
mozL10n.get("import_contacts_button3")}
disabled={this.state.importBusy}
onClick={this.handleImportButtonClick} >
<div className={cx({"contact-import-spinner": true,
spinner: true,
busy: this.state.importBusy})} />
</Button>
<Button additionalClass="primary"
caption={mozL10n.get("new_contact_button2")}
onClick={this.handleAddContactButtonClick} />
</ButtonGroup>
);
},
_renderGravatarPromoMessage: function() {
if (this.state.filter) {
return null;
}
return (
<GravatarPromo handleUse={this.handleUseGravatar} />
);
},
render: function() {
return (
<div className="contacts-container">
{this._renderContactsFilter()}
{this._renderContactsList()}
{this._renderAddContactButtons()}
</div>
);
}
});
const ContactsControllerView = React.createClass({
propTypes: {
initialSelectedTabComponent: React.PropTypes.string,
mozLoop: React.PropTypes.object.isRequired,
notifications: React.PropTypes.object.isRequired
},
getInitialState: function() {
return {
currentComponent: this.props.initialSelectedTabComponent || "contactList",
contactFormData: {}
};
},
/* XXX We should have success/Fail callbacks that the children call instead of this
* Children should not have knowledge of other views
* However, this is being implemented in this way so the view can be directed appropriately
* without making it too complex
*/
switchComponentView: function(componentName) {
return function() {
this.setState({currentComponent: componentName});
}.bind(this);
},
handleAddEditContact: function(componentName) {
return function(contactFormData) {
this.setState({
contactFormData: contactFormData || {},
currentComponent: componentName
});
}.bind(this);
},
/* XXX Consider whether linkedStated makes sense for this */
render: function() {
switch(this.state.currentComponent) {
case "contactAdd":
return (
<ContactDetailsForm
contactFormData={this.state.contactFormData}
mode="add"
mozLoop={this.props.mozLoop}
ref="contacts_add"
switchToInitialView={this.switchComponentView("contactList")} />
);
case "contactEdit":
return (
<ContactDetailsForm
contactFormData={this.state.contactFormData}
mode="edit"
mozLoop={this.props.mozLoop}
ref="contacts_edit"
switchToInitialView={this.switchComponentView("contactList")} />
);
case "contactList":
default:
return (
<ContactsList
mozLoop={this.props.mozLoop}
notifications={this.props.notifications}
ref="contacts_list"
switchToContactAdd={this.handleAddEditContact("contactAdd")}
switchToContactEdit={this.handleAddEditContact("contactEdit")} />
);
}
}
});
const ContactDetailsForm = React.createClass({
mixins: [React.addons.LinkedStateMixin],
propTypes: {
contactFormData: React.PropTypes.object.isRequired,
mode: React.PropTypes.string,
mozLoop: React.PropTypes.object.isRequired,
switchToInitialView: React.PropTypes.func.isRequired
},
componentDidMount: function() {
this.initForm(this.props.contactFormData);
},
getInitialState: function() {
return {
contact: null,
pristine: true,
name: "",
email: "",
tel: ""
};
},
initForm: function(contact) {
let state = this.getInitialState();
// Test for an empty contact object
if (_.keys(contact).length > 0) {
state.contact = contact;
state.name = contact.name[0];
state.email = getPreferred(contact, "email").value;
state.tel = getPreferred(contact, "tel").value;
}
this.setState(state);
},
handleAcceptButtonClick: function() {
// Allow validity error indicators to be displayed.
this.setState({
pristine: false
});
let emailInput = this.refs.email.getDOMNode();
let telInput = this.refs.tel.getDOMNode();
if (!this.refs.name.getDOMNode().checkValidity() ||
((emailInput.required || emailInput.value) && !emailInput.checkValidity()) ||
((telInput.required || telInput.value) && !telInput.checkValidity())) {
return;
}
let contactsAPI = this.props.mozLoop.contacts;
switch (this.props.mode) {
case "edit":
this.state.contact.name[0] = this.state.name.trim();
setPreferred(this.state.contact, "email", this.state.email.trim());
setPreferred(this.state.contact, "tel", this.state.tel.trim());
contactsAPI.update(this.state.contact, err => {
if (err) {
throw err;
}
});
this.setState({
contact: null
});
break;
case "add":
var contact = {
id: this.props.mozLoop.generateUUID(),
name: [this.state.name.trim()],
email: [{
pref: true,
type: ["home"],
value: this.state.email.trim()
}],
category: ["local"]
};
var tel = this.state.tel.trim();
if (tel) {
contact.tel = [{
pref: true,
type: ["fxos"],
value: tel
}];
}
contactsAPI.add(contact, err => {
if (err) {
throw err;
}
});
break;
}
this.props.switchToInitialView();
},
handleCancelButtonClick: function() {
this.props.switchToInitialView();
},
render: function() {
let cx = React.addons.classSet;
let phoneOrEmailRequired = !this.state.email && !this.state.tel;
let contactFormMode = "contact-form-mode-" + this.props.mode;
let contentAreaClassesLiteral = {
"content-area": true,
"contact-form": true
};
contentAreaClassesLiteral[contactFormMode] = true;
let contentAreaClasses = cx(contentAreaClassesLiteral);
return (
<div className={contentAreaClasses}>
<header>{this.props.mode === "add"
? mozL10n.get("add_contact_title")
: mozL10n.get("edit_contact_title")}</header>
<div className={cx({"form-content-container": true})}>
<input className={cx({pristine: this.state.pristine})}
pattern="\s*\S.*"
placeholder={mozL10n.get("contact_form_name_placeholder")}
ref="name"
required
type="text"
valueLink={this.linkState("name")} />
<input className={cx({pristine: this.state.pristine})}
placeholder={mozL10n.get("contact_form_email_placeholder")}
ref="email"
required={phoneOrEmailRequired}
type="email"
valueLink={this.linkState("email")} />
<input className={cx({pristine: this.state.pristine})}
placeholder={mozL10n.get("contact_form_fxos_phone_placeholder")}
ref="tel"
required={phoneOrEmailRequired}
type="tel"
valueLink={this.linkState("tel")} />
</div>
<ButtonGroup>
<Button additionalClass="button-cancel"
caption={mozL10n.get("cancel_button")}
onClick={this.handleCancelButtonClick} />
<Button additionalClass="button-accept"
caption={this.props.mode === "add"
? mozL10n.get("add_contact_button")
: mozL10n.get("edit_contact_done_button")}
onClick={this.handleAcceptButtonClick} />
</ButtonGroup>
</div>
);
}
});
return {
ContactDropdown: ContactDropdown,
ContactsList: ContactsList,
ContactDetail: ContactDetail,
ContactDetailsForm: ContactDetailsForm,
ContactsControllerView: ContactsControllerView,
_getPreferred: getPreferred,
_setPreferred: setPreferred
};
})(_, document.mozL10n);