Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<meta name="theme-group" content="theme-communications">
<meta name="theme-color" content="var(--header-background)">
<title> Contacts </title>
<!-- Hack for avoiding 'glitch' when loading the view -->
<style type="text/css">
html, body {
background: #00b7b7 !important;
}
</style>
<!-- Building Blocks -->
<link href="/shared/style/input_areas.css" rel="stylesheet">
<link href="/shared/style/lists.css" rel="stylesheet">
<link href="/shared/style/scrolling.css" rel="stylesheet">
<link href="/shared/style/buttons.css" rel="stylesheet">
<link href="/shared/style/progress_activity.css" rel="stylesheet">
<link href="/shared/style/status.css" rel="stylesheet">
<link href="/shared/style/switches.css" rel="stylesheet">
<link href="/shared/style/confirm.css" rel="stylesheet">
<link href="/shared/style/action_menu.css" rel="stylesheet">
<link href="/shared/style/edit_mode.css" rel="stylesheet">
<link href="/shared/style/animations.css" rel="stylesheet">
<link href="/contacts/style/app.css" rel="stylesheet">
<link href="/contacts/style/contacts.css" rel="stylesheet">
<link href="/contacts/style/overlay.css" rel="stylesheet">
<link href="/shared/pages/import/style/search.css" rel="stylesheet">
<link href="/shared/pages/import/style/curtain_frame.css" rel="stylesheet">
<link href="/contacts/style/status.css" rel="stylesheet">
<link href="/contacts/style/fb_extensions.css" rel="stylesheet">
<link href="/shared/style/contacts.css" rel="stylesheet">
<link href="/shared/style/contacts/contacts_buttons.css" rel="stylesheet">
<link href="/contacts/style/match_service.css" rel="stylesheet">
<link href="/contacts/views/details/style/overwrite.css" rel="stylesheet">
<!-- <link rel="stylesheet" type="text/css" href="/shared/elements/gaia-icons/gaia-icons.css" /> -->
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<script defer src="/shared/js/component_utils.js"></script>
<script defer src="/shared/elements/gaia_subheader/script.js"></script>
<script defer src="/shared/elements/gaia-header/dist/gaia-header.js"></script>
<link rel="localization" href="/shared/elements/gaia_menu/locales/gaia_menu.{locale}.properties">
<link rel="localization" href="/shared/locales/telephony/telephony.{locale}.properties">
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src="/contacts/js/utilities/performance_helper.js"></script>
<script defer src="/contacts/js/cache.js"></script>
<script defer src='/contacts/views/details/js/boot.js'></script>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/contacts/locales/contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/import_contacts/import_contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/locales/phone_types/phone_types.{locale}.properties">
<link rel="import" href="/contacts/elements/details.html">
<link rel="import" href="/shared/elements/confirmation-message.html">
</head>
<body role="application" class="app-contacts skin-comms theme-communications hidden">
<section is="view-contact-details" id='view-contact-details' role="region" class="view view-contact-profile view-noscroll current"></section>
<iframe src="/shared/pages/import/curtain.html" id="iframe_curtain"></iframe>
<form is="confirmation-message" id="confirmation-message" class="hide" role="dialog" data-type="confirm"></form>
</body>
</html>

View file

@ -0,0 +1,63 @@
/* global DetailsUI, DetailsController, LazyLoader, ContactsService */
'use strict';
/*
* This class is the one in charge of loading the minimum set of
* resources needed for the view to load. Any other JS/CSS/Element
* not needed in the critical path *must* be lazy loaded when needed.
*
* Once localization and all the basic JS/CSS/Elements are loaded,
* we will initialize UI and Controller. Both JS classes *must* be
* independent and will communicate through events.
*/
window.addEventListener('DOMContentLoaded', function() {
LazyLoader.load(['/shared/js/l10n.js']).then(function() {
LazyLoader.load([
document.getElementById('view-contact-details')
]).then(function() {
// TODO Add if needed
});
});
});
window.onload = function() {
var dependencies = [
'/contacts/services/contacts.js',
'/shared/js/contact_photo_helper.js',
'/shared/js/contacts/contacts_buttons.js',
'/shared/js/text_normalizer.js',
'/shared/js/contacts/utilities/dom.js',
'/shared/js/contacts/utilities/templates.js',
'/shared/js/contacts/import/utilities/misc.js',
'/contacts/js/match_service.js',
'/contacts/views/details/js/details_ui.js',
'/contacts/views/details/js/details_controller.js',
'/shared/pages/import/js/curtain.js'
];
LazyLoader.load(dependencies).then(function() {
DetailsUI.init();
DetailsController.init();
window.addEventListener('renderdone', function fn() {
window.removeEventListener('renderdone', fn);
document.body.classList.remove('hidden');
});
navigator.mozSetMessageHandler('activity', activity => {
DetailsController.setActivity(activity);
var id = activity.source.data.params.id;
// TODO: Implement handler for open Vcards
ContactsService.get(id, function onSuccess(savedContact) {
ContactsService.getCount(count => {
DetailsUI.render(savedContact, count, true);
});
}, function onError() {
console.error('Error retrieving contact');
});
});
});
};

View file

@ -0,0 +1,165 @@
'use strict';
/* global utils */
/* global MatchService */
/* global ContactsService */
/* global LazyLoader */
/* global ContactToVcardBlob */
/* global VcardFilename */
/* global MozActivity */
/* exported Details */
/*
* Once the deatils view is loaded, we will listen events dispatched
* from the UI. This events will come with the info needed in order
* to execute actions related with the UI (back, toggle favorite, share...).
*
* Controller will *not* contain any code related with the DOM/UI,
* and will rely on the info provided by the event.
*/
(function(exports) {
var _activity = null;
function setActivity(activity) {
_activity = activity;
}
function findDuplicates(evt) {
if (!evt.detail || !evt.detail.contactId) {
console.error('Missing parameters in CustomEvent');
return;
}
var contactId = evt.detail.contactId;
MatchService.match(contactId);
}
function toggleFavorite(evt){
if (!evt.detail || typeof evt.detail.contact === 'undefined' ||
typeof evt.detail.isFavorite === 'undefined') {
console.error('Missing parameters in CustomEvent');
return;
}
var contact = evt.detail.contact;
var isFavorite = evt.detail.isFavorite;
var favorite = !isFavorite;
if (favorite) {
contact.category = contact.category || [];
contact.category.push('favorite');
} else {
if (!contact.category) {
return;
}
var pos = contact.category.indexOf('favorite');
if (pos > -1) {
contact.category.splice(pos, 1);
}
}
function onError(e) {
console.error('Error saving favorite');
// We must restore contact in order to update the UX accordingly
if (favorite) {
var pos = contact.category.indexOf('favorite');
if (pos > -1) {
contact.category.splice(pos, 1);
}
} else {
contact.category = contact.category || [];
contact.category.push('favorite');
}
dispatchEvent('toggleFavoriteDone', {contact: contact});
}
ContactsService.save(
utils.misc.toMozContact(contact),
function(e) {
if (typeof e !== 'undefined') {
onError(e);
return;
}
// TODO: Listening to oncontactchange event, we can save the evt
// and send it via sessionStorage to the main list in order to
// update it accordingly with the changes made by the user in this view.
ContactsService.get(contact.id, function(savedContact) {
dispatchEvent('toggleFavoriteDone', {contact: savedContact});
}, onError);
}
);
}
function init() {
window.addEventListener('backAction', handleBackAction);
window.addEventListener('editAction', handleEditAction);
window.addEventListener('toggleFavoriteAction', toggleFavorite);
window.addEventListener('shareAction', shareContact);
window.addEventListener('findDuplicatesAction', findDuplicates);
// TODO: Need to save the oncontactchange event in order to update
// the main list when the user go back from this view. It could be done
// by saving the event in sessionStorage
}
function shareContact(evt) {
const VCARD_DEPS = [
'/shared/js/text_normalizer.js',
'/shared/js/contact2vcard.js',
'/shared/js/setImmediate.js'
];
if (!evt.detail || typeof evt.detail.contact === 'undefined') {
console.error('Missing parameters in CustomEvent');
return;
}
var contact = evt.detail.contact;
LazyLoader.load(VCARD_DEPS,function vcardLoaded() {
ContactToVcardBlob([contact], function blobReady(vcardBlob) {
VcardFilename(contact).then(filename => {
/* jshint nonew: false */
new MozActivity({
name: 'share',
data: {
type: 'text/vcard',
number: 1,
blobs: [new window.File([vcardBlob], filename, {
type: 'text/x-vcard'
})],
filenames: [filename]
}
});
});
// The MIME of the blob should be this for some MMS gateways
}, { type: 'text/x-vcard'} );
});
}
function handleBackAction(evt) {
if (_activity) {
_activity.postResult({});
} else {
window.history.back();
}
}
function handleEditAction(evt) {
// In the future the navigation will change the URL to navigate
// to #update view: Bug 1169579
}
function dispatchEvent(name, data) {
window.dispatchEvent(new CustomEvent(name, {detail: data}));
}
exports.DetailsController = {
'init': init,
'setActivity': setActivity
};
})(window);

View file

@ -0,0 +1,490 @@
/* global ContactsButtons, LazyLoader, utils, WebrtcClient */
/* global Normalizer, ContactPhotoHelper */
/* exported DetailsUI */
/*
* UI *must* work alone, without any 'Controller' tied. This will
* help us to have a well-separated model, where the UI will dispatch
* events that a Controller could potentially handle.
*
* UI will be in charge of rendering the info provided by 'Boot', which
* is the only part of the code which knows UI & Controller.
*
* Every action performed by the user in the UI will dispatch an event that
* should be handle by a controller
*/
(function(exports) {
'use strict';
const PHOTO_POS = 7;
const INIT_MARGIN = 8;
const MAX_POSITION = 150;
// Scale ratio for different devices
var SCALE_RATIO = window.devicePixelRatio || 1;
var DetailsUI = {
init: function() {
var self = this;
function initContainers() {
self.header = document.querySelector('#details-view-header');
self.contactDetails = document.querySelector('#contact-detail');
self.listContainer = document.querySelector('#details-list');
self.detailsName = document.querySelector('#contact-name-title');
self.detailsNameText =
document.querySelector('#contact-name-title bdi');
self.orgTitle = document.querySelector('#org-title');
self.datesTemplate = document.querySelector('#dates-template-\\#i\\#');
self.addressesTemplate =
document.querySelector('#address-details-template-\\#i\\#');
self.duplicateTemplate =
document.querySelector('#duplicate-contacts-template');
self.editContactButton = document.querySelector('#edit-contact-button');
self.cover = document.querySelector('#cover-img');
self.detailsInner = document.querySelector('#contact-detail-inner');
self.favoriteMessage = document.querySelector('#toggle-favorite');
self.notesTemplate =
document.querySelector('#note-details-template-\\#i\\#');
self.socialTemplate =
document.querySelector('#social-template-\\#i\\#');
}
function addListeners() {
self.initPullEffect(self.cover);
self.favoriteMessage.addEventListener('click',
self.toggleFavorite.bind(self));
self.header.addEventListener('action',
self.handleBackAction.bind(self));
self.editContactButton.addEventListener('click',
self.handleEditAction.bind(self));
ContactsButtons.init(self.listContainer, self.contactDetails, null);
window.addEventListener('toggleFavoriteDone',
self.toggleFavoriteHandler.bind(self));
}
initContainers();
addListeners();
},
handleBackAction: function(evt) {
this.dispatchEvent('backAction');
},
handleEditAction: function(evt) {
this.dispatchEvent('editAction');
},
initPullEffect: function(cover) {
var maxPosition = Math.round(MAX_POSITION * SCALE_RATIO);
var startPosition = 0;
var self = this;
function onTouchStart(e) {
if (self.contactDetails.classList.contains('no-photo')) {
return;
}
e.preventDefault();
startPosition = e.changedTouches[0].clientY;
self.contactDetails.classList.add('up');
cover.classList.add('up');
window.addEventListener('touchmove', onTouchMove, true);
window.addEventListener('touchend', onTouchEnd, true);
}
function onTouchEnd(e) {
e.preventDefault();
self.contactDetails.style.transform = null;
self.contactDetails.classList.add('up');
cover.style.transform = null;
cover.classList.add('up');
window.removeEventListener('touchmove', onTouchMove, true);
window.removeEventListener('touchend', onTouchEnd, true);
}
function onTouchMove(e) {
e.preventDefault();
var deltaY = e.changedTouches[0].clientY - startPosition;
deltaY = Math.min(maxPosition, Math.max(0, deltaY));
var calc = 'calc(' + INIT_MARGIN + 'rem + ' + deltaY + 'px)';
self.contactDetails.style.transform = 'translateY(' + calc + ')';
self.contactDetails.classList.remove('up');
// Divide by 40 (4 times slower and in rems)
var coverPosition = (-PHOTO_POS + (deltaY / 40)) + 'rem';
cover.style.transform = 'translateY(' + coverPosition + ')';
cover.classList.remove('up');
}
cover.addEventListener('touchstart', onTouchStart, true);
},
render: function(currentContact, contactsCount, readOnly) {
if(this.isAFavoriteChange){
this.isAFavoriteChange = false;
return Promise.resolve(this.isAFavoriteChange);
}
this.dispatchEvent('renderinit');
this.contactData = currentContact;
this.contactsCount = contactsCount;
// Initially enabled and only disabled if necessary
this.editContactButton.removeAttribute('disabled');
this.editContactButton.classList.remove('hide');
this.header.setAttribute('action', 'back');
if (readOnly) {
this.editContactButton.classList.add('hide');
this.header.setAttribute('action', 'close');
}
this.doReloadContactDetails(this.contactData);
},
doReloadContactDetails: function(contact) {
this.setupDisplayName(contact);
this.contactDetails.classList.remove('no-photo');
this.contactDetails.classList.remove('up');
utils.dom.removeChildNodes(this.listContainer);
this.renderFavorite(contact);
this.renderOrg(contact);
ContactsButtons.renderPhones(contact);
ContactsButtons.renderEmails(contact);
this.renderWebrtcClient(contact);
this.renderAddresses(contact);
this.renderDates(contact);
this.renderNotes(contact);
this.renderShareButton(contact);
this.renderDuplicate(contact);
this.renderPhoto(contact);
this.dispatchEvent('renderdone');
},
setupDisplayName: function(contact) {
var name = this.getDisplayName(contact);
if (typeof name === 'string') {
this.detailsNameText.setAttribute('data-l10n-id', name);
} else if (name.raw) {
this.detailsNameText.textContent = name.raw;
} else if (name.id) {
navigator.mozL10n.setAttributes(this.detailsNameText,
name.id, name.args);
}
},
getDisplayName: function(contact) {
var l10nId = 'noName';
if (this.hasName(contact)) {
l10nId = {'raw': contact.name[0]};
} else if (this.hasContent(contact.tel)) {
l10nId = {'raw': contact.tel[0].value};
} else if (this.hasContent(contact.email)) {
l10nId = {'raw': contact.email[0].value};
}
return l10nId;
},
hasContent: function(field) {
return (Array.isArray(field) &&
field.length > 0 &&
field[0].value &&
field[0].value.trim());
},
hasName: function(contact) {
return (Array.isArray(contact.name) &&
contact.name[0] &&
contact.name[0].trim());
},
renderFavorite: function(contact) {
var favorite = this.isFavorite();
this.toggleFavoriteMessage(favorite);
this.header.classList.toggle('favorite', !!favorite);
},
toggleFavorite: function() {
var contact = this.contactData;
var favorite = !this.isFavorite();
this.toggleFavoriteMessage(favorite);
// Disabling button while saving the contact
this.favoriteMessage.style.pointerEvents = 'none';
this.dispatchEvent('toggleFavoriteAction',
{contact: contact, isFavorite: !favorite});
},
toggleFavoriteMessage: function(isFav) {
var cList = this.favoriteMessage.classList;
var l10nId = isFav ? 'removeFavorite' : 'addFavorite';
this.favoriteMessage.setAttribute('data-l10n-id', l10nId);
isFav ? cList.add('on') : cList.remove('on');
},
toggleFavoriteHandler: function(evt) {
var savedContact = evt.detail.contact;
this.favoriteMessage.style.pointerEvents = 'auto';
this.setContact(savedContact);
this.renderFavorite(savedContact);
this.isAFavoriteChange = true;
},
setContact: function(currentContact) {
this.contactData = currentContact;
},
renderOrg: function(contact) {
if (contact.org && contact.org.length > 0 && contact.org[0] !== '') {
this.orgTitle.textContent = contact.org[0];
this.orgTitle.classList.remove('hide');
} else {
this.orgTitle.classList.add('hide');
this.orgTitle.textContent = '';
}
},
renderWebrtcClient: function(contact) {
LazyLoader.load([
'/contacts/style/webrtc-client/webrtc_client.css',
'/contacts/js/webrtc-client/webrtc_client.js'
]).then(function() {
WebrtcClient.start(contact);
});
},
renderAddresses: function(contact) {
if (!contact.adr) {
return;
}
// Load what we need
LazyLoader.load('/contacts/js/utilities/mozContact.js', () => {
for (var i = 0; i < contact.adr.length; i++) {
var currentAddress = contact.adr[i];
// Sanity check
if (utils.mozContact.haveEmptyFields(currentAddress,
['streetAddress', 'postalCode', 'locality', 'countryName'])) {
continue;
}
var address = currentAddress.streetAddress || '';
var escapedStreet = Normalizer.escapeHTML(address, true);
var locality = currentAddress.locality;
var escapedLocality = Normalizer.escapeHTML(locality, true);
var country = currentAddress.countryName || '';
var escapedCountry = Normalizer.escapeHTML(country, true);
var postalCode = currentAddress.postalCode || '';
var escapedPostalCode = Normalizer.escapeHTML(postalCode, true);
var addressField = {
streetAddress: escapedStreet,
postalCode: escapedPostalCode,
locality: escapedLocality || '',
countryName: escapedCountry,
'type_l10n_id': currentAddress.type,
i: i
};
var template = utils.templates.render(this.addressesTemplate,
addressField);
this.listContainer.appendChild(template);
}
});
},
renderDates: function(contact) {
if (!contact.bday && !contact.anniversary) {
return;
}
var dateString = '';
var fields = ['bday', 'anniversary'];
var l10nIds = ['birthday', 'anniversary'];
var rendered = 0;
for (var j = 0; j < fields.length; j++) {
var value = contact[fields[j]];
if (!value) {
continue;
}
try {
dateString = utils.misc.formatDate(value);
} catch (err) {
console.error('Error parsing date');
continue;
}
var element = utils.templates.render(this.datesTemplate, {
i: ++rendered,
date: dateString,
'type_l10n_id': l10nIds[j]
});
this.listContainer.appendChild(element);
}
},
renderNotes: function(contact) {
if (!contact.note || contact.note.length === 0) {
return;
}
var container = document.createElement('li');
var title = document.createElement('h2');
title.setAttribute('data-l10n-id', 'comments');
container.appendChild(title);
for (var i = 0; i < contact.note.length; i++) {
var currentNote = contact.note[i];
var noteField = {
note: Normalizer.escapeHTML(currentNote, true) || '',
i: i
};
var template = utils.templates.render(this.notesTemplate, noteField);
container.appendChild(template);
this.listContainer.appendChild(container);
}
},
renderShareButton: function(contact) {
var social = utils.templates.render(this.socialTemplate, {
i: contact.id
});
var shareButton = social.querySelector('#share_button');
shareButton.addEventListener('click', () => {
this.dispatchEvent('shareAction', {contact: this.contactData});
});
shareButton.classList.remove('hide');
this.listContainer.appendChild(social);
},
renderDuplicate: function(contact) {
var dupItem = utils.templates.render(this.duplicateTemplate, {});
var findMergeButton = dupItem.querySelector('#find-merge-button');
findMergeButton.disabled = true;
if (this.contactsCount > 1) {
// Only have this active if contact list has more than one entry
findMergeButton.disabled = false;
findMergeButton.addEventListener('click', () => {
this.dispatchEvent('findDuplicatesAction',
{contactId: this.contactData.id});
});
}
this.listContainer.appendChild(dupItem);
},
renderPhoto: function(contact) {
this.contactDetails.classList.remove('up');
var photo = ContactPhotoHelper.getFullResolution(contact);
if (photo) {
var currentHash = this.cover.dataset.imgHash;
if (!currentHash) {
utils.dom.updatePhoto(photo, this.cover);
this.updateHash(photo, this.cover);
}
else {
// Need to recalculate the hash and see whether the images changed
this.calculateHash(photo, newHash => {
if (currentHash !== newHash) {
utils.dom.updatePhoto(photo, this.cover);
this.cover.dataset.imgHash = newHash;
}
else {
// Only for testing purposes
this.cover.dataset.photoReady = 'true';
}
});
}
this.contactDetails.classList.add('up');
this.cover.classList.add('translated');
this.contactDetails.classList.add('translated');
var clientHeight = this.contactDetails.clientHeight -
(INIT_MARGIN * 10 * SCALE_RATIO);
if (this.detailsInner.offsetHeight < clientHeight) {
this.cover.style.overflow = 'hidden';
} else {
this.cover.style.overflow = 'auto';
}
} else {
this.resetPhoto();
}
},
updateHash: function(photo, cover) {
this.calculateHash(photo, function(hash) {
cover.dataset.imgHash = hash;
});
},
calculateHash: function(photo, cb) {
var START_BYTES = 127;
var BYTES_HASH = 16;
var out = [photo.type, photo.size];
// We skip the first bytes that typically are headers
var chunk = photo.slice(START_BYTES, START_BYTES + BYTES_HASH);
var reader = new FileReader();
reader.onloadend = function() {
out.push(reader.result);
cb(out.join(''));
};
reader.onerror = function() {
window.console.error('Error while calculating the hash: ',
reader.error.name);
cb(out.join(''));
};
reader.readAsDataURL(chunk);
},
resetPhoto: function() {
this.cover.classList.remove('translated');
this.contactDetails.classList.remove('translated');
this.cover.style.backgroundImage = '';
this.cover.style.overflow = 'auto';
this.contactDetails.style.transform = '';
this.contactDetails.classList.add('no-photo');
this.cover.dataset.imgHash = '';
},
isFavorite: function() {
return this.contactData != null && this.contactData.category != null &&
this.contactData.category.indexOf('favorite') != -1;
},
dispatchEvent: function(name, data) {
window.dispatchEvent(new CustomEvent(name, {detail: data}));
}
};
exports.DetailsUI = DetailsUI;
})(window);

View file

@ -0,0 +1,7 @@
body * {
transition: opacity 0.3s ease;
}
body.hidden * {
opacity: 0;
}

View file

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<meta name="theme-group" content="theme-communications">
<meta name="theme-color" content="var(--header-background)">
<title> Contacts </title>
<!-- Hack for avoiding 'glitch' when loading the view -->
<style type="text/css">
html, body {
background: #00b7b7 !important;
}
</style>
<!-- Building Blocks -->
<link href="/shared/style/input_areas.css" rel="stylesheet">
<link href="/shared/style/lists.css" rel="stylesheet">
<link href="/shared/style/scrolling.css" rel="stylesheet">
<link href="/shared/style/buttons.css" rel="stylesheet">
<link href="/shared/style/progress_activity.css" rel="stylesheet">
<link href="/shared/style/status.css" rel="stylesheet">
<link href="/shared/style/switches.css" rel="stylesheet">
<link href="/shared/style/confirm.css" rel="stylesheet">
<link href="/shared/style/action_menu.css" rel="stylesheet">
<link href="/shared/style/edit_mode.css" rel="stylesheet">
<link href="/shared/style/animations.css" rel="stylesheet">
<link href="/contacts/style/app.css" rel="stylesheet">
<link href="/contacts/style/contacts.css" rel="stylesheet">
<link href="/contacts/style/overlay.css" rel="stylesheet">
<link href="/shared/pages/import/style/search.css" rel="stylesheet">
<link href="/shared/pages/import/style/curtain_frame.css" rel="stylesheet">
<link href="/contacts/style/status.css" rel="stylesheet">
<link href="/contacts/style/fb_extensions.css" rel="stylesheet">
<link href="/shared/style/contacts.css" rel="stylesheet">
<link href="/shared/style/contacts/contacts_buttons.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<script defer src="/shared/js/component_utils.js"></script>
<script defer src="/shared/elements/gaia_subheader/script.js"></script>
<script defer src="/shared/elements/gaia-header/dist/gaia-header.js"></script>
<!-- Localization -->
<link rel="localization" href="/shared/elements/gaia_menu/locales/gaia_menu.{locale}.properties">
<link rel="localization" href="/shared/elements/gaia_sim_picker/locales/gaia_sim_picker.{locale}.properties">
<script defer src="/shared/js/html_imports.js"></script>
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src="/contacts/js/utilities/performance_helper.js"></script>
<script defer src='/contacts/views/form/js/boot.js'></script>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/contacts/locales/contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/import_contacts/import_contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/locales/phone_types/phone_types.{locale}.properties">
<!-- Some CSS magic needed to work in a separate html -->
<link href="/contacts/views/form/style/overwrite.css" rel="stylesheet">
<!-- Load just the templates needed -->
<link rel="import" href="/contacts/elements/tag.html">
<link rel="import" href="/contacts/elements/form.html">
</head>
<body role="application" class="app-contacts skin-comms theme-communications">
<section is="view-contact-form" id="view-contact-form" role="region" class="view view-bottom view-edit-contact"></section>
<form id="action-menu" role="dialog" data-type="action" class="hide">
<header id="org-title" data-l10n-id="select_recipient"> Select recipient </header>
<menu id="value-menu" type="toolbar">
</menu>
</form>
<section is="view-select-tag" id="view-select-tag" role="region" class="view view-right view-select-tag"></section>
<form id="action-menu" role="dialog" data-type="action" class="hide">
<header id="org-title" data-l10n-id="select_recipient"> Select recipient </header>
<menu id="value-menu" type="toolbar">
</menu>
</form>
</body>
</html>

View file

@ -0,0 +1,60 @@
/* global LazyLoader, FormUI, FormController */
'use strict';
/*
* This class is the one in charge of loading the minimum set of
* resources needed for the view to load. Any other JS/CSS/Element
* not needed in the critical path *must* be lazy loaded when needed.
*
* Once localization and all the basic JS/CSS/Elements are loaded,
* we will initialize UI and Controller. Both JS classes *must* be
* independent and will communicate through events.
*/
window.addEventListener('DOMContentLoaded', function() {
LazyLoader.load([
'/shared/js/l10n.js'], function() {
LazyLoader.load([
document.getElementById('view-contact-form')
], function() {
// TODO Add if needed
});
});
});
window.addEventListener('load', function() {
var dependencies = [
'/contacts/js/navigation.js',
'/contacts/services/contacts.js',
'/shared/js/contact_photo_helper.js',
'/shared/js/contacts/utilities/templates.js',
'/contacts/js/contacts_tag.js',
'/contacts/js/tag_options.js',
'/shared/js/text_normalizer.js',
'/shared/js/contacts/import/utilities/status.js',
'/shared/js/contacts/utilities/dom.js',
'/shared/js/contacts/import/utilities/misc.js',
'/contacts/views/form/js/form_ui.js',
'/contacts/views/form/js/form_controller.js',
'/contacts/views/form/js/main_navigation.js'
];
LazyLoader.load(
dependencies,
function() {
FormUI.init();
FormController.init();
navigator.mozSetMessageHandler(
'activity',
function(activity) {
FormController.setActivity(
activity
);
FormUI.render(
activity.source.data.params
);
}
);
}
);
});

View file

@ -0,0 +1,217 @@
/* global LazyLoader, MergeHelper, ContactsService, utils,
Matcher, MatchService */
/*
* Once critical path is loaded, we will listen events dispatched
* from the UI. This events will come with the info needed in order
* to execute actions related with the UI (close, save, update...).
*
* Controller will *not* contain any code related with the DOM/UI,
* and will rely on the info provided by the event.
*/
(function(exports) {
'use strict';
var _;
var _activity;
const CONTACTS_APP_ORIGIN = location.origin;
function close() {
if (_activity) {
_activity.postResult({});
} else {
window.history.back();
}
}
function hasName(contact) {
return (Array.isArray(contact.givenName) && contact.givenName[0] &&
contact.givenName[0].trim()) ||
(Array.isArray(contact.familyName) && contact.familyName[0] &&
contact.familyName[0].trim());
}
function doMerge(contact, list, cb) {
var callbacks = {
success: cb,
error: function(e) {
console.error('Failed merging duplicate contacts: ', e.name);
cb();
}
};
LazyLoader.load('/contacts/js/utilities/merge_helper.js', function() {
MergeHelper.merge(contact, list).then(callbacks.success, callbacks.error);
});
}
function getCompleteName(contact) {
var givenName = Array.isArray(contact.givenName) ?
contact.givenName[0] : '';
var familyName = Array.isArray(contact.familyName) ?
contact.familyName[0] : '';
var completeName = givenName && familyName ?
givenName + ' ' + familyName :
givenName || familyName;
return completeName;
}
// Fills the contact data to display if no givenName and familyName
function getDisplayName(contact) {
if (hasName(contact)) {
return { givenName: contact.givenName, familyName: contact.familyName };
}
// TODO Remove l10n deprecated methods:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1181586
var givenName = [];
if (Array.isArray(contact.name) && contact.name.length > 0) {
givenName.push(contact.name[0]);
} else if (contact.org && contact.org.length > 0) {
givenName.push(contact.org[0]);
} else if (contact.tel && contact.tel.length > 0) {
givenName.push(contact.tel[0].value);
} else if (contact.email && contact.email.length > 0) {
givenName.push(contact.email[0].value);
} else {
givenName.push(_('noName'));
}
return { givenName: givenName, modified: true };
}
function doMatch(contact, callbacks) {
LazyLoader.load(['/shared/js/text_normalizer.js',
'/shared/js/simple_phone_matcher.js',
'/shared/js/contacts/contacts_matcher.js'], function() {
Matcher.match(contact, 'active', callbacks);
});
}
var mergeHandler;
function cookMatchingCallbacks(contact) {
return {
onmatch: function(results) {
MatchService.showDuplicateContacts();
mergeHandler = function mergeHandler(e) {
if (e.origin !== CONTACTS_APP_ORIGIN) {
return;
}
var data = e.data;
switch (data.type) {
case 'duplicate_contacts_loaded':
// UI ready, passing duplicate contacts
var duplicateContacts = {};
Object.keys(results).forEach(function(id) {
duplicateContacts[id] = {
matchingContactId: id,
matchings: results[id].matchings
};
});
window.postMessage({
type: 'show_duplicate_contacts',
data: {
name: getCompleteName(getDisplayName(contact)),
duplicateContacts: duplicateContacts
}
}, CONTACTS_APP_ORIGIN);
break;
case 'merge_duplicate_contacts':
window.removeEventListener('message', mergeHandler);
// List of duplicate contacts to merge (identifiers)
var list = [];
var ids = data.data;
Object.keys(ids).forEach(id => {
list.push(results[id]);
});
doMerge(contact, list, function finished() {
window.postMessage({
type: 'duplicate_contacts_merged',
data: ids
}, CONTACTS_APP_ORIGIN);
close();
});
break;
case 'ready':
// TODO Use if needed
break;
case 'window_close':
// If user igonores duplicate contacts we save the contact
window.removeEventListener('message', mergeHandler);
doSave(contact, true);
break;
}
};
window.addEventListener('message', mergeHandler);
},
onmismatch: function() {
// Saving because there aren't duplicate contacts
doSave(contact);
}
};
}
function doSave(contact) {
ContactsService.addListener('contactchange',
function oncontactchange(event) {
sessionStorage.setItem('contactID', event.contactID);
sessionStorage.setItem('reason', event.reason);
ContactsService.removeListener('contactchange', oncontactchange);
close();
}
);
ContactsService.save(contact, function(error) {
// Use if needed.
});
}
exports.FormController = {
init: function() {
_ = navigator.mozL10n.get;
window.addEventListener('close-ui', function() {
close();
});
function saveContactHandler(event) {
var contact = utils.misc.toMozContact(event.detail);
LazyLoader.load(
[
'/contacts/style/match_service.css',
'/contacts/js/match_service.js'
],
function() {
var callbacks = cookMatchingCallbacks(contact);
doMatch(contact, callbacks);
}
);
}
window.addEventListener(
'save-contact',
saveContactHandler
);
},
setActivity: function(activity) {
_activity = activity;
}
};
}(window));

View file

@ -0,0 +1,803 @@
/* global ContactPhotoHelper */
/* global ContactsTag */
/* global LazyLoader */
/* global Normalizer */
/* global utils */
/* global TAG_OPTIONS */
/* global ActionMenu */
/* global TagSelector */
/* global PhotoPicker */
/*
* UI *must* work alone, without any 'Controller' tied. This will
* help us to have a well-separated model, where the UI will dispatch
* events that a Controller could potentially handle.
*
* UI will be in charge of rendering the info provided by 'Boot', which
* is the only part of the code which knows UI & Controller.
*/
(function(exports) {
'use strict';
var counters = {
'tel': 0,
'email': 0,
'adr': 0,
'date': 0,
'note': 0
};
// Cached elements that we will keep in memory
var contactForm,
deleteContactButton,
addNewDateButton,
thumb,
thumbAction,
saveButton,
formHeader,
formTitle,
givenName,
company,
familyName,
formView;
var _, currentPhoto;
var configs;
function initConfigs() {
// Keep in memory the template configuration
configs = {
'tel': {
template: document.getElementById('add-phone-#i#'),
tags: TAG_OPTIONS['phone-type'],
fields: ['value', 'type', 'carrier'],
container: document.getElementById('contacts-form-phones')
},
'email': {
template: document.getElementById('add-email-#i#'),
tags: TAG_OPTIONS['email-type'],
fields: ['value', 'type'],
container: document.getElementById('contacts-form-emails')
},
'adr': {
template: document.getElementById('add-address-#i#'),
tags: TAG_OPTIONS['address-type'],
fields: [
'type',
'streetAddress',
'postalCode',
'locality',
'countryName'
],
container: document.getElementById('contacts-form-addresses')
},
'date': {
template: document.getElementById('add-date-#i#'),
tags: TAG_OPTIONS['date-type'],
fields: ['value', 'type'],
container: document.getElementById('contacts-form-dates')
},
'note': {
template: document.getElementById('add-note-#i#'),
tags: TAG_OPTIONS['address-type'],
fields: ['note'],
container: document.getElementById('contacts-form-notes')
}
};
}
// Let's get the right event for the current device
var touchstart = 'ontouchstart' in window ? 'touchstart' : 'mousedown';
const INVALID_CLASS = 'invalid';
const DELETE_BUTTON_CLASS = 'img-delete-button';
function initContainers() {
deleteContactButton = document.querySelector('#delete-contact');
thumb = document.querySelector('#thumbnail-photo');
thumbAction = document.querySelector('#thumbnail-action');
saveButton = document.querySelector('#save-button');
addNewDateButton = document.querySelector('#add-new-date');
contactForm = document.getElementById('contact-form');
formHeader = document.querySelector('#contact-form-header');
formTitle = document.getElementById('contact-form-title');
givenName = document.getElementById('givenName');
company = document.getElementById('org');
familyName = document.getElementById('familyName');
formView = document.getElementById('view-contact-form');
}
function closeHandler() {
window.dispatchEvent(new Event('close-ui'));
}
function addFieldHandler(e) {
e.preventDefault();
e.stopPropagation();
if (e.target.dataset && e.target.dataset.action === 'delete') {
return;
}
if (e.target.dataset && e.target.dataset.fieldType) {
var type = e.target.dataset.fieldType;
insertField(
type,
null,
true
);
}
}
function saveHandler() {
var contact = {};
var inputs = { givenName, familyName };
for (var field in inputs) {
var value = inputs[field].value;
if (value && value.length > 0) {
contact[field] = [value];
} else {
contact[field] = null;
}
}
if (company.value && company.value.length > 0) {
contact.org = [company.value];
}
contact.email = getEmails();
contact.tel = getPhones();
contact.adr = getAddresses();
contact.note = getNotes();
var dates = getDates();
contact.bday = dates.bday;
contact.anniversary = dates.anniversary;
contact.name = getName(contact);
if (currentPhoto) {
contact.photo = [currentPhoto];
}
var customEvent = new CustomEvent('save-contact', {'detail': contact});
window.dispatchEvent(customEvent);
}
function addListeners() {
thumbAction.querySelector('#photo-button').onclick = photoAction;
document.addEventListener('input', function input(event) {
checkDisableButton();
});
contactForm.addEventListener(touchstart, function click(event) {
// Filter any other 'hit' in the DOM
var target = event.target;
if (target.tagName !== 'BUTTON') {
return;
}
var action = target.dataset.action || target.getAttribute('type');
switch(action) {
case 'reset':
event.preventDefault();
var input = target.previousElementSibling;
if (input.getAttribute('name').startsWith('tel') &&
input.dataset.field === 'value') {
var telId = input.id;
var telIndex = telId.substring(telId.indexOf('_') + 1);
var carrierField =
document.getElementById('carrier' + '_' + telIndex);
carrierField.classList.add(INVALID_CLASS);
cachedInputs = null;
}
input.value = '';
checkDisableButton();
break;
case 'delete':
event.preventDefault();
event.stopPropagation();
var type = target.dataset.type;
var selector = target.dataset.selector;
var container = configs[type].container;
var elem = document.getElementById(selector);
if (type !== 'photo') {
// Remove field
elem.parentNode.removeChild(elem);
// Show empty if no more fields of the same type are available
if (container.querySelectorAll('[data-field]').length === 0) {
container.classList.add('empty');
}
}
counters[type]--;
// In this version only two dates are allowed
if (type === 'date') {
checkAddDateButton();
}
cachedInputs = null;
// textFieldsCache.clear();
checkDisableButton();
break;
}
});
thumbAction.addEventListener(touchstart, function click(event) {
// Removing current photo
if (event.target.tagName === 'BUTTON') {
saveButton.removeAttribute('disabled');
}
});
// 'ValueModified' comes from the 'TagSelector', and will return a value
// from a set of Tags.
formView.addEventListener('ValueModified', function onValueModified(event) {
if (!event.detail) {
return;
}
if (!emptyForm() && event.detail.prevValue !== event.detail.newValue) {
saveButton.removeAttribute('disabled');
}
});
formHeader.addEventListener('action', closeHandler);
saveButton.addEventListener('click', saveHandler);
contactForm.addEventListener('click', addFieldHandler);
}
function init() {
// Cache l10n functionality
_ = navigator.mozL10n.get;
// Cache all DOM elements and reuse them
initContainers();
// Cache configuration for templates
initConfigs();
// Add listeners to DOM elements
addListeners();
}
// Renders the birthday as per the locale
function renderDate(date, bdayInputText) {
if (!date) {
return;
}
bdayInputText.textContent = utils.misc.formatDate(date);
bdayInputText.classList.remove('placeholder');
}
function onInputDate(bdayInputText, e) {
renderDate(e.target.valueAsDate, bdayInputText);
}
function extractValue(value) {
if (value) {
if (Array.isArray(value) && value.length > 0 && value[0]) {
return value[0].trim();
} else if (!Array.isArray(value)) {
return value.trim();
}
}
return '';
// The last statement allows to return the value in case we are getting
// just params from an activity and not a mozContact.
}
function fillDates(contact) {
if (contact.bday) {
contact.date = [];
contact.date.push({
type: 'birthday',
value: contact.bday
});
}
if (contact.anniversary) {
contact.date = contact.date || [];
contact.date.push({
type: 'anniversary',
value: contact.anniversary
});
}
}
function renderPhoto(contact) {
if (contact.photo && contact.photo.length > 0) {
currentPhoto = ContactPhotoHelper.getFullResolution(contact);
appendRemovePhotoButton();
}
utils.dom.updatePhoto(currentPhoto, thumb);
}
function render(params) {
formView.classList.remove('skin-organic');
saveButton.setAttribute('disabled', 'disabled');
saveButton.setAttribute('data-l10n-id', 'done');
deleteContactButton.parentNode.classList.add('hide');
formTitle.setAttribute('data-l10n-id', 'addContact');
params = params || {};
givenName.value = extractValue(params.givenName);
familyName.value = extractValue(params.lastName || params.familyName);
company.value = extractValue(params.company || params.org);
renderPhoto(params);
fillDates(params);
['tel', 'email', 'adr', 'date', 'note'].forEach(function(field) {
params[field] && renderTemplate(field, params[field]);
});
checkDisableButton();
}
/**
* Render Template
*
* @param {string} type Type of template, eg. 'tel'
* @param {object[]} toRender
*/
function renderTemplate(type, toRender) {
if (!Array.isArray(toRender)) {
toRender = [{value: toRender}];
}
for (var i = 0; i < toRender.length; i++) {
insertField(type, toRender[i] || {});
}
}
function checkAddDateButton() {
addNewDateButton.disabled = (counters.date >= 2);
}
function checkCarrierTel(carrierInput, event) {
var telInput = event.target;
var value = telInput.value;
if (!value || !value.trim()) {
// deletedTelNumber = true;
// If it was not previously filled then it will be disabled
if (!telInput.dataset.wasFilled) {
carrierInput.setAttribute('disabled', 'disabled');
}
else {
// Otherwise marked as invalid in order not to submit it
carrierInput.classList.add(INVALID_CLASS);
cachedInputs = null;
// textFieldsCache.clear();
}
}
else {
// Marked as filled
telInput.dataset.wasFilled = true;
// Enabling and marking as valid
carrierInput.removeAttribute('disabled');
carrierInput.classList.remove(INVALID_CLASS);
}
}
/**
* We cannot relay on the counter, but in the next id after the
* last field.
* See bug 1113134 for related explanation.
*/
function getNextTemplateId(container) {
var nodes = container.childNodes;
if (!nodes || nodes.length === 0) {
return 0;
}
var lastNode = nodes[nodes.length - 1];
var value = lastNode.dataset.index;
return value ? parseInt(value) + 1 : 0;
}
function isBuiltInType(type, tagList) {
return tagList.some(function(tag) {
return tag.type === type;
});
}
function insertField(type, object, isUserInteraction) {
if (!type || !configs[type]) {
console.error('Inserting field with unknown type');
return;
}
var obj = object || {};
var config = configs[type];
var template = config.template;
var tags = ContactsTag.filterTags(type, null, config.tags);
var container = config.container;
var default_type = tags[0] && tags[0].type || '';
var currField = {};
config.fields.forEach(function(currentElem) {
var def = (currentElem === 'type') ? default_type : '';
var defObj = (typeof(obj) === 'string') ? obj : obj[currentElem];
var value = '';
var isDate = (defObj && typeof defObj.getMonth === 'function');
currField[currentElem] = (defObj && typeof(defObj) === 'object' &&
!isDate ? defObj.toString() : defObj);
value = currField[currentElem] || def;
if (currentElem === 'type') {
currField.type_value = value;
// Do localization for built-in types
if (isBuiltInType(value, tags)) {
currField.type_l10n_id = value;
value = _(value) || value;
}
}
if (!isDate) {
currField[currentElem] = Normalizer.escapeHTML(value, true);
}
});
currField.i = getNextTemplateId(container);
var rendered = utils.templates.render(template, currField);
// Controlling that if no tel phone is present carrier field is disabled
if (type === 'tel') {
var carrierInput = rendered.querySelector('input[data-field="carrier"]');
var telInput = rendered.querySelector('input[data-field="value"]');
var cb = checkCarrierTel.bind(null, carrierInput);
telInput.addEventListener('input', cb, true);
checkCarrierTel(carrierInput, {target: telInput});
}
// Adding listener to properly render dates
if (type === 'date') {
var dateInput = rendered.querySelector('input[type="date"]');
// Setting the max value as today's date
var currentDate = new Date();
dateInput.setAttribute('max', currentDate.getFullYear() + '-' +
(currentDate.getMonth() + 1) + '-' +
currentDate.getDate());
var dateInputText = dateInput.previousElementSibling;
if (currField.value) {
dateInput.valueAsDate = currField.value;
renderDate(currField.value, dateInputText);
}
else {
dateInputText.setAttribute('data-l10n-id', 'date-span-placeholder');
}
dateInput.addEventListener(
'input',
onInputDate.bind(null, dateInputText));
}
var removeEl = createDeleteButton(rendered.id, type);
rendered.insertBefore(removeEl, rendered.firstChild);
// Add event listeners
var boxTitle = rendered.querySelector('legend.action');
if (boxTitle) {
boxTitle.addEventListener('click', selectTag);
}
// This will happen when the fields are added by the user on demand
if (isUserInteraction) {
rendered.classList.add('inserted');
window.setTimeout(() => rendered.classList.add('displayed'));
}
container.classList.remove('empty');
container.appendChild(rendered);
counters[type]++;
cachedInputs = null;
// Finally we need to check the status of the add date button
if (type === 'date') {
checkAddDateButton();
}
}
/*
* Method for selecting a tag (i.e. "phone type"). This needs to handle
* navigation among panels, that's why we need a 'MainNavigation' object
*/
function selectTag(evt) {
evt.preventDefault();
var target = evt.currentTarget;
LazyLoader.load('/contacts/js/utilities/tagSelector.js', function() {
TagSelector.show(target.children[0]);
});
return false;
}
function getPhones(contact) {
var phones = [];
var selector = '#view-contact-form form div.phone-template';
var phonesDOM = document.querySelectorAll(selector);
for (var i = 0; i < phonesDOM.length; i++) {
var currentPhone = phonesDOM[i];
var index = currentPhone.dataset.index;
var numberField = document.getElementById('number_' + index);
var value = numberField.value;
if (!value) {
continue;
}
var type =
[document.getElementById('tel_type_' + index).dataset.value || ''];
var carrierSelector = 'carrier_' + index;
var carrier = document.getElementById(carrierSelector).value || '';
phones.push({ value, type, carrier });
}
return phones;
}
function getEmails() {
var emails = [];
var selector = '#view-contact-form form div.email-template';
var emailsDOM = document.querySelectorAll(selector);
for (var i = 0; i < emailsDOM.length; i++) {
var currentEmail = emailsDOM[i];
var index = currentEmail.dataset.index;
var emailField = document.getElementById('email_' + index);
var value = emailField.value;
value = value && value.trim();
if (!value) {
continue;
}
var type = [
document.getElementById('email_type_' + index).dataset.value || ''];
emails.push({ value, type });
}
return emails;
}
function getName(contact) {
var givenName = '', familyName = '';
if (contact.givenName && Array.isArray(contact.givenName)) {
givenName = contact.givenName[0].trim();
}
if (contact.familyName && Array.isArray(contact.familyName)) {
familyName = contact.familyName[0].trim();
}
var completeName = (givenName + ' ' + familyName).trim();
return completeName ? [completeName] : [];
}
function getDates(contact) {
var dates = {};
var selector = '#view-contact-form form div.date-template';
var datesDOM = document.querySelectorAll(selector);
var bdayVal = null, anniversaryVal = null;
for (var i = 0; i < datesDOM.length; i++) {
var currentDate = datesDOM[i];
var arrayIndex = currentDate.dataset.index;
var dateField = document.getElementById('date_' + arrayIndex);
var dateValue = dateField.valueAsDate;
selector = 'date_type_' + arrayIndex;
var type = document.getElementById(selector).dataset.value || '';
if (!dateValue || !type) {
continue;
}
// Date value is referred to current TZ but it is not needed to normalize
// as that will be done only when the date is presented to the user
// by calculating the corresponding offset
switch (type) {
case 'birthday':
bdayVal = dateValue;
break;
case 'anniversary':
anniversaryVal = dateValue;
break;
}
}
dates.bday = bdayVal;
dates.anniversary = anniversaryVal;
return dates;
}
function getAddresses(contact) {
var addresses = [];
var selector =
'#view-contact-form form div.address-template';
var addressesDOM = document.querySelectorAll(selector);
for (var i = 0; i < addressesDOM.length; i++) {
var currentAddress = addressesDOM[i];
var arrayIndex = currentAddress.dataset.index;
var addressField = document.getElementById('streetAddress_' + arrayIndex);
var addressValue = addressField.value || '';
addressValue = addressValue.trim();
selector = 'address_type_' + arrayIndex;
var typeField = document.getElementById(selector).dataset.value || '';
typeField = typeField.trim();
selector = 'locality_' + arrayIndex;
var locality = document.getElementById(selector).value || '';
locality = locality.trim();
selector = 'postalCode_' + arrayIndex;
var postalCode = document.getElementById(selector).value || '';
postalCode = postalCode.trim();
selector = 'countryName_' + arrayIndex;
var countryName = document.getElementById(selector).value || '';
countryName = countryName.trim();
// Sanity check for pameters, check all params but the typeField
if (addressValue === '' && locality === '' &&
postalCode === '' && countryName === '') {
continue;
}
addresses.push({
streetAddress: addressValue,
postalCode: postalCode,
locality: locality,
countryName: countryName,
type: [typeField]
});
}
return addresses;
}
function getNotes(contact) {
var notes = [];
var selector = '#view-contact-form form div.note-template';
var notesDOM = document.querySelectorAll(selector);
for (var i = 0; i < notesDOM.length; i++) {
var currentNote = notesDOM[i];
var arrayIndex = currentNote.dataset.index;
var noteField = document.getElementById('note_' + arrayIndex);
var noteValue = noteField.value;
noteValue = noteValue && noteValue.trim();
if (!noteValue) {
continue;
}
notes.push(noteValue);
}
return notes;
}
function checkDisableButton() {
var saveButton = document.getElementById('save-button');
if (emptyForm()) {
saveButton.setAttribute('disabled', 'disabled');
} else {
saveButton.removeAttribute('disabled');
}
}
var cachedInputs = null;
function emptyForm() {
if (!cachedInputs) {
cachedInputs =
contactForm.querySelectorAll('input[data-field]:not(.invalid)');
}
for (var i = cachedInputs.length - 1; i >= 0; i--) {
if ((cachedInputs[i].value && cachedInputs[i].value.trim()) ||
(cachedInputs[i].valueAsDate)) {
return false;
}
}
return true;
}
function createDeleteButton(selector, type) {
var delButton = document.createElement('button');
delButton.className = DELETE_BUTTON_CLASS;
delButton.setAttribute('data-l10n-id', 'removeField');
delButton.dataset.type = type;
delButton.dataset.action = 'delete';
delButton.dataset.selector = selector;
return delButton;
}
function appendRemovePhotoButton() {
var button = thumbAction.querySelector('button.' + DELETE_BUTTON_CLASS);
if (!button) {
button = createDeleteButton(thumbAction.id, 'photo');
thumbAction.appendChild(button);
}
else {
// Ensure it is visible
button.classList.remove('hide');
}
thumbAction.classList.add('with-photo');
}
function appendPhoto(resized) {
appendRemovePhotoButton();
if (!emptyForm()) {
saveButton.removeAttribute('disabled');
}
utils.dom.updatePhoto(resized, thumb);
currentPhoto = resized;
}
function photoAction() {
if (!!currentPhoto) {
removeOrUpdatePhoto();
} else {
LazyLoader.load(
[
'/contacts/views/form/js/photo_picker.js'
],
function() {
PhotoPicker.pick(appendPhoto);
}
);
}
}
function removeOrUpdatePhoto() {
LazyLoader.load(
[
'/contacts/views/form/js/photo_picker.js',
'/contacts/js/action_menu.js'
],
function() {
var prompt = new ActionMenu('photo-options');
prompt.addToList({id: 'remove-photo'}, removePhoto);
prompt.addToList(
{id: 'change-photo'},
PhotoPicker.pick.bind(null, appendPhoto)
);
prompt.show();
}
);
}
function removePhoto() {
currentPhoto = null;
thumbAction.classList.remove('with-photo');
utils.dom.updatePhoto(null, thumb);
if (!emptyForm()) {
saveButton.removeAttribute('disabled');
}
}
window.FormUI = {
'init': init,
'render': render
};
}(window));

View file

@ -0,0 +1,7 @@
/* global navigationStack */
/* exported MainNavigation */
(function(exports) {
'use strict';
exports.MainNavigation = new navigationStack('view-contact-form');
}(window));

View file

@ -0,0 +1,89 @@
/* global MozActivity */
(function(exports) {
'use strict';
// The size we want our contact photos to be
const PHOTO_WIDTH = 320;
const PHOTO_HEIGHT = 320;
// bug 1038414: ask for an image about 2MP before
// doing the crop to save memory in both apps
const MAX_PHOTO_SIZE = 200000;
function pickImage(callback) {
callback = callback || function foo() {};
var activity = new MozActivity({
name: 'pick',
data: {
type: 'image/jpeg',
maxFileSizeBytes: MAX_PHOTO_SIZE
}
});
activity.onsuccess = function() {
// XXX
// this.result.blob is valid now, but it won't stay valid
// (see https://bugzilla.mozilla.org/show_bug.cgi?id=806503)
// And it might not be the size we want, anyway, so we make
// our own copy that is at the right size.
resizeBlob(
this.result.blob,
PHOTO_WIDTH,
PHOTO_HEIGHT,
callback
);
};
activity.onerror = function() {
window.console.error('Error in the activity', activity.error);
};
}
function resizeBlob(blob, target_width, target_height, callback) {
var img = document.createElement('img');
var url = URL.createObjectURL(blob);
img.src = url;
function cleanupImg() {
img.src = '';
URL.revokeObjectURL(url);
}
img.onerror = cleanupImg;
img.onload = function() {
var image_width = img.width;
var image_height = img.height;
var scalex = image_width / target_width;
var scaley = image_height / target_height;
var scale = Math.min(scalex, scaley);
var w = target_width * scale;
var h = target_height * scale;
var x = (image_width - w) / 2;
var y = (image_height - h) / 2;
var canvas = document.createElement('canvas');
canvas.width = target_width;
canvas.height = target_height;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(img, x, y, w, h, 0, 0, target_width, target_height);
cleanupImg();
canvas.toBlob(function(resized) {
context = null;
canvas.width = canvas.height = 0;
canvas = null;
callback(resized);
} , 'image/jpeg');
};
}
exports.PhotoPicker = {
pick: pickImage
};
}(window));

View file

@ -0,0 +1,22 @@
/*
* Throbber is just a loading bar at the top of the UI for
* showing the progress of the action requested by the user.
*
* This is needed for low-end devices, where 'saving' or
* *updating* a contact could take some seconds.
*/
(function(exports) {
'use strict';
var throbberDOM = document.getElementById('throbber');
exports.Throbber = {
show: function() {
throbberDOM.classList.remove('hide');
},
hide: function() {
throbberDOM.classList.add('hide');
}
};
}(window));

View file

@ -0,0 +1,4 @@
#view-contact-form {
visibility: visible;
transform: translateY(0);
}

View file

@ -0,0 +1,23 @@
'use strict';
/* global LazyLoader, MatchingUI */
window.addEventListener('DOMContentLoaded', function() {
LazyLoader.load(['/shared/js/l10n.js']).then(function() {
// TODO Add if needed
});
});
window.onload = function() {
var dependencies = [
'/shared/js/contact_photo_helper.js',
'/shared/js/sanitizer.js',
'/shared/js/contacts/import/utilities/misc.js',
'/contacts/views/matching/js/matching_ui.js'
];
window.screen.mozLockOrientation('portrait-primary');
LazyLoader.load(dependencies).then(function() {
MatchingUI.init();
});
};

View file

@ -0,0 +1,213 @@
'use strict';
/* global Curtain */
/* global LazyLoader */
/* global MergeHelper */
/* global Matcher */
/**
* This object perform each operation related to the actions that an user
* can do in the matching contacts view
*/
(function(exports) {
var CONTACTS_APP_ORIGIN = location.origin;
var contact = null,
matchings = {};
/*
* This library implements two modes of work ('matching' by default):
*
* 1) 'matching': find duplicates adding a contact trigger that checks for
* duplicates. If there are duplicates, it shows them in a
* list where users are able to check what contacts will be
* finally merged.
*
* 2) 'listing': this library shows a list of contacts received from the
* caller by means of a postMessage mechanism. Users can
* check what contacts will be merged but this action will be
* performed by the caller. Basically the library sends the
* list of identifiers to the caller by the same mechanism.
*/
var type = 'matching';
var parentWindow = null;
function start(cid) {
if (!cid) {
type = 'listing';
window.addEventListener('message', duplicateContactsHandler);
parentWindow.postMessage({
type: 'duplicate_contacts_loaded'
}, CONTACTS_APP_ORIGIN);
return;
}
Curtain.show('wait', 'duplicateContacts');
// Finding duplicate contacts
var callbacks = {
onmatch: showUI,
onmismatch: function() {
Curtain.show('alert', 'noDuplicateContacts');
Curtain.onok = abort;
}
};
Curtain.oncancel = function oncancel() {
// Removing callbacks
callbacks.onmatch = callbacks.onmismatch = null;
abort();
};
var matcherDependencies = ['/shared/js/text_normalizer.js',
'/shared/js/simple_phone_matcher.js',
'/shared/js/contacts/contacts_matcher.js'];
LazyLoader.load(matcherDependencies, function loaded() {
// Give some time to see the searching for duplicates message
setTimeout(function() {
parentWindow.ContactsService.get(cid, function success(mContact) {
// Master contact
contact = mContact;
Matcher.match(contact, 'active', callbacks);
}, abort);
}, 1000);
});
}
function duplicateContactsHandler(e) {
if (e.origin !== CONTACTS_APP_ORIGIN) {
return;
}
var data = e.data;
if (data && data.type === 'show_duplicate_contacts') {
window.removeEventListener('message', duplicateContactsHandler);
// Incoming contact
contact = {
name: [data.data.name]
};
var duplicateContacts = data.data.duplicateContacts;
var total = Object.keys(duplicateContacts).length;
var matchingReady = function() {
if (--total === 0) {
window.addEventListener('message', contactsMergedHandler);
showUI(duplicateContacts);
}
};
Object.keys(duplicateContacts).forEach(function(cid) {
parentWindow.ContactsService.get(cid, function success(contact) {
duplicateContacts[cid] = {
matchingContact: contact,
matchings: duplicateContacts[cid].matchings
};
matchingReady();
}, matchingReady);
});
} else if(data && data.type === 'sync'){
parentWindow = e.source;
navigator.mozL10n.once(function localized(evt) {
// The controller is started when the literals are available
start(window.location.search.substring('contactId'.length + 2));
});
}
}
function contactsMergedHandler(e) {
if (e.origin !== CONTACTS_APP_ORIGIN) {
return;
}
if (e.data && e.data.type === 'duplicate_contacts_merged') {
window.removeEventListener('message', contactsMergedHandler);
Curtain.hide(function() {
parentWindow.postMessage({
type: 'window_close',
data: ''
}, CONTACTS_APP_ORIGIN);
});
}
}
function showUI(results) {
matchings = results;
window.dispatchEvent(new CustomEvent('initUI', {
'detail': {
'type': type,
'contact': contact,
'results': results
}
}));
window.addEventListener('UIReady', function fn() {
window.removeEventListener('UIReady', fn);
// We start the open-animation when the UI is ready
if (type === 'matching') {
Curtain.hide(sendReadyEvent, CONTACTS_APP_ORIGIN);
} else {
sendReadyEvent();
}
});
}
function sendReadyEvent() {
parentWindow.postMessage({
type: 'ready',
data: ''
}, CONTACTS_APP_ORIGIN);
}
function abort() {
var notifyParent = function cmc_notifyParent() {
parentWindow.postMessage({
type: 'abort',
data: ''
}, CONTACTS_APP_ORIGIN);
};
Curtain.hide(notifyParent);
}
function merge(checkedContacts) {
Curtain.show('wait', 'mergingDuplicateContacts');
Curtain.hideMenu(); // Hide cancel button
if (type === 'listing') {
// Delegating the merge action to caller and this is the end
parentWindow.postMessage({
type: 'merge_duplicate_contacts',
data: checkedContacts
}, CONTACTS_APP_ORIGIN);
return;
}
var MERGE_DEPS = ['/contacts/js/utilities/merge_helper.js'];
LazyLoader.load(MERGE_DEPS, function loaded() {
var cb = function cb() {
Curtain.hide(function() {
parentWindow.postMessage({
type: 'window_close',
data: ''
}, CONTACTS_APP_ORIGIN);
});
};
var list = [];
Object.keys(checkedContacts).forEach(function(id) {
list.push(matchings[id]);
});
MergeHelper.merge(contact, list).then(cb, cb);
});
}
window.addEventListener('message', duplicateContactsHandler);
window.addEventListener('merge', evt => {
merge(evt.detail.checkedContacts);
});
})(window);

View file

@ -0,0 +1,304 @@
'use strict';
/* global ContactPhotoHelper */
/* global ImageLoader */
/* global LazyLoader */
/* global Sanitizer */
/* jshint nonew: false */
/**
* This object handles every interaction between the user and the UI of the
* matching contacts view
*/
(function(exports) {
var CONTACTS_APP_ORIGIN = location.origin;
var listDependencies = [
'/shared/js/contacts/utilities/image_loader.js'
];
var MatchingUI = {
_: null,
// Counter for checked list items
checked: 0,
// Hash contains identifiers of checked contacts
checkedContacts: {},
// Field order when showing matching details main reason
fieldOrder: ['tel', 'email', 'name'],
init: function() {
this._ = navigator.mozL10n.get;
this.mergeButton = document.getElementById('merge-action');
if (!this.mergeButton) {
return;
}
this.duplicateMessage = document.querySelector('#duplicate-msg > p');
this.contactsList =
document.querySelector('#contacts-list-container > ol');
this.contactsList.addEventListener('click', this.onClick.bind(this)
, true);
this.mergeButton.addEventListener('click', this.onMerge.bind(this));
window.addEventListener('initUI', evt => {
this.load(evt.detail.type, evt.detail.contact, evt.detail.results);
});
},
/*
* Loads the UI that implements the merge of duplicate contacts
*
* @param{Object} Master contact
*
* @param{Object} Hash of matching contacts from Matcher module
*
*/
load: function(type, contact, results) {
this.matchingResults = results;
document.body.dataset.mode = type;
var contactName = this.getDisplayName(contact);
var params = { name: Sanitizer.escapeHTML `${contactName}` };
if (type === 'matching') {
// "Suggested duplicate contacts for xxx"
navigator.mozL10n.setAttributes(this.duplicateMessage,
'suggestedDuplicatesMessage',
params);
} else {
navigator.mozL10n.setAttributes(document.querySelector('title'),
'duplicatesFoundTitle');
// "xxx duplicates information in the following contacts"
navigator.mozL10n.setAttributes(this.duplicateMessage,
'foundDuplicatesMessage',
params);
}
// Rendering the duplicate contacts list
this.renderList(results);
},
templateDuplicateContact: function(contact) {
return Sanitizer.createSafeHTML `<li data-uuid="${contact.id}"
class="block-item">
<label class="pack-checkbox">
<input type="checkbox" checked>
<span></span>
</label>
<aside class="pack-end">
<img data-src="${contact.thumb}">
</aside>
<p>
<bdi class="ellipsis-dir-fix">${contact.displayName}</bdi>
</p>
<p class="match-main-reason">
<bdi class="ellipsis-dir-fix">${contact.mainReason}</bdi>
</p>
</li>`;
},
renderList: function(contacts) {
LazyLoader.load(listDependencies, () => {
// For each contact in the list
var contactsKeys = Object.keys(contacts);
contactsKeys.forEach(id => {
// New contact appended
this.checkedContacts[id] = id;
var contact = this.cookContact(contacts[id]);
// Use a template node so we can append it and also access the node
var item = document.createElement('template');
item.innerHTML =
Sanitizer.unwrapSafeHTML(this.templateDuplicateContact(contact));
this.contactsList.appendChild(item.content);
if (contact.email1 === '') {
var emailField = item.content.querySelector('p:last-child');
emailField && emailField.parentNode.removeChild(emailField);
}
});
this.checked = contactsKeys.length;
this.checkMergeButton();
new ImageLoader('#main', 'li');
setTimeout(() => {
window.dispatchEvent(new CustomEvent('UIReady'));
// Remove the hidden ui
document.body.classList.remove('ui-not-ready');
});
});
},
/*
* It creates a new copy becase it is forbidden to add new attributes to
* original contacts provided by the API
*
* @param(Object) Contact object provided by Gecko
*/
cookContact: function(matching) {
var contact = matching.matchingContact;
var reasons = matching.matchings || {};
var out = {};
this.populate(contact, out, Object.getOwnPropertyNames(contact));
this.populate(contact, out,
Object.getOwnPropertyNames(Object.getPrototypeOf(contact)));
out.displayName = this.getDisplayName(contact);
out.mainReason = this.selectMainReason(reasons);
var photo = ContactPhotoHelper.getThumbnail(out);
if (photo) {
out.thumb = window.URL.createObjectURL(photo);
}
return out;
},
getCompleteName: function(contact) {
var givenName = Array.isArray(contact.givenName) ?
contact.givenName[0] : '';
var familyName = Array.isArray(contact.familyName) ?
contact.familyName[0] : '';
var completeName = givenName && familyName ?
givenName + ' ' + familyName :
givenName || familyName;
return completeName;
},
// Fills the contact data to display if no givenName and familyName
getDisplayName: function(contact) {
if (this.hasName(contact)) {
return this.getCompleteName(contact);
}
var name = [];
if (Array.isArray(contact.name) && contact.name[0] &&
contact.name[0].trim()) {
name.push(contact.name[0]);
} else if (contact.org && contact.org[0] && contact.org[0].trim()) {
name.push(contact.org[0]);
} else if (contact.tel && contact.tel[0]) {
name.push(contact.tel[0].value);
} else if (contact.email && contact.email[0]) {
name.push(contact.email[0].value);
} else {
name.push(this._('noName'));
}
return name[0];
},
hasName: function(contact) {
return (Array.isArray(contact.givenName) && contact.givenName[0] &&
contact.givenName[0].trim()) ||
(Array.isArray(contact.familyName) && contact.familyName[0] &&
contact.familyName[0].trim());
},
selectMainReason: function(matchings) {
var out = '';
for (var j = 0; j < this.fieldOrder.length; j++) {
var aField = this.fieldOrder[j];
var theMatchings = matchings[aField];
if (Array.isArray(theMatchings) && theMatchings[0]) {
out = theMatchings[0].matchedValue;
if (out) {
break;
}
}
}
return out;
},
populate: function(source, target, propertyNames) {
propertyNames.forEach(function(property) {
var propertyValue = source[property];
if (propertyValue) {
target[property] = propertyValue;
}
});
},
onClose: function(e) {
e.stopPropagation();
e.preventDefault();
parent.postMessage({
type: 'window_close',
data: ''
}, CONTACTS_APP_ORIGIN);
},
onClick: function(e) {
e.stopPropagation();
e.preventDefault();
if (e.target.tagName === 'INPUT') {
return false;
}
var li = e.target.closest('li');
var uuid = li.dataset.uuid;
var el = li.querySelector('input');
if (el.checked) {
--this.checked;
delete this.checkedContacts[uuid];
} else {
++this.checked;
this.checkedContacts[uuid] = uuid;
}
// Change manually the status as we prevented this
el.checked = !el.checked;
this.checkMergeButton();
},
isMatch: function(matchings, aField, fieldValue) {
var noMatch = true;
if (matchings[aField]) {
// Using every to short circuit the checking when a match is found.
noMatch = matchings[aField].every(function(obj) {
var val = fieldValue.value || fieldValue;
if (obj.matchedValue === val) {
return false;
}
return true;
});
}
return !noMatch;
},
checkMergeButton: function() {
navigator.mozL10n.setAttributes(this.mergeButton,
'mergeActionButtonLabel',
{ n: this.checked });
this.mergeButton.disabled = (this.checked === 0);
},
onMerge: function(e) {
e.stopPropagation();
e.preventDefault();
window.dispatchEvent(new CustomEvent('merge', {
'detail': {
'checkedContacts': this.checkedContacts
}
}));
}
};
exports.MatchingUI = MatchingUI;
})(window);

View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta name="theme-color" content="#00b7b7">
<title data-l10n-id="mergeDuplicateTitle">Merge duplicates</title>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/contacts/locales/matcher/matcher.{locale}.properties">
<link rel="localization" href="/shared/locales/phone_types/phone_types.{locale}.properties">
<script type="text/javascript" src="/shared/js/l10n.js"></script>
<script type="text/javascript" src="/shared/js/lazy_loader.js"></script>
<script type="text/javascript" src="/contacts/views/matching/js/boot.js"></script>
<script type="text/javascript" src="/contacts/views/matching/js/matching_controller.js"></script>
<script type="text/javascript" src="/shared/pages/import/js/curtain_panel.js"></script>
<!-- Lazy loaded, when rendering duplicate list
<script type="text/javascript" src="/shared/js/contacts/utilities/image_loader.js"></script>
<script type="text/javascript" src="/shared/js/contacts/utilities/templates.js"></script>
<script defer src="/shared/js/sanitizer.js"></script>
-->
<!-- Lazy loaded, files needed for match&merge
<script type="text/javascript" src="/shared/js/text_normalizer.js"></script>
<script type="text/javascript" src="/shared/js/simple_phone_matcher.js"></script>
<script type="text/javascript" src="/shared/js/contacts/contacts_matcher.js"></script>
<script type="text/javascript" src="/shared/js/contacts/contacts_merger.js"></script>
-->
<!-- Building Blocks -->
<link href="/shared/style/switches.css" rel="stylesheet">
<link href="/shared/style/lists.css" rel="stylesheet">
<link href="/shared/style/confirm.css" rel="stylesheet">
<link href="/shared/style/animations.css" rel="stylesheet">
<link href="/shared/style/headers.css" rel="stylesheet">
<link href="/shared/style/progress_activity.css" rel="stylesheet">
<link href="/shared/pages/import/style/root.css" rel="stylesheet">
<link href="/shared/pages/import/style/curtain.css" rel="stylesheet">
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<!-- <link rel="stylesheet" type="text/css" href="/shared/elements/gaia-icons/gaia-icons.css" /> -->
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<link rel="localization" href="/shared/locales/import_contacts/import_contacts.{locale}.properties">
<link rel="stylesheet" href="style/matching_contacts.css">
<!--<link rel="import" href="/shared/pages/import/elements/curtain.html">-->
</head>
<body role="application" class="skin-comms ui-not-ready">
<section role="region">
<section id="main">
<section id="duplicate-msg">
<p></p>
</section>
<!-- Contacts List Section -->
<section id="contacts-list-container" data-type="list" class="active">
<ol>
</ol>
</section> <!-- contacts-list-container -->
<form id="footer" role="dialog" data-type="confirm">
<menu>
<button id="merge-action" data-l10n-id="merge" class="danger full">
Merge
</button>
</menu>
</form>
</section> <!-- main -->
</section> <!-- role="section" -->
</body>
</html>

View file

@ -0,0 +1,128 @@
@import url("/contacts/style/root.css");
.hide, *[data-template] {
display: none;
}
#main {
position: fixed;
height: 100%;
width: calc(100% + 0.8rem);
overflow-x: hidden;
overflow-y: auto;
background: #F4F4F4;
}
#contacts-list-container.active li:not(:active):after {
display: none;
}
#contacts-list-container label {
left: 0;
}
#contacts-list-container > ol > li[data-uuid] {
display: block;
-moz-padding-start: 4rem;
border-top: none;
}
/*#contacts-list-container > ol > li[data-uuid] aside,
#contacts-list-container > ol > li[data-uuid] label,
#contacts-list-container > ol > li[data-uuid] p {
pointer-events: none;
}*/
#contacts-list-container > ol > li[data-uuid] aside > img {
box-shadow: none;
border: none;
}
#contacts-list-container > ol > li[data-uuid]:last-child {
border-bottom: none;
}
/* BB overrides */
.match-main-reason {
color: #008eab;
}
form[role="dialog"][data-type="edit"] #duplicate-msg > p {
color: #000;
overflow: visible;
white-space: normal;
}
form[role="dialog"][data-type="edit"] #main {
pointer-events: auto;
}
#duplicate-msg {
width: 100%;
z-index: 1;
padding: 0.4rem 0;
}
#duplicate-msg > p {
font-size: 1.8rem;
line-height: 2.2rem;
margin: 1rem 3rem;
}
#footer {
background: none;
pointer-events: none;
position: fixed;
}
#footer menu {
border-top: #d8d8d8 0.1rem solid;
background: #e8e8e8;
}
#footer button:not([disabled]) {
pointer-events: auto;
}
[data-type="list"] aside img {
border-style: hidden;
}
[data-type="list"] li[data-visited="true"] aside img {
border-style: solid;
}
/* Ellipsis direction hack before bug 883884 is fixed */
.ellipsis-dir-fix {
display: block;
width: -moz-fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* RTL View */
html[dir="rtl"] #contacts-list-container label {
left: unset;
right: 0;
}
#contacts-list-container > ol > li[data-uuid] label {
pointer-events: auto;
}
.ui-not-ready {
background-color: #27c8c2;
}
.ui-not-ready section[role="region"] {
display: none;
}
/* Bug 1202359: Prevent send two events */
span::after {
pointer-events: none;
}

View file

@ -0,0 +1,46 @@
/* global LazyLoader, VCardLoadUI, VCardLoadController, VCardHandler */
'use strict';
/*
* This class is the one in charge of loading the minimum set of
* resources needed for the view to load. Any other JS/CSS/Element
* not needed in the critical path *must* be lazy loaded when needed.
*
* Once localization and all the basic JS/CSS/Elements are loaded,
* we will initialize UI and Controller. Both JS classes *must* be
* independent and will communicate through events.
*/
window.addEventListener('DOMContentLoaded', function() {
LazyLoader.load(['/shared/js/l10n.js']).then(function() {
LazyLoader.load([
document.getElementById('multiple-select-view')
]);
});
});
window.addEventListener('load', function() {
var dependencies = [
'/shared/js/contacts/import/utilities/vcard_reader.js',
'/contacts/views/vcard_load/js/vcard_handler.js',
'/contacts/views/vcard_load/js/vcard_load_ui.js',
'/contacts/views/vcard_load/js/vcard_load_controller.js'
];
LazyLoader.load(dependencies).then(function() {
VCardLoadUI.init();
VCardLoadController.init();
navigator.mozSetMessageHandler('activity', function(activity) {
VCardLoadController.setActivity(activity);
VCardHandler.handle(activity)
.then(contacts => {
var data = activity.source.data;
var filename = VCardHandler.getFileName(data.filename || data.src);
// Do Render
VCardLoadUI.render(contacts, filename);
});
});
});
});

View file

@ -0,0 +1,58 @@
/* global VCardReader */
(function(exports) {
'use strict';
function handle(activity) {
return getvCardReader(activity.source.data.blob).then(readvCard);
}
function getvCardReader(blob) {
return new Promise((resolve, reject) => {
var fileReader = new FileReader();
fileReader.readAsText(blob);
fileReader.onloadend = function() {
resolve(new VCardReader(fileReader.result));
};
fileReader.onerror = function() {
console.error('The blob could not be read as a file.');
reject(fileReader.error);
};
});
}
function readvCard(vCardReader) {
var contacts = [];
return new Promise(function(resolve, reject) {
var cursor = vCardReader.getAll();
cursor.onsuccess = function(event) {
if (!event.target.result) {
resolve(contacts);
return;
}
contacts.push(event.target.result);
cursor.continue();
};
cursor.onerror = function(event) {
console.error('Error while retrieving cursor from vcard');
reject();
};
});
}
function getFileName(path) {
var barIndex = path.lastIndexOf('/');
if (barIndex > -1) {
path = path.substring(barIndex + 1);
}
return path;
}
exports.VCardHandler = {
'handle': handle,
'getFileName': getFileName
};
})(window);

View file

@ -0,0 +1,100 @@
/* global LazyLoader, ContactsService, Matcher, contacts */
(function(exports) {
'use strict';
var _activity;
const STATUS_TIMEOUT = 3000; // ms before closing view which shows status msg
function close() {
if (_activity) {
_activity.postResult({});
} else {
window.history.back();
}
}
function cleanup() {
window.removeEventListener('closeAction', close);
window.removeEventListener('saveAction', importAll);
}
function importAll(evt) {
if (!evt.detail.contactsToImport) {
console.error('Missing parameter');
return;
}
cleanup();
var contactsToImport = evt.detail.contactsToImport;
var numDupsMerged = 0,
importedContacts = 0,
parsedContacts = 0;
const DEPENDENCIES = [
'/shared/js/contacts/import/utilities/status.js',
'/shared/js/simple_phone_matcher.js',
'/shared/js/contacts/contacts_matcher.js',
'/shared/js/contacts/contacts_merger.js',
'/shared/js/contacts/merger_adapter.js',
'/contacts/services/contacts.js',
'/shared/js/text_normalizer.js'
];
LazyLoader.load(DEPENDENCIES, function() {
contactsToImport.forEach((contact, index) => {
Matcher.match(contact, 'passive', {
onmatch: (matches) => {
var callbacks = {
success: () => {
numDupsMerged++;
doContinue(true);
},
error: doContinue
};
contacts.adaptAndMerge(contact, matches, callbacks);
},
onmismatch: () => {
ContactsService.save(
contact,
function(e) {
if (e) {
console.error(e);
doContinue();
return;
}
doContinue(true);
}
);
}
});
});
});
function doContinue(isContactImported) {
parsedContacts++;
if (isContactImported) {
importedContacts++;
}
if (contactsToImport.length <= parsedContacts) {
window.dispatchEvent(new CustomEvent('showStatusAction', {
'detail': {
'numDups': numDupsMerged,
'importedContacts': importedContacts
}
}));
window.setTimeout(close, STATUS_TIMEOUT);
}
}
}
exports.VCardLoadController = {
init: function() {
window.addEventListener('closeAction', close);
window.addEventListener('saveAction', importAll);
},
setActivity: function(activity) {
_activity = activity;
}
};
})(window);

View file

@ -0,0 +1,97 @@
/*global utils */
(function(exports) {
'use strict';
var contactsToImport = [];
var header, saveButton, container, template, title;
function cacheElements() {
header = document.getElementById('multiple-select-view-header');
saveButton = document.getElementById('save-button');
container = document.getElementById('multiple-select-container');
template = document.getElementById('contactToImportTemplate');
title = document.getElementById('multiple-select-view-title');
}
function addListeners() {
saveButton.addEventListener('click',
dispatchEvent.bind(null, 'saveAction',
{'contactsToImport': contactsToImport}));
header.addEventListener('action', dispatchEvent.bind(null, 'closeAction'));
window.addEventListener('showStatusAction', onShowStatus);
}
/**
* Initializes references to DOM elements. This is needed before calling
* render.
*/
function init() {
cacheElements();
addListeners();
}
/**
* Allows to preview which contacts will be imported. Requires to call init
* first when DOM is ready.
*
* @param cursor - Reader object to continue reading from vCard.
* @param {string} filename - Name of the vcf file we are reading from.
*/
function render(contacts, filename) {
container.innerHTML = ''; // Avoids rendering the same content twice.
title.textContent = filename;
contacts.forEach(doRenderContact);
function doRenderContact(contact) {
var clone = document.importNode(template.content, true);
var contactData = clone.querySelectorAll('p');
contactData[0].textContent =
Array.isArray(contact.name) && contact.name[0];
var org = (Array.isArray(contact.org) && contact.org[0]) ?
contact.org[0] : '';
contactData[1].textContent = org;
if (Array.isArray(contact.photo) && contact.photo[0] instanceof Blob) {
var picture = clone.querySelector('aside span');
picture.style.backgroundImage = 'url(' +
window.URL.createObjectURL(contact.photo[0]) + ')';
}
contactsToImport.push(contact);
container.appendChild(clone);
}
}
function dispatchEvent(name, data) {
window.dispatchEvent(new CustomEvent(name, {'detail': data}));
}
function onShowStatus(evt) {
if (!evt.detail) {
return;
}
var imported = evt.detail.importedContacts,
merged = evt.detail.numDups;
utils.status.show({
id: 'vCardContacts-imported',
args: {
n: imported
}
}, {
id: 'contactsMerged',
args: {
numDups: merged
}
});
}
exports.VCardLoadUI = {
'init': init,
'render': render
};
})(window);

View file

@ -0,0 +1,4 @@
aside span {
background-position: center;
background-size: 5rem;
}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<meta name="theme-group" content="theme-communications">
<meta name="theme-color" content="var(--header-background)">
<title> Contacts </title>
<!-- Building Blocks -->
<link href="/shared/style/input_areas.css" rel="stylesheet">
<link href="/shared/style/lists.css" rel="stylesheet">
<link href="/shared/style/scrolling.css" rel="stylesheet">
<link href="/shared/style/buttons.css" rel="stylesheet">
<link href="/shared/style/progress_activity.css" rel="stylesheet">
<link href="/shared/style/status.css" rel="stylesheet">
<link href="/shared/style/switches.css" rel="stylesheet">
<link href="/shared/style/confirm.css" rel="stylesheet">
<link href="/shared/style/action_menu.css" rel="stylesheet">
<link href="/shared/style/edit_mode.css" rel="stylesheet">
<link href="/shared/style/animations.css" rel="stylesheet">
<link href="/contacts/style/app.css" rel="stylesheet">
<link href="/contacts/style/contacts.css" rel="stylesheet">
<link href="/contacts/style/overlay.css" rel="stylesheet">
<link href="/shared/pages/import/style/curtain_frame.css" rel="stylesheet">
<link href="/contacts/style/status.css" rel="stylesheet">
<link href="/shared/style/contacts.css" rel="stylesheet">
<link href="/shared/style/contacts/contacts_buttons.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="app://theme.gaiamobile.org/shared/elements/gaia-theme/gaia-theme.css" />
<!-- Web Components -->
<script defer src="/shared/elements/config.js"></script>
<script defer src="/shared/js/component_utils.js"></script>
<script defer src="/shared/elements/gaia_subheader/script.js"></script>
<script defer src="/shared/elements/gaia-header/dist/gaia-header.js"></script>
<!-- Localization -->
<link rel="localization" href="/shared/elements/gaia_menu/locales/gaia_menu.{locale}.properties">
<script defer src="/shared/js/html_imports.js"></script>
<script defer src="/shared/js/lazy_loader.js"></script>
<script defer src='/contacts/views/vcard_load/js/boot.js'></script>
<meta name="defaultLanguage" content="en-US">
<meta name="availableLanguages" content="en-US">
<link rel="localization" href="/contacts/locales/contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/import_contacts/import_contacts.{locale}.properties">
<link rel="localization" href="/shared/locales/date/date.{locale}.properties">
<link rel="localization" href="/shared/locales/phone_types/phone_types.{locale}.properties">
<!-- Load just the templates needed -->
<link rel="import" href="/contacts/elements/multiple_select.html">
<link rel="import" href="/contacts/elements/status.html">
</head>
<body role="application" class="app-contacts skin-comms theme-communications">
<section is="multiple-select-view" id="multiple-select-view" role="region" class="view current"></section>
<section is="status-message" id="statusMsg" role="status" class="hidden"></section>
</body>
</html>