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,196 @@
/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
/*global ActivityClient,
App,
Attachment,
Draft,
Drafts,
MessageManager,
Navigation,
Settings,
Utils
*/
/*exported ActivityHandler */
'use strict';
/**
* Describes available data types that can be associated with the activities.
* @enum {string}
*/
const ActivityDataType = {
IMAGE: 'image/*',
AUDIO: 'audio/*',
VIDEO: 'video/*',
URL: 'url',
VCARD: 'text/vcard'
};
var ActivityHandler = {
init: function() {
ActivityClient.init(App.instanceId);
ActivityClient.on(
'new-activity-request', this._onNewActivity.bind(this)
);
ActivityClient.on(
'share-activity-request', this._onShareActivity.bind(this)
);
},
_onNewActivity: function newHandler(activityData) {
var viewInfo = {
body: activityData.body,
number: activityData.target || activityData.number,
threadId: null
};
var threadPromise;
var focus = '';
if (viewInfo.number) {
// It's reasonable to focus on composer if we already have some phone
// number or even contact to fill recipients input
focus = 'composer';
/* If we have a body, we're always redirecting to the new message view,
* because we don't want to overwrite any possible draft from an existing
* conversation. */
if (!viewInfo.body) {
// try to get a thread from number
// if no thread, promise is rejected and we try to find a contact
threadPromise = MessageManager.findThreadFromNumber(viewInfo.number)
.then((threadId) => viewInfo.threadId = threadId)
// case no contact and no thread id: gobble the error
.catch(() => {});
}
}
return (threadPromise || Promise.resolve()).then(
() => this.toView(viewInfo, focus)
);
},
_onShareActivity: function shareHandler(activityData) {
var dataToShare = null;
switch(activityData.type) {
case ActivityDataType.AUDIO:
case ActivityDataType.VIDEO:
case ActivityDataType.IMAGE:
var attachments = activityData.blobs.map(function(blob, idx) {
var attachment = new Attachment(blob, {
name: activityData.filenames[idx],
isDraft: true
});
return attachment;
});
var size = attachments.reduce(function(size, attachment) {
if (attachment.type !== 'img') {
size += attachment.size;
}
return size;
}, 0);
if (size > Settings.mmsSizeLimitation) {
Utils.alert({
id: 'attached-files-too-large',
args: {
n: activityData.blobs.length,
mmsSize: (Settings.mmsSizeLimitation / 1024).toFixed(0)
}
}).then(() => ActivityClient.postResult());
return;
}
dataToShare = attachments;
break;
case ActivityDataType.URL:
dataToShare = activityData.url;
break;
// As for the moment we only allow to share one vcard we treat this case
// in an specific block
case ActivityDataType.VCARD:
dataToShare = new Attachment(activityData.blobs[0], {
name: activityData.filenames[0],
isDraft: true
});
break;
default:
ActivityClient.postError(
'Unsupported activity data type: ' + activityData.type
);
return;
}
if (!dataToShare) {
ActivityClient.postError('No data to share found!');
return;
}
return this.toView({ body: dataToShare });
},
/**
* Store a draft for this message, and returns a draftId.
*
* @param {Object} message Message to save
* @param {Number} message.threadId Conversation where this messages is
* located.
* @param {String} message.number The recipient as a phone number or email
* address.
* @param {(String|Attachment|Array.<Attachment>)} [message.body] The data to
* add to the message's body.
* @returns {Promise.<Number>} The new draft id.
*/
_storeDraftFromMessage(message) {
var content = message.body || null;
var isSimpleContent = !content || typeof content === 'string';
var isEmailAddress = message.number && Utils.isEmailAddress(message.number);
var draft = new Draft({
threadId: message.threadId || null,
recipients: message.number && [message.number] || null,
content: !content || Array.isArray(content) ? content : [content],
type: isSimpleContent && !isEmailAddress ? 'sms' : 'mms'
});
return Drafts.request().then(
() => Drafts.add(draft).store()
).then(() => draft.id);
},
/**
* Delivers the user to the correct view based on the params provided in the
* "message" parameter.
* @param {{number: string, body: string, threadId: number}} message It's
* either a message object that belongs to a thread, or a message object from
* the system. "number" is a string phone number to pre-populate the
* recipients list with, "body" is an optional body to preset the compose
* input with, "threadId" is an optional threadId corresponding to a new or
* existing thread.
* @param {string?} focus indicates which of 'composer' or 'recipients' field
* we need to focus when we navigate to Thread panel.
*/
toView: function ah_toView(message, focus) {
return Utils.onceDocumentIsVisible().then(() => {
// If we have appropriate thread then let's forward user there, otherwise
// open new message composer.
if (message.threadId) {
return Navigation.toPanel('thread', {
id: message.threadId,
focus
});
}
return this._storeDraftFromMessage(message).then(
(draftId) => Navigation.toPanel('composer', { draftId, focus })
);
});
}
};

View file

@ -0,0 +1,89 @@
/*global MozActivity */
(function(exports) {
'use strict';
function handleActivity(activity, onsuccess, onerror) {
// Note: the MozActivity is intentionally constructed in the caller and
// passed down to this function in order to make it possible to pass an
// inline option to its constructor in the caller for the sake of static
// analysis tools. Please do not change that!
if (typeof onsuccess === 'function') {
activity.onsuccess = onsuccess;
}
activity.onerror = typeof onerror === 'function' ? onerror : function(error) {
console.warn('Unhandled error spawning activity; ' + error.message);
};
}
var ActivityPicker = {
dial: function ap_call(number, onsuccess, onerror) {
handleActivity(new MozActivity({
name: 'dial',
data: {
type: 'webtelephony/number',
number: number
}
}), onsuccess, onerror);
},
email: function ap_email(email, onsuccess, onerror) {
handleActivity(new MozActivity({
name: 'new',
data: {
type: 'mail',
URI: 'mailto:' + email
}
}), onsuccess, onerror);
},
url: function ap_browse(url, onsuccess, onerror) {
handleActivity(new MozActivity({
name: 'view',
data: {
type: 'url',
url: url
}
}), onsuccess, onerror);
},
createNewContact: function ap_createNewContact(contactProps) {
handleActivity(new MozActivity({
name: 'new',
data: {
type: 'webcontacts/contact',
params: contactProps
}
}));
},
addToExistingContact: function ap_addToExistingContact(contactProps) {
handleActivity(new MozActivity({
name: 'update',
data: {
type: 'webcontacts/contact',
params: contactProps
}
}));
},
viewContact: function ap_viewContact(contactProps) {
handleActivity(new MozActivity({
name: 'open',
data: {
type: 'webcontacts/contact',
params: contactProps
}
}));
},
openSettings: function ap_openSettings(onsuccess, onerror) {
handleActivity(new MozActivity({
name: 'configure',
data: {
target: 'device',
section: 'messaging'
}
}), onsuccess, onerror);
}
};
exports.ActivityPicker = ActivityPicker;
}(this));

View file

@ -0,0 +1,72 @@
/*global Promise */
(function(exports) {
'use strict';
/**
* Class that is applied to body whenever application is ready for user.
* @const {string}
*/
const APPLICATION_READY_CLASS_NAME = 'js-app-ready';
/**
* Unique identifier of the app instance.
* @type {number}
*/
const INSTANCE_ID = Date.now();
var app = {
isReady: function app_isReady() {
return document.body.classList.contains(APPLICATION_READY_CLASS_NAME);
},
/**
* Sets application readiness, can be called only once
*/
setReady: function app_setReady() {
if (this.isReady()) {
throw new Error('Application should be marked as ready only once!');
}
document.body.classList.add(APPLICATION_READY_CLASS_NAME);
// Tell audio channel manager that we want to adjust the notification
// channel if the user press the volumeup/volumedown buttons in Messages.
if (navigator.mozAudioChannelManager) {
navigator.mozAudioChannelManager.volumeControlChannel = 'notification';
}
},
/**
* Returns promise that will be resolved once application is ready for
* interaction.
* @returns {Promise}
*/
whenReady: function whenReady() {
if (this.isReady()) {
return Promise.resolve();
}
return new Promise(function(resolve, reject) {
var bodyClassObserver = new MutationObserver(function() {
try {
if (this.isReady()) {
bodyClassObserver.disconnect();
resolve();
}
} catch(e) {
reject(e);
}
}.bind(this));
bodyClassObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
}.bind(this));
},
get instanceId() {
return INSTANCE_ID;
}
};
exports.App = app;
})(this);

7
gaia/apps/sms/views/shared/js/bootstrap.js vendored Executable file
View file

@ -0,0 +1,7 @@
/* global Startup */
(function() {
'use strict';
Startup.init();
})(self);

View file

@ -0,0 +1,308 @@
/*global SharedComponents, Template, Utils, Settings */
'use strict';
(function(exports) {
var FLAVORS = {
/*
* renderAll controls whether all phone numbers sohuld be rendered for this
* contact
*
* shouldHighlight controls whether we should generate a markup allowing
* highlighting depending on the input
*
* template "main" will get the following parameters:
* - name: the contact's title
* - number: the phone number
* - nameHTML: same than name if shouldHighlight is false, otherwise contains
* markup with the highlighting
* - phoneDetailsHTML: phone details label (type, number, carrier), also if
* shouldHighlight is true, then number is wrapped into highlighting markup
* - photoHTML: result of the "photo" template, if present
* - type: phone number type information
* - carrier: phone number carrier information
*
* template "photo" will get the following parameters:
* - photoURL: the URL of the contact's first photo
*
*/
suggestion: {
renderAll: true,
shouldHighlight: true,
templates: {
main: 'contact-suggestion-tmpl'
}
},
suggestionUnknown: {
renderAll: true,
shouldHighlight: true,
templates: {
main: 'contact-suggestion-unknown-tmpl'
}
},
prompt: {
renderAll: false,
shouldHighlight: false,
templates: {
main: 'contact-prompt-tmpl',
photo: 'contact-photo-tmpl'
}
},
'group-view': {
renderAll: false,
shouldHighlight: false,
templates: {
main: 'contact-suggestion-tmpl',
photo: 'contact-photo-tmpl'
}
},
'report-view': {
renderAll: false,
shouldHighlight: false,
templates: {
main: 'contact-report-tmpl',
photo: 'contact-photo-tmpl'
}
}
};
/* let's lazy load generic templates */
var templates = {};
function getTemplate(id) {
if (!templates[id]) {
templates[id] = Template(id);
}
return templates[id];
}
var highlightTemplateId = 'contact-highlight-tmpl';
// Warning: always feed this function an already escaped data
function highlight(escapedData, regexpArray) {
// When rendering a suggestion, we highlight the matched substring.
// The approach is to escape the html and the search string, and
// then replace on all "words" (whitespace bounded strings) with
// the substring run through the highlight template.
// this variable will allow skipping to the next regexp
var matchFound;
var template = getTemplate(highlightTemplateId);
function loopReplaceFn(match) {
matchFound = true;
// The match is safe, because splitData[i] is derived from
// escapedData
return template.interpolate({
str: match
}, {
safe: ['str']
});
}
var splitData = escapedData.split(/\s+/);
// For each "word"
for (var i = 0; i < splitData.length; i++) {
matchFound = false;
// Loop over search term regexes
for (var k = 0; !matchFound && k < regexpArray.length; k++) {
splitData[i] = splitData[i].replace(regexpArray[k], loopReplaceFn);
}
}
return splitData.join(' ');
}
var ContactRenderer = exports.ContactRenderer = function constructor(opts) {
if (!opts) {
throw new Error('ContactRenderer constructor needs an options object');
}
this.opts = opts;
this.templates = {};
for (var key in opts.templates) {
var id = opts.templates[key];
this.templates[key] = Template(id);
}
if (this.templates.photo) {
this.opts.renderPhoto = true;
}
};
ContactRenderer.prototype = {
// Returns true when a contact has been rendered
// Returns false when no contact has been rendered
render: function cr_render(renderOpts) {
/**
*
* params {
* contact:
* A contact object.
*
* input:
* Any input value associated with the contact,
* possibly from a search or similar operation.
*
* skip:
* An array of phone numbers that should not be rendered
*
* target:
* Parent node to append the rendered node to
*
* infoBlock:
* Node that will be appended to the node selected by
* infoBlockParentSelector
*
* infoBlockParentSelector:
* Parent element selector for appending infoBlock as child
*
* }
*
*/
var contact = renderOpts.contact;
var input = renderOpts.input;
input = input && input.trim();
var target = renderOpts.target;
var shouldHighlight = this.opts.shouldHighlight;
var renderAll = this.opts.renderAll;
var renderPhoto = this.opts.renderPhoto;
var skip = renderOpts.skip || [];
var block = renderOpts.infoBlock;
var parentSelector = renderOpts.infoBlockParentSelector;
// we can't do much without a contact
if (!contact) {
return false;
}
// don't render if there is no phone number
// TODO: Add email checking support for MMS
var hasTel = contact.tel && contact.tel.length;
var hasEmail = contact.email && contact.email.length;
if (!hasTel && !(Settings.supportEmailRecipient && hasEmail)) {
return false;
}
// We search on the escaped HTML via a regular expression
var escaped = Utils.escapeRegex(Template.escape(input));
var escsubs = escaped.split(/\s+/);
// Build a list of regexes used for highlighting suggestions
var regexps = {
name: escsubs.map(function(k) {
// String matches occur on the beginning of a "word" to
// maintain parity with the contact search algorithm which
// only considers left aligned exact matches on words
return new RegExp('^' + k, 'gi');
}),
number: escsubs.map(function(k) {
// Match any of the search terms with the number
return new RegExp(k, 'ig');
}),
email: escsubs.map(function(k) {
// Match any of the search terms with the email
return new RegExp('^' + k, 'gi');
})
};
var include = renderPhoto ? { photoURL: true } : null;
var addresses = [];
if (contact.tel && contact.tel.length) {
addresses = addresses.concat(contact.tel);
}
if (Settings.supportEmailRecipient &&
contact.email && contact.email.length) {
addresses = addresses.concat(contact.email);
}
var details = Utils.getContactDetails(
addresses[0].value, contact, include
);
var tempDiv = document.createElement('div');
addresses.forEach(function(current) {
// Only render a contact's tel value entry for the _specified_
// input value when not rendering all values. If the tel
// record value _doesn't_ match, then continue.
//
if (!renderAll && !Utils.probablyMatches(current.value, input)) {
return;
}
// If rendering for contact search result suggestions, don't
// render contact tel records for values that are already
// selected as recipients. This comparison should be safe,
// as the value in this.recipients.numbers comes from the same
// source that current.value comes from.
if (renderAll && skip.indexOf(current.value) > -1) {
return;
}
var data = Utils.getDisplayObject(details.title, current);
var props = ['name', 'number'];
if (Settings.supportEmailRecipient) {
props.push('email');
}
props.forEach(function(key) {
var escapedData = Template.escape(data[key]);
if (shouldHighlight) {
escapedData = highlight(escapedData, regexps[key]);
}
data[key + 'HTML'] = escapedData;
});
data.phoneDetailsHTML = SharedComponents.phoneDetails({
number: data.numberHTML,
type: data.type,
carrier: data.carrier
}, {
safe: ['number']
}).toString();
// Render contact photo only for specific flavor
data.photoHTML = renderPhoto && details.photoURL ?
this.templates.photo.interpolate() : '';
// Interpolate HTML template with data and inject.
// Known "safe" HTML values will not be re-sanitized.
tempDiv.innerHTML = this.templates.main.interpolate(data, {
safe: ['nameHTML', 'phoneDetailsHTML', 'srcAttr', 'photoHTML']
});
var element = tempDiv.firstElementChild;
var blockParent = element.querySelector(parentSelector);
if (blockParent) {
blockParent.appendChild(block);
}
if (data.photoHTML) {
var contactPhoto = element.querySelector('.contact-photo');
contactPhoto.style.backgroundImage =
'url("' + encodeURI(details.photoURL) + '")';
Utils.asyncLoadRevokeURL(details.photoURL);
}
target.appendChild(element);
tempDiv.textContent = '';
}, this);
return true;
}
};
ContactRenderer.flavor = function(flavor) {
// we could possibly cache instances here, if we need more performance
return new ContactRenderer(FLAVORS[flavor]);
};
})(window);

View file

@ -0,0 +1,328 @@
/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
(function(exports) {
'use strict';
/*global fb, Settings, Utils */
var unknownNumbers = [];
var filterFns = {
contains: function(a, b) {
a = a.toLowerCase();
b = b.toLowerCase();
return a.includes(b);
},
equality: function(a, b) {
a = a.toLowerCase();
b = b.toLowerCase();
return a === b;
}
};
/**
* isMatch
*
* Validate a contact record against a list of terms by specified fields
*
* @param {Object} contact a contact record object.
*
* @param {Object} criteria fields and terms to validate a contact.
* - fields (fields of a contact record).
* - terms (terms to validate against contact record field values).
* @param {Function} filterFn function accepts 2 arguments for
* comparison.
*
*/
function isMatch(contact, criteria, filterFn) {
/**
* Validation Strategy
*
* 1. Let _found_ be a register of confirmed terms
* 2. Let _contact_ be a contact record.
* 3. For each _term_ [...input list], with the label _outer_
* - For each _field_ of [givenName, familyName]
* - For each _value_ of _contact_[ _field_ ]
* - Let _found_[ _term_ ] be the result of calling
* _filterFn_( _value_, _term_).
* - If _found_[ _term_ ] is **true**, continue to
* loop labelled _outer_
* 4. If every value of _key_ in _found_ is **true** return **true**,
* else return **false**
*/
var found = {};
// The outer loop is specifically labelled to allow the
// nested condition a way out of the second and third loop.
outer:
for (var i = 0, ilen = criteria.terms.length; i < ilen; i++) {
var term = criteria.terms[i];
for (var j = 0, jlen = criteria.fields.length; j < jlen; j++) {
var field = criteria.fields[j];
if (!contact[field]) {
continue;
}
for (var k = 0, klen = contact[field].length; k < klen; k++) {
var value = contact[field][k];
if (typeof value.value !== 'undefined') {
value = value.value;
}
if ((found[term] = filterFn(value.trim(), term))) {
continue outer;
}
}
}
}
// Pending the publication of a list of ES6 features available in Firefox
// and deemed "safe enough" for FirefoxOS development, the above code is a
// stand-in for what should be written as:
// outer:
// for (var term of criteria.terms) {
// for (var field of criteria.fields) {
// for (var value of contact[field]) {
// if (found[term] = value.toLowerCase().contains(term)) {
// continue outer;
// }
// }
// }
// }
return Object.keys(found).every(function(key) {
return found[key];
});
}
var rspaces = /\s+/;
var Contacts = {
findBy: function contacts_findBy(filter, callback) {
var lower = [];
var filterValue = (filter.filterValue || '').trim();
var terms;
if (!navigator.mozContacts || !filterValue.length) {
/**
* Bailout Strategy
*
* 1. A _missing_ filter.filterValue will results in an
* error when passed to mozContacts.find, bailing out
* early avoids the additional overhead of making a
* request that we know will fail.
*
* 2. Missing navigator.mozContacts API or empty but
* present filter.filterValue make the app useless,
* so prevent unpredictable state by making the
* result a no-op.
*
*/
return Promise.resolve([]);
}
/**
* Lookup Strategy
*
* 1. Create a list of terms from the filterValue string
*
* 2. If only 1 term, set filterValue to that term.
*
* 3. If more than 1 term, find the length predominate term.
* a. let initial be an empty string
* (Do not use default first item in terms, or it will not
* be pushed into the lowercase list)
* b. For each term of terms
* i. Push lowercased term into a list of lowercased terms
* ii. If the current term.length is greater then initial.length,
* return the term, otherwise return the initial.
*
* 4. If filterValue.length is < 3, set the filterLimit to 10
*
* 5. Remove the length predominate term from the list of validation terms
*
* 6. Add back the original search value, this is needed for matching
* multi-word queries
*
* 7. Make a mozContact.find request with the length predominate term
* as the filter.filterValue.
*
* 8. In the mozContacts.find request success handler, filter the
* results by evaluating each contact record against
* the list of validation terms.
*/
// Step 1
terms = filterValue.split(rspaces);
filter.filterValue = terms.length === 1 ?
// Step 2
terms[0] :
// Step 3
terms.reduce(function(initial, term) {
// Step 3.b.i
// (used as a criteria list for isMatch)
lower.push(term.toLowerCase());
// Step 3.b.ii
return term.length > initial.length ? term : initial;
// Step 3.a
}, '');
// Step 4
if (filter.filterValue.length < 3) {
filter.filterLimit = 10;
}
// Step 5
lower.splice(lower.indexOf(filter.filterValue.toLowerCase()), 1);
// Step 6
// This would be much nicer using spread operator
lower.push.apply(lower, terms);
// Step 7
return navigator.mozContacts.find(filter).then((contacts) => {
var results;
// Step 8
if (terms.length > 1) {
var fields = ['tel', 'givenName', 'familyName'];
if (Settings.supportEmailRecipient) {
fields.push('email');
}
var criteria = { fields: fields, terms: lower };
results = contacts.filter(
(contact) => isMatch(contact, criteria, filterFns.contains)
);
} else {
results = contacts;
}
return results;
}).catch((e) => {
console.error('Got an error while looking up contacts.', e.name, e);
return [];
});
},
findByString: function contacts_findByString(filterValue) {
return Promise.all([
this.findContactByString(filterValue),
this.findByUnknown(filterValue)
]).then(([known, unknown]) => {
return (known || []).concat(unknown);
});
},
findContactByString: function contacts_findContactByString(filterValue) {
var props = ['tel', 'givenName', 'familyName'];
if (Settings.supportEmailRecipient) {
props.push('email');
}
return this.findBy({
filterBy: props,
filterOp: 'contains',
filterValue: filterValue
});
},
findByUnknown: function contacts_findByUnknown(filterValue) {
var list = [];
for (var i = 0, length = unknownNumbers.length; i < length; i++) {
//We only need at max 3 unknown contacts
if (list.length > 2) {
break;
}
var num = unknownNumbers[i];
if (num.includes(filterValue)) {
var obj = {
name: [num],
tel: [{value: num}],
source: 'unknown'
};
list.push(obj);
}
}
return Promise.resolve(list);
},
findExact: function contacts_findExact(filterValue) {
return this.findBy({
filterBy: ['givenName', 'familyName'],
filterOp: 'contains',
filterValue: filterValue
}).then((contacts) => {
var contact = contacts.length ? contacts[0] : null;
var criteria = {
fields: ['name'],
terms: [filterValue]
};
var isExact = false;
if (contact) {
isExact = isMatch(contact, criteria, filterFns.equality);
}
return isExact ? [contact] : [];
});
},
findByPhoneNumber: function contacts_findByPhone(filterValue) {
return this.findBy({
filterBy: ['tel'],
filterOp: 'match',
filterValue: filterValue.replace(/\s+/g, '')
}).then((contacts) => {
if (contacts.length) {
return contacts;
}
var defer = Utils.Promise.defer();
// Facebook search
fb.getContactByNumber(filterValue, function fbByPhone(fbContact) {
defer.resolve(fbContact ? [fbContact] : []);
}, function error_fbByPhone(err) {
if (err.name !== 'DatastoreNotFound') {
console.error('Error while retrieving fb by phone: ', err.name);
}
defer.resolve([]);
});
return defer.promise;
});
},
findByAddress: function contacts_findByAddress(fValue) {
if (Settings.supportEmailRecipient && Utils.isEmailAddress(fValue)) {
return this.findExactByEmail(fValue);
} else {
return this.findByPhoneNumber(fValue);
}
},
findExactByEmail: function contacts_findExactByEmail(fValue) {
return this.findBy({
filterBy: ['email'],
filterOp: 'equals',
filterValue: fValue
});
},
addUnknown: function addUnknown(number) {
var index = unknownNumbers.indexOf(number);
if (index === -1) {
unknownNumbers.push(number);
}
},
clearUnknown: function clearUnknown() {
unknownNumbers.length = 0;
},
getunknownLength: function getunknownLength() {
return unknownNumbers.length;
}
};
exports.Contacts = Contacts;
}(this));

View file

@ -0,0 +1,165 @@
/*global WeakMap */
/* exported Dialog */
(function(exports) {
'use strict';
/*
Generic confirm screen. Only 'cancel/default' is mandatory.
Text fields are localized using commonly defined parameter structure:
https://developer.mozilla.org/en-US/Firefox_OS/Developing_Gaia/
localization_code_best_practices#Writing_APIs_that_operate_on_L10nIDs + 'raw'
can be Node aw well.
The constructor parameter should follow the following structure:
{
title: { raw: 'Non localizable title' },
body: {
id: 'localizableStringWithArgument',
args: { n: count }
},
classes: ['specific-class'], // optional
options: {
// Cancel is a mandatory option. You need to define at least the text.
cancel: {
text: 'localizableCancelLabel'
},
confirm: {
text: 'localizableRemoveLabel',
method: function(params) {
fooFunction(params);
},
params: [arg1, arg2,....],
className: 'optionalClassName'
}
}
*/
// helper to localize an element given parameters
function createLocalizedElement(tagName, valueL10n) {
var element = document.createElement(tagName);
// if we passed an l10nId, use the l10n `setAttributes' method
if (typeof valueL10n === 'string') {
element.setAttribute('data-l10n-id', valueL10n);
} else if (valueL10n.id) {
document.l10n.setAttributes(element, valueL10n.id, valueL10n.args);
// if we passed in a HTML Fragment, it is already localized
} else if (valueL10n.raw && valueL10n.raw.nodeType) {
element.appendChild(valueL10n.raw);
// otherwise - stuff text in here...
} else {
element.textContent = valueL10n.raw;
}
return element;
}
var Dialog = function(params) {
// We need, at least, one cancel option string
if (!params || !params.options || !params.options.cancel) {
return;
}
var handlers = new WeakMap();
// Create the structure
this.form = document.createElement('form');
this.form.dataset.type = 'confirm';
this.form.setAttribute('role', 'dialog');
this.form.tabIndex = -1;
// Pick up option_menu.css styling
this.form.dataset.subtype = 'menu';
if (params.classes) {
this.form.classList.add(...params.classes);
}
var infoSection = document.createElement('section');
infoSection.appendChild(createLocalizedElement('h1', params.title));
infoSection.appendChild(createLocalizedElement('p', params.body));
this.form.appendChild(infoSection);
// Adding options. In this case we have a maximum of 2, with different styles
// per button
var menu = document.createElement('menu');
// Default button (Cancel button). It's mandatory
var cancelOption = params.options.cancel;
var cancelButton = createLocalizedElement('button', cancelOption.text);
handlers.set(cancelButton, cancelOption);
// If we have only button, let's mark it as recommended action.
if (!params.options.confirm) {
cancelButton.className = 'recommend';
}
menu.appendChild(cancelButton);
if (params.options.confirm) {
var confirmOption = params.options.confirm;
var confirmButton = createLocalizedElement('button', confirmOption.text);
confirmButton.className = params.options.confirm.className || 'recommend';
handlers.set(confirmButton, confirmOption);
menu.appendChild(confirmButton);
}
this.form.addEventListener('submit', function(event) {
event.preventDefault();
});
this.form.addEventListener('transitionend', function(event) {
var form = event.target;
if (!form.classList.contains('visible') && form.parentNode) {
form.remove();
} else {
// Focus form for accessibility
form.focus();
}
document.body.classList.remove('dialog-animating');
});
menu.addEventListener('click', function(event) {
var action = handlers.get(event.target);
if (!action) {
return;
}
if (action.method) {
// Delegate operation to target method. This allows
// for a custom "Cancel" to be provided by calling program
action.method.apply(null, action.params || []);
}
// Hide action menu when click is received
this.hide();
}.bind(this));
// Appending the action menu to the form
this.form.appendChild(menu);
};
// We prototype functions to show/hide the UI of action-menu
Dialog.prototype.show = function() {
// Remove the focus to hide the keyboard asap
document.activeElement && document.activeElement.blur();
if (!this.form.parentNode) {
document.body.appendChild(this.form);
// Flush style on form so that the show transition plays once we add
// the visible class.
this.form.clientTop;
}
this.form.classList.add('visible');
document.body.classList.add('dialog-animating');
};
Dialog.prototype.hide = function() {
this.form.classList.remove('visible');
};
exports.Dialog = Dialog;
// end global closure
}(this));

View file

@ -0,0 +1,111 @@
/*global Dialog, Settings*/
/* exported ErrorDialog */
(function(exports) {
'use strict';
/*
Error confirm screen. Handling all the message error code and corresponding
description/behavior.
Options object can contain the following properties:
* recipients: ['foo', 'bar'] - to show the recipient information, used only
if errorDescription.showRecipient is equal to "true";
* confirmHandler: function() - to be executed if user selects confirm
action in the error dialog, used only if errorDescription.executeHandler
is equal to "true".
}
*/
function ErrorDialog(errorDescription, options) {
var prefix = errorDescription.prefix;
if (!prefix) {
throw new Error('Prefix is required!');
}
var dialogOptions = {
cancel: {
text: prefix + 'BtnOk'
}
};
if (options && options.confirmHandler && errorDescription.executeHandler) {
dialogOptions.confirm = {
text: prefix + 'Confirm',
method: options.confirmHandler
};
}
var bodyL10nId = prefix + 'Body';
var body = null;
var additionalClasses = [];
if (errorDescription.showRecipient) {
var fragment = document.createDocumentFragment();
var mainText = document.createElement('span');
document.l10n.setAttributes(
mainText, bodyL10nId,
{ n: options.recipients.length }
);
fragment.appendChild(mainText);
var list = options.recipients.reduce((list, recipient) => {
var li = document.createElement('li');
li.textContent = recipient;
list.appendChild(li);
return list;
}, document.createElement('ul'));
fragment.appendChild(list);
body = { raw: fragment };
additionalClasses.push('error-dialog-show-recipient');
} else if (errorDescription.showDsdsStatus) {
var messageBodyParams = null;
var mmsServiceId = Settings.mmsServiceId;
if (mmsServiceId !== null) {
// mmsServiceId = 0 => default Service is SIM 1
// mmsServiceId = 1 => default Service is SIM 2
switch (mmsServiceId) {
case 0:
messageBodyParams = {
activeSimId: '1',
nonActiveSimId: '2'
};
break;
case 1:
messageBodyParams = {
activeSimId: '2',
nonActiveSimId: '1'
};
break;
default:
console.error(
'MMS ServiceId(' + mmsServiceId + ') is not matched!'
);
}
} else {
console.error('Settings unavailable');
}
body = {
id: bodyL10nId,
args: messageBodyParams
};
}
Dialog.call(this, {
title: prefix + 'Title',
body: body || bodyL10nId,
options: dialogOptions,
classes: ['error-dialog-' + prefix, 'error-dialog', ...additionalClasses]
});
}
ErrorDialog.prototype = Object.create(Dialog.prototype);
exports.ErrorDialog = ErrorDialog;
}(this));

View file

@ -0,0 +1,81 @@
/* exported Errors */
(function(exports) {
'use strict';
/**
* Maps error code to error description object:
* prefix: used as prefix for error message text/title l10nId,
* showRecipient: indicates whether recipients info should be included into
* error message displayed to the user;
* showDsdsStatus: indicates whether DSDS status should be included into
* error message displayed to the user;
* executeHandler: indicates whether confirmation handler passed to the
* error dialog should be executed once user selects confirm action.
* @type {Map.<string, Object>}
*/
const ERRORS = new Map();
function initErrorsMap() {
ERRORS.set('NoSimCardError', {
prefix: 'sendMissingSimCard'
});
ERRORS.set('RadioDisabledError', {
prefix: 'sendFlightMode'
});
ERRORS.set('FdnCheckError',{
prefix: 'fdnError',
showRecipient: true
});
ERRORS.set('NonActiveSimCardError', {
prefix: 'switchSimToRetrieve',
showDsdsStatus: true,
executeHandler: true
});
ERRORS.set('NoSignalError', {
prefix: 'sendNoSignalError'
});
ERRORS.set('NotFoundError', {
prefix: 'sendNotFoundError'
});
ERRORS.set('InvalidAddressError', {
prefix: 'sendInvalidAddressError'
});
ERRORS.set('SimNotMatchedError', {
prefix: 'simNotMatchedError'
});
ERRORS.set('NonActiveSimCardToSendError', {
prefix: 'switchSimToSend',
showDsdsStatus: true,
executeHandler: true
});
ERRORS.set('RadioDisabledToDownloadError', {
prefix: 'radioDisabledToDownload'
});
// The error type below will be represented as "GeneralError" in dialog for
// the rest of error codes (InternalError and etc.)
ERRORS.set('UnknownError', {
prefix: 'sendDefaultError'
});
}
exports.Errors = {
get: function(errorCode) {
if (!ERRORS.size) {
initErrorsMap();
}
return ERRORS.get(errorCode) || ERRORS.get('UnknownError');
}
};
})(window);

View file

@ -0,0 +1,83 @@
/* global BroadcastChannel,
EventDispatcher
*/
/* exported InterInstanceEventDispatcher */
(function(exports) {
'use strict';
/**
* Channel that is used to broadcast messages between message app instances.
* @type {BroadCastChannel}
*/
var channel = null;
const BROADCAST_CHANNEL_NAME = 'messages-channel';
const ALLOWED_EVENTS = ['draft-saved', 'draft-deleted'];
function postMessage(name, parameters) {
if (!channel) {
throw new Error('Channel is not created!');
}
if (!name) {
throw new Error('Name should be defined!');
}
channel.postMessage(parameters !== undefined ?
{ name: name, parameters: parameters } :
{ name: name }
);
}
const Dispatcher = EventDispatcher.mixin({
connect: function() {
if (channel) {
return;
}
channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.addEventListener('message', onMessage);
exports.addEventListener('unload', () => this.disconnect());
},
disconnect: function() {
if (!channel) {
return;
}
channel.close();
channel.removeEventListener('message', onMessage);
channel = null;
}
}, ALLOWED_EVENTS);
// Save and override original emit method to avoid firing events in the same
// application instance.
const originalEmitter = Dispatcher.emit.bind(Dispatcher);
Dispatcher.emit = function(eventName, parameters) {
if (ALLOWED_EVENTS.indexOf(eventName) < 0) {
throw new Error('Event "' + eventName + '" is not allowed!');
}
postMessage(eventName, parameters);
};
function onMessage(message) {
try {
originalEmitter(message.data.name, message.data.parameters);
} catch (error) {
console.error(
'Error while emitting event "%s" with parameters "%s"',
message.data && message.data.name,
JSON.stringify(message.data && message.data.parameters),
error
);
}
}
exports.InterInstanceEventDispatcher = Dispatcher;
})(window);

View file

@ -0,0 +1,93 @@
/* exported LocalizationHelper */
(function(exports) {
'use strict';
/**
* Look for attachment iframes and localize them - l20n.js doesn't do this
* XXX: remove once bug 1020130 is fixed
*/
function localizeAttachmentIFrames() {
var attachmentContainers = Array.from(document.querySelectorAll(
'iframe.attachment-container'
));
return Promise.all(
attachmentContainers.map(localizeAttachmentIFrame)
);
}
/**
* Localize an attachment iframe
*/
function localizeAttachmentIFrame(attachmentContainer) {
var doc = attachmentContainer.contentDocument;
doc.documentElement.lang = document.documentElement.getAttribute('lang');
doc.documentElement.dir = document.documentElement.getAttribute('dir');
return document.l10n.translateFragment(doc.body);
}
/**
* Look for time elements with format and re-translate the date message.
* More complex because the argument to the l10n string is itself a formatted
* date using l10n.
* @param {boolean} isTimeFormatChanged Indicates that time format has been
* changed and we need to update only elements with time aware formats.
*/
function localizeDateTime(isTimeFormatChanged) {
var dateTimeElements = document.querySelectorAll('.l10n-contains-date');
Array.forEach(dateTimeElements, function(element) {
if (!element.dataset.l10nDate) {
return;
}
var formatL10nId = element.dataset.l10nDateFormat;
if (!formatL10nId) {
return;
}
formatL10nId = JSON.parse(formatL10nId);
if (isTimeFormatChanged && !formatL10nId.hour) {
return;
}
formatL10nId.hour12 = navigator.mozHour12;
var formatter = new Intl.DateTimeFormat(
navigator.languages, formatL10nId
);
var localeData = formatter.format(new Date(+element.dataset.l10nDate));
if (element.hasAttribute('data-l10n-id') &&
element.hasAttribute('data-l10n-args')) {
var l10nAttrs = document.l10n.getAttributes(element);
l10nAttrs.args.date = localeData;
document.l10n.setAttributes(element, l10nAttrs.id, l10nAttrs.args);
} else {
element.textContent = localeData;
}
});
}
exports.LocalizationHelper = {
init: function () {
function localize() {
localizeDateTime();
return localizeAttachmentIFrames();
}
// After startup, call localize every time the language changes
document.addEventListener('DOMRetranslated', localize);
// This event is fired when time format (12h/24h) is changed
window.addEventListener(
'timeformatchange',
localizeDateTime.bind(null, /* isTimeFormatChanged */ true)
);
// Call localize during startup
return document.l10n.ready.then(localize);
}
};
})(window);

View file

@ -0,0 +1,906 @@
/*global
EventDispatcher,
Utils
*/
(function(window) {
'use strict';
/* The factory will be used in unit tests. */
var NavigationFactory = window.NavigationFactory = function(window) {
/**
* @function
* The debug function translates to console.log in debug mode.
*
* @param {String} arg1 The first value to be displayed to the console.
* @param {...*} args The other values to be displayed to the console, or the
* parameters to the first string.
*/
const debug = [
() => {},
(arg1, ...args) => console.log('[Navigation] ' + arg1, ...args),
(arg1, ...args) =>
console.log('[Navigation] (%s) ' + arg1, Date.now(), ...args),
][0];
/**
* This contains an array of view characteristics, necessary to handle
* navigation requests.
* Each item has the following properties:
* - name: this is the name of the view. This will be used also in the URL
* hash if the same HTML file holds several views.
* - url: this is the URL used to match the current location and find the
* current view.
* - behavior: this is the actual JavaScript object that manages this view.
* - previous (optional): this is the name of the previous view; this is
* necessary to decide if we do a forward/backward animation.
* - partOf (optional): this is used when a view is part of another view's
* HTML document. In that case one view is "master" and the other are only
* part of the master view.
*
* NGA_VIEWS holds the configuration for NGA split views.
*/
const NGA_VIEWS = Object.freeze([
Object.freeze({
name: 'thread',
url: 'views/conversation/index.html',
behavior: 'ConversationView',
previous: 'thread-list'
}),
Object.freeze({
name: 'thread-list',
url: 'views/inbox/index.html',
behavior: 'InboxView'
}),
Object.freeze({
name: 'report-view',
behavior: 'ReportView',
previous: 'thread',
partOf: 'thread'
}),
Object.freeze({
name: 'composer',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread'
}),
Object.freeze({
name: 'group-view',
behavior: 'GroupView',
previous: 'thread',
partOf: 'thread'
})
]);
/**
* OA_VIEWS holds the configuration for the old non-split architecture.
*/
const OA_VIEWS = Object.freeze([
Object.freeze({
name: 'thread-list',
url: 'index.html',
behavior: 'InboxView'
}),
Object.freeze({
name: 'thread',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread-list'
}),
Object.freeze({
name: 'composer',
behavior: 'ConversationView',
previous: 'thread-list',
partOf: 'thread-list'
}),
Object.freeze({
name: 'group-view',
behavior: 'GroupView',
previous: 'thread',
partOf: 'thread-list'
}),
Object.freeze({
name: 'report-view',
behavior: 'ReportView',
previous: 'thread',
partOf: 'thread-list'
})
]);
const VIEWS = isUsingOldArchitecture() ? OA_VIEWS : NGA_VIEWS;
const priv = {
pendingInit: Symbol('pendingInit')
};
var currentState;
var navigationTransition;
/**
* Navigate to this URL.
*
* @param {String} url URL to load.
*/
function setLocation(url) {
debug('Setting location', url);
window.location.assign(url);
}
/**
* We're using the old architecture if we're not in a split document.
*
* @returns {Boolean} true if we're using the old architecture.
*/
function isUsingOldArchitecture() {
debug('isUsingOldArchitecture()');
var pathname = window.location.pathname;
var result = (pathname === '/' || pathname === '/index.html');
debug('isUsingOldArchitecture() =>', result);
return result;
}
/**
* Parses hash to find a view name.
* It takes the part of the hash between '/' or the start of the string, and '?'
* or the end of the string.
*
* Examples:
* #/conversation?id=5 => conversation
* #/composer?recipient=+33615647897 => composer
* #/composer => composer
*
* @param {String} hash The string to analyze.
* @returns {String} The analyzed string. It is not guaranteed to be an existing
* view name.
*/
function findViewNameFromHash(hash) {
debug('findViewNameFromHash(%s)', hash);
if (!hash || !hash.startsWith('#/')) {
return null;
}
var endOfViewName = hash.indexOf('?');
if (endOfViewName < 0) {
endOfViewName = undefined;
}
return hash.slice(2, endOfViewName);
}
/**
* Tries to find the correct view from the location.
*
* First it tries to find the view using the location's hash.
* If findViewNameFromHash returns something, we try to match an existing view
* from this name, then we check whether the found view is indeed part of this
* document using the full URL.
*
* If findViewNameFromHash returns nothing, we try to find a view from the full
* URL.
*
* @param {Location} location The content of window.location.
* @returns {Object} The found view, or null.
*/
function findViewFromLocation(location) {
debug('findViewFromLocation(%s)', location.href);
var pathName = location.pathname;
if (pathName.endsWith('/')) {
pathName += 'index.html';
}
var hash = location.hash;
var viewNameFromHash = findViewNameFromHash(hash);
var viewFromHash = findViewFromName(viewNameFromHash);
if (viewNameFromHash && !viewFromHash) {
console.error('We found no view from the hash %s', hash);
return null;
}
var viewFromPath = VIEWS.find(
(view) => pathName.endsWith('/' + view.url)
) || null;
if (!viewFromPath) {
console.error('We found no view from the path %s', pathName);
return null;
}
if (viewFromHash && viewFromHash.partOf &&
viewFromHash.partOf !== viewFromPath.name) {
console.error(
'The view %s found from the hash %s is not part of view %s',
viewFromHash.name, hash, viewFromPath.name
);
viewFromHash = null;
}
return viewFromHash || viewFromPath;
}
/**
* Finds a view from its name.
*
* @param {String} name Name of the view to find.
* @returns {Object} The found view, or null.
*/
function findViewFromName(name) {
debug('findViewFromName(%s)', name);
var view = VIEWS.find((view) => view.name === name) || null;
debug('findViewFromName() => %s', view && view.name);
return view;
}
/**
* Finds the container view of a view.
*
* @param {Object} view A view or a subview.
* @returns {Object} the container view of the parameter view. It can be the
* same object if the parameter is itself the container view.
*/
function findContainerView(view) {
debug('findContainerView(%s)', view.name);
if (view.partOf) {
debug('findContainerView: %s is part of view %s', view.name, view.partOf);
view = findViewFromName(view.partOf);
}
debug('findContainerView() => %s', view.name);
return view;
}
/**
* @type {Defer}
* Used at startup time to indicate when the app loaded all dependencies.
* Will be nullified once the app is ready.
* @see Navigation.setReady
* @see waitForReady
*/
var readyDefer = Utils.Promise.defer();
/**
* @returns {Promise} Resolved when the app is ready, rejected if the app is
* ready at call time.
*/
function waitForReady() {
if (!readyDefer) {
return Promise.reject(
new Error('waitForReady was called but status is ready already.')
);
}
return readyDefer.promise;
}
function startNavigationFromCurrentLocation() {
var location = window.location;
debug('startNavigationFromCurrentLocation(), location=%s', location);
var newPanel = findViewFromLocation(location);
if (!newPanel) {
return Promise.reject(
new Error('Couldn\'t find a view from location ' + location.href)
);
}
var args = Utils.params(decodeURIComponent(location.hash));
debug(
'startNavigationFromCurrentLocation:',
'found panel', newPanel.name, 'with args', args
);
return startNavigation({ newPanel, args });
}
/**
* Sets up the state to handle the navigation.
* Generally we're waiting for any ongoing transition to end before starting a
* new one. However if the ongoing transition was started with opts.partial ==
* true, we merely augment the ongoing transition with the new information,
* especially arguments.
*
* @param {Object} opts options
* @param {String} [opts.newPanel] The panel we'll switch to. This can be
* omitted in case we only want to execute beforeLeave.
* @param {Object} [opts.args] The arguments we'll use for the next panel.
* @param {Boolean} [opts.partial] If present and true, this new navigation
* request is a partial one missing information (eg arguments) and we'll call
* startNavigation once again without this property once we'll know the missing
* bits.
*
* @returns {Promise} Resolved when we could start the navigation (when a
* previous ongoing one is finished).
*/
function startNavigation(opts) {
var { newPanel, args, partial } = opts;
args = args || {};
debug(
'startNavigation(newPanel=%s, partial=%s)',
newPanel && newPanel.name, partial
);
if (navigationTransition && (!navigationTransition.partial || partial)) {
debug('startNavigation: previous navigation exists, deferring.');
return navigationTransition.defer.promise.then(
() => startNavigation(opts)
);
}
var newContainerView = newPanel && findContainerView(newPanel);
var containerView = currentState && findContainerView(currentState.panel);
var leaveDocument = currentState && containerView !== newContainerView;
if (newPanel && !leaveDocument) {
var behaviorObject = window[newPanel.behavior];
var viewReady =
behaviorObject &&
(typeof behaviorObject.isReady !== 'function' ||
behaviorObject.isReady());
if (!viewReady) {
// This panel is not ready yet, let's wait until the app is ready.
return waitForReady().then(
() => startNavigation(opts)
);
}
}
// Remove the focus before navigating.
document.activeElement.blur();
if (!navigationTransition) {
navigationTransition = {
stepsRun: [],
defer: Utils.Promise.defer()
};
}
navigationTransition.partial = partial;
navigationTransition.leaveDocument = leaveDocument;
if ('meta' in args) {
throw new Error('Arguments cannot have a `meta` property.');
}
navigationTransition.transitionArgs = {
meta: {
next: newPanel ? {
panel: newPanel.name,
args
} : null,
prev: currentState ? {
panel: currentState.panel.name,
args: currentState.args
} : null
}
};
Object.assign(navigationTransition.transitionArgs, args);
navigationTransition.states = {
from: currentState,
to: {
panel: newPanel,
args
}
};
return Promise.resolve();
}
/**
* Sets the currentState variable to its new value.
*/
function setFutureState() {
debug('setFutureState()');
currentState = navigationTransition.states.to;
}
/**
* Ends the navigation so that new navigations can take place.
* This cleans up the navigation states.
*/
function endNavigation() {
debug('endNavigation()');
if (navigationTransition) {
navigationTransition.defer.resolve();
navigationTransition = null;
}
}
/**
* This function is used to catch errors at the end of navigation promise
* chains. We're ending the navigation if this happens, so that we can start a
* new one afterwards.
*
* @param {Error} [e] The exception to display, if any.
*/
function onNavigationError(e = new Error('RejectedPromise')) {
console.error('Navigation error', e);
endNavigation();
return Promise.reject(e);
}
/**
* This function is used as a rejection handler, to swallow errors and log them.
*
* @param {Exception} [e] The exception to display, if any.
*/
function catchStepError(e) {
if (e) {
console.error(
'[Navigation] got an exception: (%s) %s',
e, e.stack && e.stack.replace(/\n/g, '|')
);
} else {
debug('[Navigation] got a rejected promise');
}
}
/**
* Executes the lifecycle function for `stepName` on the right object.
*
* @param {String} stepName The step to execute: one of 'beforeLeave',
* 'beforeEnter', 'afterLeave', 'afterEnter'.
* @returns {Promise} Resolved when the step returns a value or a resolved
* promise, rejected when the step either throws or returns a rejected promise.
* Or a resolved promise if no step was executed (because it was already
* executed or there was no function to execute).
*/
function executeNavigationStep(stepName) {
debug('executeNavigationStep(%s)', stepName);
if (!navigationTransition) {
return Promise.reject(new Error(
'Trying to execute the navigation step ' + stepName +
' but no transition is started yet.'
));
}
if (navigationTransition.stepsRun.indexOf(stepName) >= 0) {
// this step has already run (case of back -> onHashChange)
debug('executeNavigationStep: step %s has already run.', stepName);
return Promise.resolve();
}
var stateKey = stepName.endsWith('Leave') ? 'from' : 'to';
var state = navigationTransition.states[stateKey];
if (!state) {
debug('Nothing to execute for step', stepName);
return Promise.resolve();
}
var panel = state.panel;
var resultPromise = Promise.resolve();
var viewObject = window[panel.behavior];
if (viewObject[stepName]) {
// encapsulating as a then handler to catch exceptions.
resultPromise = resultPromise.then(
() => viewObject[stepName](navigationTransition.transitionArgs)
);
}
return resultPromise.then(() => {
navigationTransition.stepsRun.push(stepName);
debug(`Finished executeNavigationStep(${stepName})`);
});
}
/**
* Used in `waitForPanelAnimation` to help solving race conditions.
* @type {Defer}
*/
var previousAnimationDefer;
/**
* Wait for the end of an animation on `panelElement`.
*
* @param {Element} panelElement The element to watch.
* @returns {Promise} Resolves when the animation ends.
*/
function waitForPanelAnimation(panelElement) {
if (previousAnimationDefer) {
previousAnimationDefer.reject(new Error('A new animation started'));
}
var defer = Utils.Promise.defer();
previousAnimationDefer = defer;
var timeout;
function onAnimationEnd(e) {
panelElement.removeEventListener('animationend', onAnimationEnd);
clearTimeout(timeout);
debug('animationend', e && e.type);
defer.resolve();
}
panelElement.addEventListener('animationend', onAnimationEnd);
timeout = setTimeout(onAnimationEnd, 500);
return defer.promise.then(() => previousAnimationDefer = null);
}
/**
* Hides current panel, shows new panel.
* It will use an animation if we change panels in the same document.
*
* @returns {Promise} Resolves when the animation is over, if there is an
* animation. Otherwise returns a resolved promise.
*/
function switchPanel() {
debug('switchPanel()');
if (!navigationTransition) {
throw new Error(
'Trying to switch panel but no transition is started yet.'
);
}
var from = navigationTransition.states.from;
var to = navigationTransition.states.to;
var oldView;
var oldPanelElement;
if (from) {
oldView = from.panel;
oldPanelElement = document.querySelector(`.panel-${oldView.behavior}`);
if (!oldPanelElement) {
throw new Error(
'Couldn\'t find the container element for view ' + oldView.name
);
}
}
var newView = to.panel;
if (!newView) {
throw new Error(
'Trying to switch panel, but no new view is defined!'
);
}
var newPanelElement = document.querySelector(`.panel-${newView.behavior}`);
if (!newPanelElement) {
return Promise.reject(new Error(
'Couldn\'t find the container element for view ' + newView.name
));
}
if (newPanelElement === oldPanelElement) {
return Promise.resolve();
}
var isGoingBack = oldView && oldView.previous === newView.name;
newPanelElement.style = '';
newPanelElement.classList.remove('panel-hidden');
newPanelElement.classList.add('panel-active');
var doSlideAnimation =
from && from.panel &&
from.panel.behavior !== newView.behavior;
var doFadeInAnimation = !from || !from.panel;
var animationPromise;
if (doSlideAnimation) {
var shouldGoRight = isGoingBack ^ (document.dir === 'rtl');
newPanelElement.style.animationName =
shouldGoRight ? 'left-to-current' : 'right-to-current';
oldPanelElement.style.animationName =
shouldGoRight ? 'current-to-right' : 'current-to-left';
} else if (doFadeInAnimation) {
newPanelElement.style.animationName = 'fade-in';
}
if (doFadeInAnimation || doSlideAnimation) {
animationPromise = waitForPanelAnimation(newPanelElement).catch(
() => {}
).then(() => {
if (oldPanelElement) {
oldPanelElement.style = '';
}
newPanelElement.style = '';
});
}
return (animationPromise || Promise.resolve()).then(() => {
if (oldPanelElement) {
oldPanelElement.classList.remove('panel-active');
oldPanelElement.setAttribute('aria-hidden', 'true');
}
newPanelElement.setAttribute('aria-hidden', 'false');
});
}
/**
* Reacts to hash changes.
*
* This is the heart of the navigation logic and handles running the various
* navigation steps.
* It also handles any unwanted errors. (TODO: needs to tune this a little
* more).
*/
function onHashChange() {
debug('onHashChange, location=', window.location);
startNavigationFromCurrentLocation().then(
() => executeNavigationStep('beforeLeave')
).then(
() => executeNavigationStep('beforeEnter').catch(catchStepError)
).then(
setFutureState
).then(
switchPanel
).then(
() => executeNavigationStep('afterEnter').catch(catchStepError)
).then(
() => executeNavigationStep('afterLeave').catch(catchStepError)
).then(
endNavigation
).then(
() => Navigation.emit('navigated')
).catch(onNavigationError); // TODO reset to previous hash or call back ?
}
/**
* Attaches the hashchange event handler.
*/
function attachHistoryListener() {
window.addEventListener('hashchange', onHashChange);
}
/**
* Removes the hashchange event handler.
*/
function detachHistoryListener() {
window.removeEventListener('hashchange', onHashChange);
}
var Navigation = {
/**
* Calls `window.back` after running the `beforeLeave` step.
*
* We'll either (1) stay in the same document, or (2) move back to an earlier
* document.
*
* In case 1), this will only change the hash, and then the onHashChange event
* handler takes care of the end of the transition.
* In case 2), the full URL will change, and the new window's init method will
* take care of the end of the transition.
*
* @returns {Promise} Resolves when the action is done, if we stay in the same
* document.
*/
back() {
debug('back()');
return startNavigation({ partial: true }).then(
() => executeNavigationStep('beforeLeave')
).then(
() => window.history.back()
).then(
() => navigationTransition.defer.promise,
onNavigationError
);
// todo reset navigation on "pageshow" event ?
},
/**
* Lifecycle methods are generally called in this order:
* - previousPanel.beforeLeave
* - nextPanel.beforeEnter
* - nextPanel.afterEnter
* - previousPanel.afterLeave
*
* If we stay in the same document and enter a new panel, the panels are slid
* between beforeEnter and afterLeave.
*
* With the new architecture, the navigation can happen in one of 2 cases:
* 1. either we stay in the same document,
* 2. or we move to another document.
*
* In case 1), we only change the hash, and then the onHashChange event
* handler takes care of the end of the transition.
* In case 2), we change the full URL, and the new window's init method will
* take care of the end of the transition.
*
* In all cases:
* - beforeLeave can prevent changing the location and thus the
* navigation. It is expected that beforeLeave does not change any state but
* merely checks whether the transition can take place.
* - beforeEnter will be called before the view is displayed. Therefore it can
* make visual changes but it can't prevent a view from being entered. All
* rejections and exceptions will be swallowed and output to the console.
* - afterEnter does left-over actions
* - afterLeave needs to clean up the panel, reclaim memory. It's called only
* when the user stays in the same document.
*
* toPanel itself returns a Promise that will be resolved if the transition
* happened and rejected otherwise.
*
* @param {String} viewName The view to load.
* @param {Object} [args] The arguments to pass to the view.
* @returns {Promise} Resolves when the transition is over, if we stay in the
* same document.
*/
toPanel(viewName, args) {
debug('toPanel(%s)', viewName);
var view = findViewFromName(viewName);
if (!view) {
return Promise.reject(
new Error(`Couldn't find view ${viewName}`)
);
}
if (this.isCurrentPanel(viewName, args)) {
debug('View %s is the current view.', viewName);
return Promise.resolve();
}
if (navigationTransition) {
debug('toPanel: previous navigation exists, deferring.');
return navigationTransition.defer.promise.then(
() => this.toPanel(viewName, args)
);
}
// Set pendingInit to false if we start to navigate to other panel.
this[priv.pendingInit] = false;
var hash = '#';
if (view.partOf) {
hash += '/' + viewName;
}
hash = Utils.url(hash, args);
return startNavigation(
{ newPanel: view, args, partial: true }
).then(
() => executeNavigationStep('beforeLeave')
).then(() => {
var nextLocation;
if (navigationTransition.leaveDocument) {
var containerView = findContainerView(view);
nextLocation = '/' + containerView.url + hash;
} else {
nextLocation = hash;
}
setLocation(nextLocation);
}).then(
() => navigationTransition.defer.promise,
onNavigationError
);
// TODO do something on pageshow?
//
},
/**
* This is called when a view is started.
*
* It tries to find the right view from the location, and then initiates a
* navigation from this.
*/
init() {
debug('init()');
attachHistoryListener();
// Early reject when app is started via notification
if (navigator.mozHasPendingMessage('notification')) {
this[priv.pendingInit] = true;
return Promise.reject();
}
this[priv.pendingInit] = false;
return startNavigationFromCurrentLocation().then(
// right away as we don't execute anything on the previous panel, and we
// need a state at startup.
setFutureState
).then(
() => executeNavigationStep('beforeEnter').catch(catchStepError)
).then(
switchPanel
).then(
() => executeNavigationStep('afterEnter').catch(catchStepError)
).then(
endNavigation
).then(
() => Navigation.emit('navigated')
).catch(onNavigationError);
},
[priv.pendingInit]: false,
hasPendingInit() {
return this[priv.pendingInit];
},
/* will be used by tests */
cleanup() {
detachHistoryListener();
},
/**
* Checks if current location.hash corresponds to default panel. This is
* sometimes called before we resolve the state, in that case resolve
* directly using findViewNameFromHash. Otherwise we use the resolved state.
*
* @returns {boolean} True if current location.hash corresponds to default
* panel.
*/
isDefaultPanel() {
return currentState ?
!currentState.panel.partOf :
!findViewNameFromHash(window.location.hash);
},
/**
* Called with a first argument that is not an object, it returns whether the
* current panel is this first argument.
*
* Called with 2 arguments, the first one is not an object, the second one
* must be an object. It returns whether the current panel is the first
* argument, and for each property of the second argument, whether its value
* is equal to the value for the current panel's arguments' same property.
*
* @param {String} panelName Checks if the current panel is this panelName.
* @param {Object} [args] Checks against the current panel enter
* arguments.
* @returns {boolean} Result of the comparison.
*/
isCurrentPanel(panelName, args) {
debug('isCurrentPanel(%s)', panelName);
if (!currentState || !panelName) {
return false;
}
if (panelName !== currentState.panel.name) {
return false;
}
if (!args) {
return true;
}
var currentArgs = currentState.args || {};
for (var arg in args) {
if (args[arg] != currentArgs[arg]) {
return false;
}
}
return true;
},
/**
* Called by startup scripts to indicate when all lazy loaded files are
* downloaded.
*
* TODO: lazyload the necessary files directly in this file.
*/
setReady() {
debug('setReady()');
Array.from(document.querySelectorAll('.panel-hidden')).forEach((panel) => {
panel.classList.remove('panel-hidden');
});
readyDefer.resolve();
readyDefer = null;
}
};
return EventDispatcher.mixin(Navigation, ['navigated']);
};
window.Navigation = NavigationFactory(window);
})(window);

View file

@ -0,0 +1,101 @@
/*global SettingsURL */
(function(exports) {
'use strict';
var SETTINGS = {
notificationVolume: 'audio.volume.notification',
vibration: 'vibration.enabled',
ringtone: 'notification.ringtone'
};
function entry(key, value) {
if (urls[key]) {
return new SettingsURL().set(value);
}
return value;
}
// one day we'll be able to use ES6 initializers instead
var urls = {};
urls[SETTINGS.ringtone] = 1;
function getSetting(key) {
if (!navigator.mozSettings) {
return Promise.reject(new Error('The mozSettings API is not available.'));
}
var request = navigator.mozSettings.createLock().get(key);
return new Promise((resolve, reject) => {
request.onsuccess = function() {
resolve(entry(key, request.result[key]));
};
request.onerror = function() {
reject(new Error('Error while retrieving ' + key));
};
});
}
function getSettings(settings) {
return Promise.all(
settings.map(getSetting)
).catch((e) => {
// catch and log errors
console.error('Error while retrieving settings', e.message, e);
return settings.map(() => null);
}).then((results) => {
return settings.reduce((result, setting, i) => {
result[setting] = results[i];
return result;
}, {});
});
}
function ringtone(ringtoneFile) {
var ringtonePlayer = new Audio();
ringtonePlayer.src = ringtoneFile;
ringtonePlayer.mozAudioChannelType = 'notification';
ringtonePlayer.play();
window.setTimeout(function smsRingtoneEnder() {
ringtonePlayer.pause();
ringtonePlayer.removeAttribute('src');
ringtonePlayer.load();
}, 2000);
}
function vibrate() {
// vibration only works when App is in the foreground
if (document.hidden) {
window.addEventListener('visibilitychange', function waitOn() {
window.removeEventListener('visibilitychange', waitOn);
navigator.vibrate([200, 200, 200, 200]);
});
} else {
navigator.vibrate([200, 200, 200, 200]);
}
}
var Notify = {
ringtone: function notification_ringtone() {
return getSettings(
[SETTINGS.notificationVolume, SETTINGS.ringtone]
).then((settings) => {
if (settings[SETTINGS.notificationVolume] &&
settings[SETTINGS.ringtone]) {
ringtone(settings[SETTINGS.ringtone]);
}
});
},
vibrate: function notification_vibrate() {
return getSettings([SETTINGS.vibration]).then((settings) => {
if (settings[SETTINGS.vibration]) {
vibrate();
}
});
}
};
exports.Notify = Notify;
}(this));

View file

@ -0,0 +1,138 @@
/*exported SelectionHandler */
(function(exports) {
'use strict';
const REQUIRED_KEYS = [
// elements
'container',
'checkUncheckAllButton',
// methods
'updateSelectionStatus',
'getIdIterator',
'isInEditMode'
];
var SelectionHandler = function constructor(options) {
// Set the necessary properties and method for selection module and throw
// error if not existed in options.
if (typeof options !== 'object') {
throw new Error('options should be a valid object');
}
REQUIRED_KEYS.forEach((key) => {
if (options[key] === undefined) {
throw new Error('Selection options does not provide required key ' + key);
}
this[key] = options[key];
});
this.selected = new Set();
this.container.addEventListener(
'click', this.onSelected.bind(this)
);
this.checkUncheckAllButton.addEventListener(
'click', this.toggleCheckedAll.bind(this)
);
};
SelectionHandler.prototype = {
get selectedCount() {
return this.selected.size;
},
get selectedList() {
return Array.from(this.selected);
},
get inputIdSet() {
var itemIdSet = new Set();
for(var id of this.getIdIterator()) {
itemIdSet.add(id.toString());
}
return itemIdSet;
},
allSelected: function sel_allSelected() {
/*jshint unused: false */
var allItemCount = 0;
var selectedCount = this.selectedCount;
for (var id of this.getIdIterator()) {
if (++allItemCount > selectedCount) {
return false;
}
}
return true;
},
onSelected: function sel_onSelected(event) {
if (!this.isInEditMode()) {
return;
}
var target = event.target;
var value = target.value;
var existed = this.selected.has(value);
if (target.checked && !existed) {
this.selected.add(value);
} else if (!target.checked && existed) {
this.selected.delete(value);
} else {
// Don't emit event if no selection change
return;
}
this.updateSelectionStatus();
},
select: function sel_select(id) {
this.selected.add('' + id);
},
unselect: function sel_unselect(id) {
this.selected.delete('' + id);
},
// Update check status of input elements in the container
updateCheckboxes: function sel_updateCheckboxes() {
var inputs = this.container.querySelectorAll('input[type=checkbox]');
var length = inputs.length;
for (var i = 0; i < length; i++) {
inputs[i].checked = this.selected.has(inputs[i].value);
}
},
// if no message or few are checked : select all the messages
// and if all messages are checked : deselect them all.
toggleCheckedAll: function sel_toggleCheckedAll() {
var allInputs = this.inputIdSet;
var allSelected = (this.selected.size === allInputs.size);
if (allSelected) {
this.selected.clear();
} else {
this.selected = allInputs;
}
this.updateCheckboxes();
this.updateSelectionStatus();
},
cleanForm: function sel_cleanForm() {
// Reset all inputs
this.selected.clear();
this.updateCheckboxes();
// Reset vars for deleting methods
this.updateSelectionStatus();
}
};
exports.SelectionHandler = SelectionHandler;
}(this));

View file

@ -0,0 +1,180 @@
/*
Message app settings related value and utilities.
*/
/* exported Settings */
'use strict';
var Settings = {
SERVICE_ID_KEYS: {
mmsServiceId: 'ril.mms.defaultServiceId',
smsServiceId: 'ril.sms.defaultServiceId'
},
READ_AHEAD_THREADS_KEY: 'ril.sms.maxReadAheadEntries',
// we evaluate to 5KB the size overhead of wrapping a payload in a MMS
MMS_SIZE_OVERHEAD: 5 * 1024,
_serviceIds: null,
// we need to remove this when email functionality is ready.
supportEmailRecipient: true,
// We set the default maximum concatenated number of our SMS app to 10
// based on:
// https://bugzilla.mozilla.org/show_bug.cgi?id=813686#c0
maxConcatenatedMessages: 10,
mmsSizeLimitation: 295 * 1024, // Default mms message size limitation is 295K
mmsServiceId: null, // Default mms service SIM ID
smsServiceId: null, // Default sms service SIM ID
init: function settings_init() {
var keyHandlerSet = {
'dom.mms.operatorSizeLimitation': this.initMmsSizeLimitation.bind(this),
'operatorResource.sms.maxConcat':
this.initSmsMaxConcatenatedMsg.bind(this)
};
var settings = navigator.mozSettings;
var conns = navigator.mozMobileConnections;
function setHandlerMap(key) {
var req = settings.createLock().get(key);
req.onsuccess = function settings_getSizeSuccess() {
var handler = keyHandlerSet[key];
handler(req.result[key]);
};
}
this._serviceIds = [];
if (!settings) {
return;
}
// Only DSDS will need to handle mmsServiceId
if (conns && conns.length > 1) {
for (var prop in this.SERVICE_ID_KEYS) {
var setting = this.SERVICE_ID_KEYS[prop];
keyHandlerSet[setting] = this.initServiceId.bind(this, setting, prop);
}
// Cache all existing serviceIds
for (var i = 0, l = conns.length; i < l; i++) {
this._serviceIds.push(conns[i].iccId);
}
}
for (var key in keyHandlerSet) {
setHandlerMap(key);
}
},
//Set Maximum concatenated number of our SMS
initSmsMaxConcatenatedMsg: function initSmsMaxConcatenatedMsg(num) {
if (num && !isNaN(num)) {
this.maxConcatenatedMessages = num;
}
},
// Set MMS size limitation:
// If operator does not specify MMS message size, we leave the decision to
// MessageManager and return nothing if we can't get size limitation from db
initMmsSizeLimitation: function initMmsSizeLimitation(size) {
if (size && !isNaN(size)) {
this.mmsSizeLimitation = size - this.MMS_SIZE_OVERHEAD;
}
},
// Set default mms service SIM ID and add observer:
// In DSDS scenario, if we notify user to switch to subscription to retrieve
// the MMS from non-active subscription, we'll need current mmsServiceId
// information to tell user the active/non-active subscription
initServiceId: function initMmsServiceId(settingName, propName, id) {
if (id !== undefined) {
Settings[propName] = id;
}
navigator.mozSettings.addObserver(settingName, function(e) {
Settings[propName] = e.settingValue;
});
},
setMmsSimServiceId: function setSimServiceId(id) {
// DSDS: mms & data are both necessary for connection switch.
navigator.mozSettings.createLock().set({
'ril.mms.defaultServiceId': id,
'ril.data.defaultServiceId': id
});
},
switchMmsSimHandler: function switchSimHandler(targetId) {
var conn = window.navigator.mozMobileConnections[targetId];
return new Promise(function(resolve, reject) {
if (conn) {
if (conn.data.state === 'registered') {
// Call resolve directly if state is registered already
resolve();
} else {
// Listen to MobileConnections datachange to make sure we can start
// to retrieve mms only when data.state is registered. But we can't
// guarantee datachange event will work in other device.
conn.addEventListener('datachange', function onDataChange() {
if (conn.data.state === 'registered') {
conn.removeEventListener('datachange', onDataChange);
resolve();
}
});
this.setMmsSimServiceId(targetId);
}
} else {
reject('Invalid connection');
}
}.bind(this));
},
/**
* returns true if the device has more than 1 SIM port
*/
isDualSimDevice: function isDualSimDevice() {
return this._serviceIds && this._serviceIds.length > 1;
},
/**
* Returns true if the device has more than 1 SIM port and at least 2 SIMs are
* inserted.
*/
hasSeveralSim: function hasSeveralSim() {
if (!this.isDualSimDevice()) {
return false;
}
var simCount = this._serviceIds.reduce(function(simCount, iccId) {
return iccId === null ? simCount : simCount + 1;
}, 0);
return simCount > 1;
},
getServiceIdByIccId: function getServiceIdByIccId(iccId) {
if (!this._serviceIds) {
return null;
}
var index = this._serviceIds.indexOf(iccId);
return index > -1 ? index : null;
},
setReadAheadThreadRetrieval: function(value) {
if (!navigator.mozSettings) {
return;
}
var setting = {};
setting[this.READ_AHEAD_THREADS_KEY] = value;
navigator.mozSettings.createLock().set(setting);
}
};

View file

@ -0,0 +1,35 @@
'use strict';
/*global Map, Template*/
/*exported SharedComponents */
(function(exports) {
var cachedTemplates = new Map();
var getTemplate = function(id) {
if (!cachedTemplates.has(id)) {
cachedTemplates.set(id, Template(id));
}
return cachedTemplates.get(id);
};
var SharedComponents = {
phoneDetails: function(details, interpolationOptions) {
if (!details) {
throw new Error('Phone details are required!');
}
return getTemplate('phone-details-tmpl').prepare({
type: details.type || '',
number: details.number || '',
carrier: details.carrier || '',
hasTypeClass: !!details.type ? 'has-phone-type' : '',
hasCarrierClass: !!details.carrier ? 'has-phone-carrier' : ''
}, interpolationOptions);
}
};
exports.SharedComponents = SharedComponents;
})(window);

View file

@ -0,0 +1,18 @@
/* global ActivityShim,
MozMobileConnectionsShim,
MozMobileMessageShim,
MozSettingsShim
*/
(function(exports) {
'use strict';
exports.bootstrap = function(appInstanceId) {
// We can't handle system messages inside iframe right now, so we access
// parent window here. This should be removed once bug 818000 is resolved.
ActivityShim.init(appInstanceId, parent.mozSetMessageHandler);
MozSettingsShim.init(navigator.mozSettings);
MozMobileMessageShim.init(appInstanceId, navigator.mozMobileMessage);
MozMobileConnectionsShim.init(appInstanceId, navigator.mozMobileConnections);
};
})(self);

View file

@ -0,0 +1,165 @@
/* global asyncStorage, Promise, TaskRunner */
/* exported SilentSms */
'use strict';
var SilentSms = (function () {
var NO_ALARM_ID = -1;
// The maximum amount of time that the SmsManager waits for a message before
// disabling automatically the silent mode is five minutes.
var SERVICE_TIMEOUT = 5 * 60 * 1000;
var taskQueue;
function init() {
taskQueue = new TaskRunner();
if (!window.navigator.mozSetMessageHandler) {
// bailout if we're in Firefox
return;
}
// Msg from Usage (activation / deactivation)
window.navigator.mozSetMessageHandler('connection',
function onConnected(request) {
if (request.keyword !== 'costcontrolSmsQuery') {
return;
}
var port = request.port;
port.onmessage = function onReceivedMessage(evt) {
var data = evt.data;
switch (data.action) {
case 'enable':
taskQueue.push(function() {
return SilentSms.enableSilentModeFor(data.smsNumbers, data.type)
.then(updateUsageSilentSms)
.then(port.postMessage.bind(port, 'silentSMSEnabled'));
});
break;
case 'disable':
taskQueue.push(function() {
return SilentSms.disableSilentModeFor(data.type)
.then(updateUsageSilentSms)
.then(port.postMessage.bind(port, 'silentSMSDisabled'));
});
break;
}
};
}
);
// Deactivated silentSms mode by alarm
window.navigator.mozSetMessageHandler('alarm', function _onAlarm(alarm) {
if (alarm.data.alarmType === 'silentSmsTimeout') {
SilentSms.disableSilentModeFor(alarm.data.type)
.then(updateUsageSilentSms);
}
});
}
function getUsageSilentSms() {
return new Promise(function(resolve, reject) {
asyncStorage.getItem('usageSilentSms', function(usageSilentSms) {
resolve(usageSilentSms || {});
});
});
}
function removeAlarmTimeout(alarmId) {
// There is already an alarm, remove it
if (alarmId && alarmId !== NO_ALARM_ID) {
navigator.mozAlarms.remove(alarmId);
}
}
function addAlarmTimeout(type, delay) {
return new Promise(function doEnablingSilentMode(resolve, reject) {
var date = new Date();
date.setTime(date.getTime() + delay);
var request = navigator.mozAlarms.add(date, 'ignoreTimezone',
{alarmType: 'silentSmsTimeout',
type: type });
request.onsuccess = function _alarmSet(evt) {
var alarmId = evt.target.result;
resolve(alarmId);
};
request.onerror = function _onError() {
var errorMsg = 'Failed to set timeout for ' + type + 'request';
reject(new Error(errorMsg));
};
});
}
// The enabling silentMode process consist on two steps:
// 1. Enabling silentMode for the provided number
// 2. Activating alarm for TimeOut
function enableSilentModeFor(smsNumbers, type) {
function _removeAlarmTimeout(usageSilentSms) {
if (usageSilentSms && usageSilentSms[type] &&
usageSilentSms[type].alarmId !== NO_ALARM_ID) {
removeAlarmTimeout(usageSilentSms[type].alarmId);
}
}
function handleErrorAddingAlarm(error) {
console.error(error.toString());
return NO_ALARM_ID;
}
return getUsageSilentSms().then(function(usageSilentSms) {
function refreshUsageSilentSms(alarmId) {
usageSilentSms[type] = {
smsNumbers: smsNumbers,
alarmId: alarmId
};
return usageSilentSms;
}
_removeAlarmTimeout(usageSilentSms);
return addAlarmTimeout(type, SERVICE_TIMEOUT)
.catch(handleErrorAddingAlarm)
.then(refreshUsageSilentSms);
});
}
function disableSilentModeFor(type) {
return getUsageSilentSms().then(function(usageSilentSms) {
if (usageSilentSms && usageSilentSms[type] &&
usageSilentSms[type].alarmId !== NO_ALARM_ID) {
removeAlarmTimeout(usageSilentSms[type].alarmId);
}
usageSilentSms[type] = null;
return usageSilentSms;
});
}
function updateUsageSilentSms(usageSilentSms) {
usageSilentSms.smsIndex = getIndex(usageSilentSms);
asyncStorage.setItem('usageSilentSms', usageSilentSms);
}
function getIndex(usageSilentSms) {
var newIndex = [];
Object.keys(usageSilentSms).forEach(function(silentRequest) {
var silentSmsRequest = usageSilentSms[silentRequest];
if (silentSmsRequest && silentSmsRequest.hasOwnProperty('smsNumbers')) {
newIndex = newIndex.concat(silentSmsRequest.smsNumbers);
}
});
return newIndex;
}
function checkSilentModeFor(smsNumber) {
return taskQueue.push(function() {
function checkSilentMode(usageSilentSms) {
return usageSilentSms && usageSilentSms.smsIndex &&
usageSilentSms.smsIndex.indexOf(smsNumber) !== -1;
}
return getUsageSilentSms().then(checkSilentMode);
});
}
return {
init: init,
enableSilentModeFor: enableSilentModeFor,
disableSilentModeFor: disableSilentModeFor,
checkSilentModeFor: checkSilentModeFor
};
}());

View file

@ -0,0 +1,406 @@
/*global Utils, WBMP, TextEncoder */
(function() {
'use strict';
// characters not allowed in smil filenames
var unsafeFilenamePattern = /[^a-zA-Z0-9_#.()?&%-]/g;
// This encoder is aimed for encoding the string by 'utf-8'.
var encoder = new TextEncoder('UTF-8');
// For utilizing sending/receiving DOM API, we need to handle 2 basic object
// first: SMIL document and attachment. SMIL document is used for
// representing the layout of the mms message, and attachment contains media
// source and text. In mms message layout, one message could be split into
// one or more slides. One slide could contain at most one media with/without
// a text string. For example:
/*
Layout of a mms message with 3 slides
===================
---------
| img 1 |
--------- <- Slide 1
string 1
===================
---------
| img 2 |
--------- <- Slide 2
===================
-----------
| video 1 |
----------- <- Slide 3
string 2
===================
*/
// In this case, all the media files(img1/img2/video1) and text string
// (string1/string2) will be converted into 5 attachments, and SMIL should
// illustrate that this mms message contains 3 slides and the attachment's
// name/layout... in each slide. Here we provide parse and generate utils
// for developers to focus on the necessary information in slides.
// this is a reduce function applied to the array of slides passed to
// SMIL.generate - data will have an attachments and slides array
// slide will be a single "slide", which can have text and an attachment
// See the description of these formats in the comment before
function SMIL_generateSlides(data, slide, slideIndex) {
// default duration to 5 seconds per slide
const DURATION = 5000;
var id;
var blobType;
// each slide can have a piece of media and/or text
var media = '';
var text = '';
var name = '';
if (slide.blob) {
blobType = Utils.typeFromMimeType(slide.blob.type);
if (blobType) {
var tagName = tagNameFromBlobType(blobType);
var region = 'region="Image"';
// For attachment type 'ref' the region is not set
if (tagName === 'ref') {
region = '';
}
name = slide.name.substr(slide.name.lastIndexOf('/') + 1);
// just to be safe, remove any non-standard characters from the filename
name = name.replace(unsafeFilenamePattern, '#');
name = SMIL_generateUniqueLocation(data, name);
media = '<' + tagName + ' src="' + name + '" ' + region + '/>';
data.attachments.push({
id: '<' + name + '>',
location: name,
content: slide.blob
});
}
}
if (slide.text) {
// Set text region.
id = 'text_' + slideIndex + '.txt';
text = '<text src="' + id + '" region="Text"/>';
// The text of the content blob should always be encoded by 'utf-8'.
data.attachments.push({
id: '<' + id + '>',
location: id,
content: new Blob([encoder.encode(slide.text)], {type: 'text/plain'})
});
}
data.parts.push('<par dur="' + DURATION + 'ms">' + media + text + '</par>');
return data;
}
// Return a proper tag name depending on the blob type
function tagNameFromBlobType(blobType) {
var out;
switch(blobType) {
case 'vcard':
out = 'ref';
break;
default:
out = blobType;
}
return out;
}
function SMIL_generateUniqueLocation(data, location) {
var extension, name, result;
// Need to add reference spec here
var FILENAME_LIMIT = 40;
// if the location is already being used by the attachment
function SMIL_uniqueLocationMatches(attachment) {
return attachment.location === result;
}
// Check if a file extension exists
// Cache index of last non-extension portion of the filename
var index = location.lastIndexOf('.');
if (index === -1) {
name = location;
// Set to empty string so we can check length and append it to things
extension = '';
} else {
// Cache potential file extension
extension = location.slice(index);
name = location.slice(0, index);
}
// First truncate below the limit to make de-duplicating worthwhile
if (name.length + extension.length > FILENAME_LIMIT) {
name = name.slice(0, FILENAME_LIMIT - extension.length);
}
result = name + extension;
var duplicateIndex = 2;
// If result is identical to any other attached files
// add a duplicate marker and recheck the length
while (data.attachments.some(SMIL_uniqueLocationMatches)) {
var truncIndex = 0;
// Construct a de-deuplicated name with the extention and
// update duplicate index in case de-duplicated name is already chosen
result = name + '_' + duplicateIndex++ + extension;
// Truncate until the name (no longer needs to be de-duplicated)
// and extension are below the limit
while (result.length > FILENAME_LIMIT) {
duplicateIndex = 2;
name = name.slice(0, --truncIndex);
result = name + extension;
}
}
return result;
}
window.SMIL = {
// SMIL.parse - takes a message from the DOM API's and converts to a
// simple array format:
// message.smil = valid SMIL string, or falsey
// message.attachments = []
// message.attachments[].content = blob
// message.attachments[].location = src attr in smil
// Promise.resolve(parsedArray):
// parsedArray = []
// parsedArray[].text = 'plain text'
// parsedArray[].name = name of key
// parsedArray[].blob = data blob
parse: function SMIL_parse(message) {
var smil = message.smil;
var attachments = message.attachments;
var slides = [];
var attachmentsNotFound = false;
var smilMismatched = false;
var doc;
var parTags;
function readTextBlob(blob) {
// short circuit on null blobs
if (!blob) {
return Promise.resolve('');
}
var defer = Utils.Promise.defer();
var textReader = new FileReader();
textReader.onload = function(event) {
defer.resolve(event.target.result);
};
textReader.onerror = function(event) {
console.error('Error reading text blob');
defer.resolve('');
};
// The text blob must be encoded as 'utf-8' by Gecko.
textReader.readAsText(blob, 'UTF-8');
return defer.promise;
}
function findAttachment(name) {
var index = 0;
var length = attachments.length;
// strip the cid: prefix from some MMS encoders
name = name.replace(/^cid:/, '');
for (; index < length; index++) {
if (attachments[index].location === name ||
attachments[index].id === '<' + name + '>') {
return attachments[index];
}
}
return null;
}
function convertWbmpToPng(slide) {
var defer = Utils.Promise.defer();
var reader;
slide.name = slide.name.slice(0, -5) + '.png';
reader = new FileReader();
reader.onload = function(event) {
WBMP.decode(event.target.result, function callback(blob) {
slide.blob = blob;
defer.resolve();
});
};
reader.onerror = function(event) {
console.error('Error reading text blob');
defer.resolve();
};
reader.readAsArrayBuffer(slide.blob);
return defer.promise;
}
// handle mms messages without smil
// Display the attachments of the mms message in order
function SMIL_parseWithoutSMIL(attachment, idx) {
var blob = attachment.content;
if (!blob) {
return Promise.resolve();
}
var result;
var type = Utils.typeFromMimeType(blob.type);
// handle text blobs (plain text blob only) by reading them and
// converting to text on the last slide
if (type === 'text' && blob.type === 'text/plain') {
result = readTextBlob(blob).then((text) => {
slides[idx] = {
text: text
};
});
// make sure the type was something we want, otherwise ignore it
} else if (type) {
var slide = { name: attachment.location, blob: attachment.content };
if (slide.name && slide.name.slice(-5) === '.wbmp') {
result = convertWbmpToPng(slide);
}
slides[idx] = slide;
}
return result || Promise.resolve();
}
function SMIL_parseHandleParTag(par, index) {
// stop parsing as soon as we fail to find one attachment
if (attachmentsNotFound) {
return;
}
var mediaElements = par.querySelectorAll('img, video, audio, ref');
var textElement = par.querySelector('text');
var attachment, src;
var promises = Array.from(mediaElements).map((element) => {
var result;
var slide = {};
src = element.getAttribute('src');
attachment = findAttachment(src);
if (attachment) {
// every media attachment starts its own slide in our format
slide = { name: attachment.location, blob: attachment.content };
slides.push(slide);
if (slide.name && slide.name.slice(-5) === '.wbmp') {
result = convertWbmpToPng(slide);
}
} else {
attachmentsNotFound = true;
}
return result || Promise.resolve();
});
if (textElement) {
src = textElement.getAttribute('src');
attachment = findAttachment(src);
if (attachment) {
// check for text on the last slide
var slide = slides[slides.length - 1];
// if the last slide doesn't exist, or the last slide has text
// already, we create a new slide to store the text
if (!slide || typeof slide.text !== 'undefined') {
slide = {};
slides.push(slide);
}
// Init slide text to avoid text replaced by later blob
slide.text = '';
// read the text blob, and store it in the "slide" this function
// will hold onto
promises.push(
readTextBlob(attachment.content).then(
(text) => slide.text = text
)
);
} else {
attachmentsNotFound = true;
}
}
return Promise.all(promises);
}
var result;
// handle MMS messages with SMIL
if (smil) {
doc = (new DOMParser()).parseFromString(smil, 'application/xml');
parTags = doc.documentElement.getElementsByTagName('par');
// Check if attachments are all listed in smil.
var elements = Array.reduce(
parTags, (count, par) => count + par.childElementCount, 0
);
if (elements !== attachments.length) {
smilMismatched = true;
} else {
result = Promise.all(Array.from(parTags).map(SMIL_parseHandleParTag));
}
}
// handle MMS attachments without SMIL / malformed SMIL
if (!smil || attachmentsNotFound || !slides.length || smilMismatched) {
// reset slides in the attachments not found case
slides = Array(attachments.length);
result = Promise.all(attachments.map(SMIL_parseWithoutSMIL));
}
return (result || Promise.resolve()).then(() => slides);
},
// SMIL.generate - takes a array with slides and return smil string and
// attachment array.
generate: function SMIL_generate(slides) {
const HEADER = '<head><layout>' +
'<root-layout width="320px" height="480px"/>' +
'<region id="Image" left="0px" top="0px"' +
' width="320px" height="320px" fit="meet"/>' +
'<region id="Text" left="0px" top="320px"' +
' width="320px" height="160px" fit="meet"/>' +
'</layout></head>';
// generate html from each slide while storing the actual attachment
// in the attachments array.
var data = slides.reduce(SMIL_generateSlides, {
attachments: [],
parts: []
});
// join the html parts together
data.smil = '<smil>' + HEADER + '<body>' +
data.parts.join('') +
'</body></smil>';
// the API doesn't care about 'parts', clean it up
delete data.parts;
return data;
}
};
})();

View file

@ -0,0 +1,192 @@
/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
'use strict';
/*global ActivityHandler,
App,
ConversationView,
Drafts,
InboxView,
Information,
InterInstanceEventDispatcher,
LazyLoader,
LocalizationHelper,
MessageManager,
MessagingClient,
MozMobileConnectionsClient,
Navigation,
Settings,
SilentSms,
SystemMessageHandler,
TimeHeaders,
Utils
*/
(function(exports) {
/**
* @function
* The debug function translates to console.log in debug mode.
*
* @param {String} arg1 The first value to be displayed to the console.
* @param {...*} args The other values to be displayed to the console, or the
* parameters to the first string.
*/
var debug = 0 ?
(arg1, ...args) => console.log('[Startup] ' + arg1, ...args) :
() => {};
var Startup = exports.Startup = {
_lazyLoadScripts: [
'/shared/js/settings_listener.js',
'/shared/js/mime_mapper.js',
'/shared/js/notification_helper.js',
'/shared/js/option_menu.js',
'/shared/js/gesture_detector.js',
'/shared/js/settings_url.js',
'/shared/js/mobile_operator.js',
'/shared/js/multi_sim_action_button.js',
'/shared/js/image_utils.js',
'/shared/elements/gaia_sim_picker/script.js',
'/views/shared/js/waiting_screen.js',
'/views/shared/js/errors.js',
'/views/shared/js/dialog.js',
'/views/shared/js/error_dialog.js',
'/views/conversation/js/link_helper.js',
'/views/conversation/js/link_action_handler.js',
'/views/shared/js/contact_renderer.js',
'/views/shared/js/activity_picker.js',
'/views/conversation/js/information.js',
'/views/shared/js/shared_components.js',
'/views/shared/js/task_runner.js',
'/views/shared/js/silent_sms.js',
'/views/conversation/js/recipients.js',
'/views/conversation/js/attachment.js',
'/views/conversation/js/attachment_renderer.js',
'/views/conversation/js/conversation.js',
'/views/conversation/js/subject_composer.js',
'/views/conversation/js/compose.js',
'/views/shared/js/wbmp.js',
'/views/shared/js/smil.js',
'/views/shared/js/notify.js',
'/views/shared/js/activity_handler.js',
'/views/shared/js/system_message_handler.js',
'/views/shared/js/localization_helper.js',
'/lib/bridge/client.js',
'/services/js/activity/activity_client.js',
'/services/js/messaging/messaging_client.js',
'/services/js/moz_mobile_connections/moz_mobile_connections_client.js'
],
_lazyLoadStyles: [
'/shared/style/confirm.css',
'/shared/style/status.css',
'/shared/style/input_areas.css',
'/shared/style/progress_activity.css',
'/views/shared/style/composer.css',
'/views/conversation/style/message.css',
'/views/conversation/style/report_view.css',
'/views/conversation/style/attachment.css',
'/views/conversation/style/conversation.css',
'/views/new_message/style/new_message.css',
'/views/new_message/style/recipients.css'
],
_lazyLoadInit: function() {
var lazyLoadPromise = LazyLoader.load(
[...this._lazyLoadScripts, ...this._lazyLoadStyles]).then(() => {
LocalizationHelper.init();
InterInstanceEventDispatcher.connect();
// dispatch contentInteractive when all the modules initialized
SilentSms.init();
// When app is run as inline activity we don't want to handle any other
// system messages other than "activity" system message.
if (navigator.mozHasPendingMessage('activity')) {
ActivityHandler.init();
} else {
SystemMessageHandler.init();
}
// Init UI Managers
TimeHeaders.init();
ConversationView.init();
MessagingClient.init(App.instanceId);
MozMobileConnectionsClient.init(App.instanceId);
Information.initDefaultViews();
Navigation.setReady();
window.performance.mark('contentInteractive');
// Fetch mmsSizeLimitation and max concat
Settings.init();
window.performance.mark('objectsInitEnd');
});
return lazyLoadPromise;
},
/**
* We wait for the DOMContentLoaded event in the event sequence. After we
* loaded the first panel of threads, we lazy load all non-critical JS files.
* As a result, if the 'load' event was not sent yet, this will delay it even
* more until all these non-critical JS files are loaded. This is fine.
*/
init: function() {
var loaded = function() {
window.removeEventListener('DOMContentLoaded', loaded);
window.performance.mark('navigationLoaded');
Utils.initializeShimHost(App.instanceId);
MessageManager.init();
Drafts.init();
InboxView.init();
Navigation.init();
InboxView.once('fully-loaded', () => {
window.performance.mark('fullyLoaded');
App.setReady();
});
InboxView.once('visually-loaded', () => {
window.performance.mark('visuallyLoaded');
});
// If initial panel is default one and app isn't run from notification,
// then just navigate to it, otherwise we can delay default panel
// initialization until we navigate to requested non-default panel.
if (Navigation.isDefaultPanel() &&
!navigator.mozHasPendingMessage('notification') &&
!navigator.mozHasPendingMessage('activity')) {
debug('Rendering threads now.');
InboxView.once('visually-loaded', () => {
this._lazyLoadInit();
});
InboxView.renderThreads();
} else {
debug('Not using default panel, waiting for navigated event');
Navigation.once('navigated', () => InboxView.renderThreads());
this._lazyLoadInit();
}
// dispatch navigationInteractive when thread list related modules
// initialized
window.performance.mark('navigationInteractive');
}.bind(this);
window.addEventListener('DOMContentLoaded', loaded);
}
};
Startup.init();
})(window);

View file

@ -0,0 +1,348 @@
/*global Contacts,
MessageManager,
Navigation,
NotificationHelper,
Notify,
Settings,
SilentSms,
SMIL,
Utils
*/
/*exported SystemMessageHandler */
(function(exports) {
'use strict';
/**
* Number of milliseconds after which currently acquired wake lock will be
* automatically released.
* @type {Number}
* @const
*/
const AUTO_WAKE_LOCK_UNLOCK_TIMEOUT = 30 * 1000;
const priv = {
wakeLock: Symbol('wakeLock'),
autoWakeLockUnlockTimeout: Symbol('autoWakeLockUnlockTimeout')
};
/**
* Object that is responsible for handling "sms-received" and "notification"
* system messages.
*/
var SystemMessageHandler = {
/**
* Wake lock instance.
* @type {MozWakeLock}
* @private
*/
[priv.wakeLock]: null,
/**
* Timeout that will trigger to release of currently active wake lock.
* @type {Number}
* @private
*/
[priv.autoWakeLockUnlockTimeout]: null,
/**
* Initializes system message handlers, if app is run as activity, no
* system message handlers will be registered.
*/
init() {
if (!navigator.mozSetMessageHandler) {
return;
}
navigator.mozSetMessageHandler(
'sms-received', this.onSmsReceivedSystemMessage.bind(this)
);
navigator.mozSetMessageHandler(
'notification', this.onNotificationSystemMessage.bind(this)
);
},
/**
* Requests application wake lock.
* @private
*/
requestWakeLock() {
this[priv.wakeLock] = navigator.requestWakeLock('cpu');
this[priv.autoWakeLockUnlockTimeout] = setTimeout(
() => this.releaseWakeLock(), AUTO_WAKE_LOCK_UNLOCK_TIMEOUT
);
},
/**
* Releases currently active application wake lock.
* @private
*/
releaseWakeLock() {
var autoWakeLockUnlockTimeout = this[priv.autoWakeLockUnlockTimeout];
if (autoWakeLockUnlockTimeout) {
clearTimeout(autoWakeLockUnlockTimeout);
this[priv.autoWakeLockUnlockTimeout] = null;
}
var wakeLock = this[priv.wakeLock];
if (wakeLock) {
wakeLock.unlock();
this[priv.wakeLock] = null;
}
},
/**
* Prepares notification body for the message
* @param {MozMmsMessage|MozSmsMessage} message Message to prepare
* notification body for.
* @returns {Promise.<{raw: string}|string>} L10n object that will serve as
* notification body.
* @private
*/
getNotificationBody(message) {
// In case of sms, we want to display text content in notification.
if (message.type === 'sms') {
return Promise.resolve({ raw: message.body || '' });
}
var mmsNotificationBodyL10n = null;
// Here we can only have one sender, so deliveryInfo[0].deliveryStatus
// => message status from sender.
if (message.deliveryInfo[0].deliveryStatus !== 'success') {
// If the delivery status is manual/rejected/error, we need to apply
// specific text to notify user that message is not downloaded.
mmsNotificationBodyL10n = 'notDownloaded-title';
} else if (message.subject) {
// If subject exist, we display subject in notification.
mmsNotificationBodyL10n = { raw: message.subject };
}
return mmsNotificationBodyL10n ?
Promise.resolve(mmsNotificationBodyL10n) :
SMIL.parse(message).then((slideArray) => {
// If MMS has at least one non-empty text slide, let's display that
// text in notification otherwise use predefined 'mms message'.
var textSlide = slideArray.find((slide) => !!slide.text);
return textSlide ? { raw: textSlide.text } : 'mms-message';
});
},
/**
* Prepares notification title for the message
* @param {MozMmsMessage|MozSmsMessage} message Message to prepare
* notification title for.
* @returns {Promise.<{raw: string}|{id: string, args: Object}>} L10n object
* that will serve as notification title.
* @private
*/
getNotificationTitle(message) {
return Contacts.findByAddress(message.sender).then((contacts) => {
var sender = contacts.length && contacts[0].name &&
contacts[0].name.length && contacts[0].name[0] ||
message.sender;
if (Settings.hasSeveralSim() && message.iccId) {
return Utils.getSimNameByIccId(message.iccId).then(
(simName) => ({
id: 'dsds-notification-title-with-sim',
args: { sim: simName, sender: sender }
})
);
}
return { raw: sender };
});
},
/**
* Retrieves correct application icon to use within notifications.
* @returns {string} Application icon URI.
* @private
*/
getNotificationIcon() {
return navigator.mozApps.getSelf().then(
(app) => NotificationHelper.getIconURI(app)
);
},
/**
* Processes received class-0 message (flash message).
* @param {MozMmsMessage|MozSmsMessage} message Received class-0 message.
* @returns {Promise.<void>} Promise that is resolved once message is fully
* handled.
* @private
*/
onFlashMessageReceived(message) {
// We have to remove message from DB as it should not be kept in Inbox.
return MessageManager.deleteMessages(message.id).then(
() => navigator.mozApps.getSelf()
).then((app) =>{
app.launch();
Notify.ringtone();
Notify.vibrate();
Utils.alert({ raw: message.body || '' }, { raw: message.sender });
});
},
/**
* Handles received class-1, class-2 or class-3 message.
* @param {MozMmsMessage|MozSmsMessage} message Received message.
* @returns {Promise.<void>} Promise that is resolved once message is fully
* handled.
* @private
*/
onMessageReceived(message) {
// If message type is mms and pending on server, ignore the notification
// because it will be retrieved from server automatically. Handle other
// manual/error status as manual download and dispatch notification.
// Please ref mxr for all the possible delivery status:
// http://mxr.mozilla.org/mozilla-central/source/dom/mms/src/ril/
// MmsService.js#62
// Here we can only have one sender, so deliveryInfo[0].deliveryStatus
// => message status from sender.
if (message.type === 'mms' &&
message.deliveryInfo[0].deliveryStatus === 'pending') {
return;
}
var isInConversation = Navigation.isCurrentPanel(
'thread', { id: message.threadId }
);
// If app is already opened and we're in the right conversation, let's
// just notify user about new message.
if (!document.hidden && isInConversation) {
Notify.ringtone();
Notify.vibrate();
return;
}
return Promise.all([
this.getNotificationTitle(message),
this.getNotificationBody(message),
this.getNotificationIcon()
]).then(([titleL10n, bodyL10n, icon]) => {
// Force the helper to leave close behavior as it is because it should
// be handled in Utils.closeNotificationsForThread later.
return NotificationHelper.send(titleL10n, {
icon: icon,
bodyL10n: bodyL10n,
tag: 'threadId:' + message.threadId,
data: { id: message.id, threadId: message.threadId },
closeOnClick: false
});
}).then((notification) => {
notification.addEventListener(
'click', () => this.onNotificationClicked(message)
);
// Close notification if we are already in conversation view and app
// becomes visible.
if (document.hidden && isInConversation) {
Utils.onceDocumentIsVisible().then(() => notification.close());
}
});
},
/**
* Fires once notification is clicked.
* @param {{ id: string, threadId: string }} notification Notification
* arbitrary data.
* @returns {Promise.<void>} Promise that is resolved once notification
* click is handled.
* @private
*/
onNotificationClicked({ id, threadId }) {
return navigator.mozApps.getSelf().then((app) => {
app.launch();
// If we're currently in the target conversation, just do nothing.
if (Navigation.isCurrentPanel('thread', { id: threadId })) {
return;
}
//Validate if message still exists before opening message thread
//See issue https://bugzilla.mozilla.org/show_bug.cgi?id=837029
return MessageManager.getMessage(id).then(
(message) => Navigation.toPanel('thread', { id: message.threadId }),
() => {
if (Navigation.hasPendingInit()) {
Navigation.init();
}
Utils.alert('deleted-sms');
}
);
});
},
/**
* Handles "sms-received" system message.
* @param {MozMmsMessage|MozSmsMessage} message Received message.
* @returns {Promise.<void>} Promise that is resolved once message is fully
* handled.
* @private
*/
onSmsReceivedSystemMessage(message) {
// Acquire the cpu wake lock when we receive an SMS. This raises the
// priority of this process above vanilla background apps, making it less
// likely to be killed on OOM. It also prevents the device from going to
// sleep before the user is notified of the new message.
//
// We'll release it once we display a notification to the user. We also
// release the lock after 30s, in case we never run the notification code
// for some reason.
this.requestWakeLock();
var handleReceivedMessagePromise;
if (message.messageClass === 'class-0') {
handleReceivedMessagePromise = this.onFlashMessageReceived(message);
} else {
handleReceivedMessagePromise = SilentSms.checkSilentModeFor(
message.sender
).then((isSilent) => {
if (!isSilent) {
return this.onMessageReceived(message);
}
});
}
return handleReceivedMessagePromise.then(() => this.releaseWakeLock());
},
/**
* Handles "notification" system message.
* @param {Notification} notification Notification object.
* @returns {Promise.<void>} Promise that is resolved once notification
* system message is handled.
* @private
*/
onNotificationSystemMessage(notification) {
// When notification is removed from notification tray, notification
// system message will still be fired, but "clicked" property will be
// equal to false. This should change once bug 1139363 is landed. When
// user clicks on notification we'll get two system messages, first to
// notify app that notification is clicked and then, once we show
// Conversation view to the user, we remove that notification from the
// tray that causes the second system message with "clicked" set to false.
if (!notification.clicked || !notification.data) {
// Force closing the window for notification removal case
// because navigation didn't init correctly if pending notification
// message existed.
if (Navigation.hasPendingInit()) {
window.close();
return Promise.reject(new Error('Notification has been dismissed.'));
}
return Promise.resolve();
}
// At the moment notification.data is { id, threadId }.
return this.onNotificationClicked(notification.data);
}
};
exports.SystemMessageHandler = SystemMessageHandler;
})(self);

View file

@ -0,0 +1,10 @@
/* global Promise */
'use strict';
function TaskRunner() {
this._currentTask = Promise.resolve();
}
TaskRunner.prototype.push = function(task) {
return (this._currentTask = this._currentTask.then(task, task));
};

View file

@ -0,0 +1,93 @@
/*global Utils */
(function(exports) {
'use strict';
var updateTimer = null;
var TimeHeaders = {
init: function th_init() {
onvisibilityChange();
document.addEventListener('visibilitychange', onvisibilityChange);
window.addEventListener('timeformatchange', ontimeFormatChange);
window.addEventListener('languagechange', onlanguageChange);
},
startScheduler: function th_startScheduler() {
var now = Date.now(),
nextTimeout = new Date(now + 60000);
nextTimeout.setSeconds(0);
nextTimeout.setMilliseconds(0);
// stop updateTimer first
this.stopScheduler();
// then register a new one
updateTimer = setTimeout(function() {
this.updateAll('header[data-time-update=repeat]');
this.startScheduler();
}.bind(this), nextTimeout.getTime() - now);
},
stopScheduler: function th_stopScheduler() {
clearTimeout(updateTimer);
},
updateAll: function th_updateAll(selector) {
selector = selector || '[data-time-update]';
var elements = document.querySelectorAll(selector),
length = elements.length,
i;
for (i = 0; i < length; i++) {
this.update(elements[i]);
}
},
update: function th_update(element) {
var ts = element.dataset.time;
if (!ts) {
return;
}
// only date
if (element.dataset.dateOnly === 'true') {
Utils.setHeaderDate({
time: ts,
element: element,
withTime: false
});
// only time
} else if (element.dataset.timeOnly === 'true') {
element.removeAttribute('data-l10n-id');
element.textContent = Utils.getFormattedHour(ts);
// date + time
} else {
Utils.setHeaderDate({
time: ts,
element: element,
withTime: true
});
}
}
};
function onvisibilityChange() {
if (document.hidden) {
TimeHeaders.stopScheduler();
}
else {
TimeHeaders.updateAll();
TimeHeaders.startScheduler();
}
}
function ontimeFormatChange() {
Utils.resetDateFormatters();
TimeHeaders.updateAll();
}
function onlanguageChange() {
Utils.resetDateFormatters();
TimeHeaders.updateAll();
}
exports.TimeHeaders = TimeHeaders;
}(this));

View file

@ -0,0 +1,980 @@
/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
/* globals ContactPhotoHelper,
Dialog,
Notification,
Settings
*/
(function(exports) {
'use strict';
var rdashes = /-(.)/g;
var rescape = /[.?*+^$[\]\\(){}|-]/g;
var rparams = /([^?=&]+)(?:=([^&]*))?/g;
var rnondialablechars = /[^,#+\*\d]/g;
var rmail = /[\w-]+@[\w\-]/;
var Utils = {
date: {
shared: new Date(),
formatters: {
'time': {
options: {
hour: 'numeric',
minute: 'numeric',
},
formatter: null
},
'full': {
options: {
month: '2-digit',
day: '2-digit',
year: 'numeric',
},
formatter: null
},
'full-wt': {
options: {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
},
formatter: null
},
'day': {
options: {
weekday: 'long',
},
formatter: null
},
'day-wt': {
options: {
weekday: 'long',
hour: 'numeric',
minute: 'numeric',
},
formatter: null
},
},
},
escapeRegex: function ut_escapeRegex(str) {
if (typeof str !== 'string') {
return '';
}
return str.replace(rescape, '\\$&');
},
getFormattedHour: function ut_getFormattedHour(time) {
this.date.shared.setTime(+time);
return this._getFormatter('time', false).format(this.date.shared);
},
getDayDate: function re_getDayDate(time) {
this.date.shared.setTime(+time);
this.date.shared.setHours(0, 0, 0, 0);
return this.date.shared.getTime();
},
resetDateFormatters: function ut_resetDateFormatters() {
// remove cached formatters.
// It should happen on timeformatchange and languagechange
for (var type in this.date.formatters) {
this.date.formatters[type].formatter = null;
}
},
_getFormatter: function ut_getFormatter(type, withTime) {
if (withTime) {
type += '-wt';
}
// Cache the formatter for better performance
if (!this.date.formatters[type].formatter) {
var options = Object.assign(
{},
this.date.formatters[type].options
);
if (options.hour) {
options.hour12 = navigator.mozHour12;
}
this.date.formatters[type].formatter = Intl.DateTimeFormat(
navigator.languages,
options
);
}
return this.date.formatters[type].formatter;
},
setHeaderDate: function ut_setHeaderDate({time, element, withTime}) {
var formatter;
var today = Utils.getDayDate(Date.now());
var otherDay = Utils.getDayDate(time);
var dayDiff = (today - otherDay) / 86400000;
this.date.shared.setTime(+time);
if (isNaN(dayDiff)) {
element.setAttribute('data-l10n-id', 'incorrectDate');
return;
}
if (dayDiff < 0) {
// future time
formatter = this._getFormatter('full', withTime);
element.removeAttribute('data-l10n-id');
element.textContent = formatter.format(this.date.shared);
return;
}
if (dayDiff === 0) {
if (withTime) {
document.l10n.setAttributes(
element, 'todayWithTime', {
time: this.getFormattedHour(this.date.shared)
});
} else {
element.setAttribute('data-l10n-id', 'today');
}
} else if (dayDiff === 1) {
if (withTime) {
document.l10n.setAttributes(
element, 'yesterdayWithTime', {
time: this.getFormattedHour(this.date.shared)
});
} else {
element.setAttribute('data-l10n-id', 'yesterday');
}
} else if (dayDiff < 6) {
// day
formatter = this._getFormatter('day', withTime);
element.removeAttribute('data-l10n-id');
element.textContent = formatter.format(this.date.shared);
} else {
formatter = this._getFormatter('full', withTime);
element.removeAttribute('data-l10n-id');
element.textContent = formatter.format(this.date.shared);
}
},
// We will apply createObjectURL for details.photoURL if contact image exist
// Please remember to revoke the photoURL after utilizing it.
getContactDetails:
function ut_getContactDetails(number, contacts, include) {
var details = {};
include = include || {};
function updateDetails(contact) {
var name, phone, i, length, subscriber, org;
name = contact.name[0];
org = contact.org && contact.org[0];
length = contact.tel ? contact.tel.length : 0;
subscriber = number.length > 7 ? number.substr(-8) : number;
// Check which of the contacts phone number are we using
for (i = 0; i < length; i++) {
// Based on E.164 (http://en.wikipedia.org/wiki/E.164)
if (contact.tel[i].value.indexOf(subscriber) !== -1) {
phone = contact.tel[i];
break;
}
}
// Add data values for contact activity interaction
details.isContact = true;
// Add photo
if (include.photoURL) {
var photo = ContactPhotoHelper.getThumbnail(contact);
if (photo) {
details.photoURL = window.URL.createObjectURL(photo);
}
}
details.name = name;
// We pick the first discovered org name as the phone number's detail
// org information.
details.org = details.org || org;
}
// In no contact or contact with empty information cases, we will leave
// the title as the empty string and let caller to decide the title.
if (!contacts || (Array.isArray(contacts) && contacts.length === 0)) {
details.title = '';
} else if (!Array.isArray(contacts)) {
updateDetails(contacts);
details.title = details.name || details.org;
} else {
// Rule for fetching details with multiple contact entries:
// 1) If we got more than 1 contact entry, find another entry if
// current entry got no name/company.
// 2) If we could not get any information from all the entries,
// just display phone number.
for (var i = 0, l = contacts.length; i < l; i++) {
updateDetails(contacts[i]);
if (details.name) {
break;
}
}
details.title = details.name || details.org;
}
return details;
},
/**
* Based on input number tries to extract more phone details like phone
* type, full phone number and phone carrier.
* 1. If a phone number has carrier associated with it then both "type" and
* "carrier" will be returned;
*
* 2. If there is no carrier associated with the phone number then "type"
* and "phone number" will be returned;
*
* 3. If for some reason a single contact has two phone numbers with the
* same type and the same carrier then "type" and "phone number" will be
* returned;
*
* note: The argument "tels" can actually contain "emails" too.
*
*/
getPhoneDetails: function ut_getPhoneDetails(input, tels) {
var length = tels.length;
var hasUniqueCarriers = true;
var hasUniqueTypes = true;
var found, tel, type, carrier;
for (var i = 0; i < length; i++) {
tel = tels[i];
if (tel.value && Utils.probablyMatches(tel.value, input)) {
found = tel;
}
if (carrier && carrier === tel.carrier) {
hasUniqueCarriers = false;
}
if (type && type === tel.type[0]) {
hasUniqueTypes = false;
}
carrier = tel.carrier;
type = (tel.type && tel.type[0]) || '';
}
if (!found) {
return null;
}
return {
type: (found.type && found.type[0]) || '',
carrier: hasUniqueCarriers || hasUniqueTypes ? found.carrier : '',
number: found.value
};
},
// Based on "non-dialables" in https://github.com/andreasgal/PhoneNumber.js
//
// @param {String} input Value to remove nondialiable chars from.
//
removeNonDialables: function ut_removeNonDialables(input) {
return input.replace(rnondialablechars, '');
},
// @param {String} a First recipient field.
// @param {String} b Second recipient field
//
// Based on...
// - ITU-T E.123 (http://www.itu.int/rec/T-REC-E.123-200102-I/)
// - ITU-T E.164 (http://www.itu.int/rec/T-REC-E.164-201011-I/)
//
// ...It would appear that a maximally-minimal
// 7 digit comparison is safe.
probablyMatches: function ut_probablyMatches(a, b) {
var service = navigator.mozPhoneNumberService;
// String comparison starts here
if (typeof a !== 'string' || typeof b !== 'string') {
return false;
}
if (Settings.supportEmailRecipient &&
Utils.isEmailAddress(a) &&
Utils.isEmailAddress(b)) {
return a === b;
}
if (service && service.normalize) {
a = service.normalize(a);
b = service.normalize(b);
} else {
a = Utils.removeNonDialables(a);
b = Utils.removeNonDialables(b);
}
return a === b || a.slice(-7) === b.slice(-7);
},
/**
* multiRecipientMatch
*
* Check multi-repients without regard to order
*
* @param {(String|string[])} a First recipient field.
* @param {(String|string[])} b Second recipient field.
*
* @return {Boolean} Return true if all recipients match.
*/
multiRecipientMatch: function ut_multiRecipientMatch(a, b) {
// When ES6 syntax is allowed, replace with
// multiRecipientMatch([...a], [...b])
a = [].concat(a);
b = [].concat(b);
var blen = b.length;
if (a.length !== blen) {
return false;
}
// Check each recipient in a against each in b
// Allows for any order (and fails early)
return a.every(function(number) {
for (var i = 0; i < blen; i++) {
if (Utils.probablyMatches(number, b[i])) {
return true;
}
}
});
},
// Default image size limitation is set to 295KB for MMS user story.
// If limit is not given or bigger than default 295KB, default value need
// to be applied here for size checking. Parameters could be:
// (blob, callback) : Resizing image to default limit 295k.
// (blob, limit, callback) : Resizing image to given limitation.
getResizedImgBlob: function ut_getResizedImgBlob(blob, limit, callback) {
var defaultLimit = 295 * 1024;
if (typeof limit === 'function') {
callback = limit;
limit = defaultLimit;
}
limit = limit === 0 ? defaultLimit : Math.min(limit, defaultLimit);
if (blob.size < limit) {
setTimeout(function blobCb() {
callback(blob);
});
return;
}
var ratio = Math.sqrt(blob.size / limit);
Utils._resizeImageBlobWithRatio({
blob: blob,
limit: limit,
ratio: ratio,
callback: callback
});
},
/**
* Gets localization details for attachment size label.
* @param size Attachment blob size in bytes.
* @returns {{l10nId: string, l10nArgs: {n: string}}}
*/
getSizeForL10n: function ut_getSizeForL10n(size) {
// blob size with unit (B or KB or MB)
var sizeKB = size / 1024;
var sizeMB = sizeKB / 1024;
if (size < 1000) {
return {
l10nId: 'attachmentSizeB',
l10nArgs: { n: size }
};
} else if (sizeKB < 1000) {
return {
l10nId: 'attachmentSizeKB',
l10nArgs: { n: sizeKB.toFixed(1) }
};
}
return {
l10nId: 'attachmentSizeMB',
l10nArgs: { n: sizeMB.toFixed(1) }
};
},
// resizeImageBlobWithRatio have additional ratio to force image
// resize to smaller size to avoid edge case about quality adjustment
// not working.
// For JPG images, a ratio between 2 and 8 will be set to a close
// power of 2. A ratio between 1 and 2 will be set to 2. A ratio bigger
// than 8 will be rounded to the closest bigger integer.
//
_resizeImageBlobWithRatio: function ut_resizeImageBlobWithRatio(obj) {
var blob = obj.blob;
var callback = obj.callback;
var limit = obj.limit;
var ratio = Math.ceil(obj.ratio);
var qualities = [0.65, 0.5, 0.25];
var sampleSize = 1;
var sampleSizeHash = '';
if (blob.size < limit || ratio <= 1) {
setTimeout(function blobCb() {
callback(blob);
});
return;
}
if (blob.type === 'image/jpeg') {
if (ratio >= 8) {
sampleSize = 8;
} else {
sampleSize = ratio = Utils.getClosestSampleSize(ratio);
}
sampleSizeHash = '#-moz-samplesize=' + sampleSize;
}
var img = document.createElement('img');
var url = window.URL.createObjectURL(blob);
img.src = url + sampleSizeHash;
img.onload = function onBlobLoaded() {
window.URL.revokeObjectURL(url);
var targetWidth = img.width * sampleSize / ratio;
var targetHeight = img.height * sampleSize / ratio;
var canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(img, 0, 0, targetWidth, targetHeight);
img.src = '';
// Bug 889765: Since we couldn't know the quality of the original jpg
// The 'resized' image might have a bigger size because it was saved
// with quality or dpi. Here we will adjust the jpg quality(or resize
// blob again if low quality blob size still exceed limit) to make
// sure the size won't exceed the limitation.
var level = 0;
function cleanup() {
canvas.width = canvas.height = 0;
canvas = null;
}
function ensureSizeLimit(resizedBlob) {
if (resizedBlob.size < limit) {
cleanup();
// using a setTimeout so that used objects can be garbage collected
// right now
setTimeout(callback.bind(null, resizedBlob));
} else {
resizedBlob = null; // we don't need it anymore
// Reduce image quality for match limitation. Here we set quality
// to 0.65, 0.5 and 0.25 for image blob resizing.
// (Default image quality is 0.92 for jpeg)
if (level < qualities.length) {
canvas.toBlob(ensureSizeLimit, 'image/jpeg',
qualities[level++]);
} else {
// We will resize the blob if image quality = 0.25 still exceed
// size limitation.
cleanup();
// using a setTimeout so that used objects can be garbage
// collected right now
setTimeout(
Utils._resizeImageBlobWithRatio.bind(Utils, {
blob: blob,
limit: limit,
ratio: ratio * 2,
callback: callback
})
);
}
}
}
canvas.toBlob(ensureSizeLimit, blob.type);
};
},
getClosestSampleSize: function ut_getClosestSampleSize(ratio) {
if (ratio >= 8) {
return 8;
}
if (ratio >= 4) {
return 4;
}
if (ratio >= 2) {
return 2;
}
return 1;
},
camelCase: function ut_camelCase(str) {
return str.replace(rdashes, function replacer(str, p1) {
return p1.toUpperCase();
});
},
typeFromMimeType: function ut_typeFromMimeType(mime) {
var MAX_MIME_TYPE_LENGTH = 256; // ought to be enough for anybody
if (typeof mime !== 'string' || mime.length > MAX_MIME_TYPE_LENGTH) {
return null;
}
var index = mime.indexOf('/');
if (index === -1) {
return null;
}
var mainPart = mime.slice(0, index);
var secondPart = mime.substr(index + 1).toLowerCase();
switch (mainPart) {
case 'image':
return 'img';
case 'text':
if(secondPart.indexOf('vcard') !== -1) {
return 'vcard';
}
if (secondPart !== 'plain') {
return 'ref';
}
return mainPart;
case 'video':
case 'audio':
case 'application':
return mainPart;
default:
return null;
}
},
params: function(input) {
var parsed = {};
input.replace(rparams, function($0, $1, $2) {
if ($2 === 'true') {
$2 = true;
} else if ($2 === 'false') {
$2 = false;
}
parsed[$1] = $2;
});
return parsed;
},
url(base, params) {
if (base.indexOf('?') === -1) {
base += '?';
} else {
base += '&';
}
for (var key in params) {
if (params[key] == null) { // null or undefined
continue;
}
base +=
encodeURIComponent(key) + '=' +
encodeURIComponent(params[key]) + '&';
}
return base.slice(0, -1);
},
basicContact: function(number, records) {
var record;
if (Array.isArray(records)) {
if (records.length > 0) {
record = records[0];
}
} else if (records !== null) {
record = records;
}
// Only exit when no record and no phone number case.
if (!record && !number) {
return;
}
var telLength = (record && record.tel) ? record.tel.length : 0;
var tel;
// Look for the right tel. A record can contains more than
// one record, so we need to identify which one is the right one.
for (var i = 0; i < telLength; i++) {
if (record.tel[i].value === number) {
tel = record.tel[i];
break;
}
}
// If after looking there is no tel. matching, we apply
// directly the number
if (!tel) {
tel = {type: [''], value: number, carrier: ''};
}
// Get the title in the standard way
var details = Utils.getContactDetails(tel, record);
var info = Utils.getDisplayObject(details.title || null, tel);
return info;
},
/*
Given a title for a contact, a the current information for
an specific phone, of that contact, creates an object with
all the information needed to display data.
*/
getDisplayObject: function(theTitle, tel) {
var number = tel.value;
var type = tel.type && tel.type.length ? tel.type[0] : '';
var data = {
name: theTitle || number,
number: number,
type: type,
carrier: tel.carrier || ''
};
if (Settings.supportEmailRecipient) {
data.email = number;
data.emailHTML = '';
}
return data;
},
/*
TODO: It's workaround to avoid url revoke bug. Need platform fixing
to remove the async load/remove.(Please ref bug 972245)
*/
asyncLoadRevokeURL: function(url) {
setTimeout(function() {
var image = new Image();
image.src = url;
image.onload = image.onerror = function revokePhotoURL() {
window.URL.revokeObjectURL(this.src);
};
});
},
/*
TODO: Email Address check.
*/
isEmailAddress: function(email) {
return rmail.test(email);
},
/**
* Helper function for removing notifications. It will fetch notification
* using threadId, and close them from the returned list.
* @param {String|Number} threadId of the target message.
* @returns {Promise.<undefined>} Return always successfully resolved
* Promise with notification.close method called or console.error message.
*/
closeNotificationsForThread: function(threadId) {
var targetTag = 'threadId:' + threadId;
return Notification.get({tag: targetTag})
.then(
function onSuccess(notifications) {
for (var i = 0; i < notifications.length; i++) {
notifications[i].close();
}
}
).catch(function onError(reason) {
console.error('Notification.get(tag: ' + targetTag + '): ', reason);
});
},
/**
* Returns a function that will call specified "func" function only after it
* stops being called for a specified wait time.
* @param {function} func Function to call.
* @param {number} waitTime Number of milliseconds to wait before calling
* actual "func" function once debounced function stops being called.
* @returns {function}
*/
debounce: function(func, waitTime) {
var timeout, args, context;
var executeLater = function() {
func.apply(context, args);
timeout = context = args = null;
};
return function() {
context = this;
args = arguments;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(executeLater, waitTime);
};
},
/**
* Returns a rate limited function, useful to reduce the number of function
* calls when listening to user input (keypress) or browser events.
* @params {function} func - The function that should be rate-limited
* @params {number=300} delay - Min amount of time (ms) between two calls
* @params {Object} options - Parameters for managing edge calls.
* Attention : when both preventFirstCall and preventLastCall are set to
* true, `func` may never be called.
* @param {Boolean=false} options.preventFirstCall - Prevent first
* call when true.
* @param {Boolean=false} options.preventLastCall - Prevent last trailing
* call when true.
* @returns {function} - The rate limited function that will call the
* original function
*/
throttle: function(func, delay = 300, options = {}) {
var timeout = null;
var previous = 0;
var preventFirstCall = !!options.preventFirstCall;
var preventLastCall = !!options.preventLastCall;
if(typeof func !== 'function'){
throw new Error('func must be a Function');
}
if(typeof delay !== 'number' || delay < 0){
throw new Error('delay must be a positive number');
}
return function(...args) {
var now = Date.now();
if (!previous && preventFirstCall){
previous = now;
}
var remaining = delay - (now - previous);
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout && !preventLastCall) {
timeout = setTimeout(() => {
previous = preventFirstCall ? 0 : Date.now();
timeout = null;
func.apply(this, args);
}, remaining);
}
};
},
/**
* Shows modal alert dialog with single OK button to dismiss it.
* @param {string|{ raw: string|Node }|{id: string, args: Object }} message
* Message displayed in the alert. 1. If "message" is string then it's
* considered as l10n identifier; 2. if "message" is object with "raw"
* property then "raw" property is used as non-localizable string or as a
* complete Node; 3. If "message" is object with "id" and/or "args"
* properties then "id" is considered as l10n identifier and "args" as l10n
* string arguments.
* @param {string|{ raw: string|Node }|{id: string, args: Object }} title
* Optional dialog title, if not passed - default title is used. For
* possible parameter value see "message" parameter description above.
* @returns {Promise} Return promise is always successfully resolved.
*/
alert: function (message, title) {
var deferred = this.Promise.defer();
var dialog = new Dialog({
title: title || 'modal-dialog-default-title',
body: message,
options: {
cancel: {
text: 'modal-dialog-ok-button',
method: deferred.resolve
}
}
});
dialog.show();
return deferred.promise;
},
/**
* Shows modal confirm dialog with two buttons, first acts as cancel action,
* second one - as confirm action.
* @param {string|{ raw: string|Node }|{id: string, args: Object }} message
* Message displayed in the confirm. 1. If "message" is string then it's
* considered as l10n identifier; 2. if "message" is object with "raw"
* property then "raw" property is used as non-localizable string or as a
* complete Node; 3. If "message" is object with "id" and/or "args"
* properties then "id" is considered as l10n identifier and "args" as l10n
* string arguments.
* @param {string|{ raw: string|Node }|{id: string, args: Object }} title
* Optional dialog title, if not passed - default title is used. For
* possible parameter value see "message" parameter description above.
* @param {{ text: string|{ raw: string|Node }|{id: string, args: Object },
* className: string}} confirmOptions Optional customizations for confirm
* button, custom text(for possible parameter values see "message"
* parameter description above) and custom class name.
* @returns {Promise} Returned promise is resolved when user tap on confirm
* button and rejected when user taps on cancel button.
*/
confirm: function (message, title, confirmOptions) {
var confirmButtonText = confirmOptions && confirmOptions.text ||
'modal-dialog-ok-button';
var confirmButtonClassName = confirmOptions && confirmOptions.className ||
'recommend';
var deferred = this.Promise.defer();
var dialog = new Dialog({
title: title || 'modal-dialog-default-title',
body: message,
options: {
cancel: {
text: 'modal-dialog-cancel-button',
method: deferred.reject
},
confirm: {
text: confirmButtonText,
className: confirmButtonClassName,
method: deferred.resolve
}
}
});
dialog.show();
return deferred.promise;
},
/**
* Promise related utilities
*/
Promise: {
/**
* The Defer type is useful when creating promises.
* @typedef {Object} Defer
* @property {function(*)} resolve The Promise's resolve function.
* @property {function(*)} reject The Promise's reject function.
* @property {Promise} promise The actual promise.
*/
/**
* Returns object that contains promise and related resolve\reject methods
* to avoid wrapping long or complex code into single Promise constructor.
* @returns {Defer}
*/
defer: function() {
var deferred = {};
deferred.promise = new Promise(function(resolve, reject) {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
},
/**
* Wraps a generator function that yields Promises in a way that generator
* flow is paused until yielded Promise is resolved, so that consumer gets
* Promise result instead of Promise instance itself.
* See https://www.promisejs.org/generators/ as the reference.
* @param {function(...*): Iterator} generatorFunction Generator function
* that yields Promises.
* @return {function}
*/
async: function(generatorFunction) {
return function asyncGenerator() {
var generator = generatorFunction.apply(this, arguments);
function handle(result) {
if (result.done) {
return Promise.resolve(result.value);
}
return Promise.resolve(result.value).then(
(result) => handle(generator.next(result)),
(error) => handle(generator.throw(error))
);
}
try {
return handle(generator.next());
} catch (error) {
return Promise.reject(error);
}
};
}
},
/**
* Return the localized SIM name for multi-SIM scenario or empty string for
* single-SIM scenario
* @param {Number} iccId Integrate circuit card identity for SIM.
* @return {Promise} Return the async translation result like SIM1 or SIM2
* (locale dependent) depending on the iccId or empty string for single-sim
* scenario.
*/
getSimNameByIccId(iccId) {
var index = Settings.getServiceIdByIccId(iccId);
if (index === null) {
return Promise.resolve('');
}
return document.l10n.formatValue('sim-id-label', { id: index + 1 });
},
/**
* Initializes shim host iframe with the current app instance id.
* @param {string} appInstanceId Unique identifier of the app instance.
* @return {Promise.<void>} Resolves once shim host is initialized.
*/
initializeShimHost(appInstanceId) {
var shimHostIframe = document.querySelector('.shim-host');
var promise = typeof shimHostIframe.contentWindow.bootstrap === 'function' ?
Promise.resolve() :
new Promise((resolve) => {
shimHostIframe.addEventListener('load', function onLoad() {
shimHostIframe.removeEventListener('load', onLoad);
resolve();
});
});
return promise.then(
() => {
// Workaround for the Gecko XPCOM issue discussed in bug 1233552, in
// short if we pass handler function created inside nested iframe
// to the mozSetMessageHandler from the main window, then the handler
// could never be called in some cases (e.g. slow environment).
window.mozSetMessageHandler = function(type, handler) {
navigator.mozSetMessageHandler(type, (request) => handler(request));
};
shimHostIframe.contentWindow.bootstrap(appInstanceId);
}
);
},
/**
* Returns Promise instance that is resolved instantly if document is
* visible (document.hidden === false), othewise it's resolved only once
* `visibilitychange` event is fired.
* @returns {Promise.<void>}
*/
onceDocumentIsVisible() {
if (!document.hidden) {
return Promise.resolve();
}
var defer = this.Promise.defer();
document.addEventListener('visibilitychange', function waitVisibility() {
document.removeEventListener('visibilitychange', waitVisibility);
defer.resolve();
});
return defer.promise;
}
};
exports.Utils = Utils;
}(this));

View file

@ -0,0 +1,23 @@
/* -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- /
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
/*exported WaitingScreen */
'use strict';
var WaitingScreen = {
get loading() {
delete this.loading;
return (this.loading = document.getElementById('loading'));
},
get loadingHeader() {
delete this.loadingHeader;
return (this.loadingHeader = document.getElementById('loading-header'));
},
show: function ws_show() {
this.loading.classList.add('show-loading');
},
hide: function ws_hide() {
this.loading.classList.remove('show-loading');
}
};

View file

@ -0,0 +1,104 @@
/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */
// see http://www.wapforum.org/what/technical/SPEC-WAESpec-19990524.pdf
/*exported WBMP */
'use strict';
var WBMP = (function(document) {
function decode(arrayBuffer, callback) {
// Pointer into the byte stream.
var bytes = new Uint8Array(arrayBuffer);
var ptr = 0;
// Read unsigned octet from the byte stream.
function readOctet() {
return bytes[ptr++] & 0xff;
}
// Read unsigned multi-byte integer (6.3.1) from the byte stream.
function readMultiByteInteger() {
var result = 0;
while (true) {
if (result & 0xfe000000) {
throw 'error parsing integer';
}
var b = bytes[ptr++];
result = (result << 7) | (b & 0x7f);
if (!(b & 0x80)) {
return result;
}
}
}
// write a pixel
function write(data, w, bit) {
var color = bit ? 255 : 0;
data[w] = color;
data[w + 1] = color;
data[w + 2] = color;
data[w + 3] = 255;
}
try {
// We only support image type 0: B/W, no compression
if (readMultiByteInteger() !== 0) {
return false;
}
// We don't expect any extended headers here.
if (readOctet() !== 0) {
return false;
}
var width = readMultiByteInteger();
var height = readMultiByteInteger();
// Reject incorrect image dimensions.
if (width === 0 || width > 65535 || height === 0 || height > 65535) {
return false;
}
// Create a canvas to draw the pixels into.
var canvas = document.createElement('canvas');
canvas.setAttribute('width', width);
canvas.setAttribute('height', height);
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var imageData = ctx.createImageData(width, height);
var data = imageData.data;
// Decode the image.
for (var y = 0; y < height; ++y) {
for (var x = 0; x < width; x += 8) {
var bits = bytes[ptr++];
var w = (y * width + x) * 4;
write(data, w, bits & 0x80);
write(data, w + 4, bits & 0x40);
write(data, w + 8, bits & 0x20);
write(data, w + 12, bits & 0x10);
write(data, w + 16, bits & 0x08);
write(data, w + 20, bits & 0x04);
write(data, w + 24, bits & 0x02);
write(data, w + 28, bits & 0x01);
}
}
if (ptr > bytes.length) {
return null;
}
// Update the canvas pixels.
ctx.putImageData(imageData, 0, 0);
// Convert to an image.
canvas.toBlob(callback);
} catch (e) {
// Error occured.
return null;
}
}
return {
decode: decode
};
})(document);

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Messages Shim Host</title>
<!-- Firefox Desktop shims -->
<script src="/desktop-mock/navigator_moz_mobilemessage_shim.js"></script>
<script src="/desktop-mock/navigator_moz_settings_shim.js"></script>
<script src="/desktop-mock/navigator_moz_mobile_connections.js"></script>
<!-- Shim scripts -->
<script src="/lib/bridge/service.js"></script>
<script src="/lib/bridge/plugins/stream/service.js"></script>
<script src="/services/js/bridge_service_mixin.js"></script>
<script src="/services/js/activity/activity_shim.js"></script>
<script src="/services/js/moz_settings/moz_settings_shim.js"></script>
<script src="/services/js/moz_mobile_message/moz_mobile_message_shim.js"></script>
<script src="/services/js/moz_mobile_connections/moz_mobile_connections_shim.js"></script>
<script src="/views/shared/js/shim_host_bootstrap.js"></script>
</head>
<body></body>
</html>

View file

@ -0,0 +1,25 @@
/*
Structure of app
*/
.view-body {
overflow-y: auto;
overflow-x: hidden;
height: -moz-calc(100% - 5rem);
width: 100%;
/* This box-sizing is necessary to prevent vertical scrolling in views that
* specify vertical paddings and/or borders. */
-moz-box-sizing: border-box;
will-change: scroll-position;
}
/* Style of highlighted text. */
.highlight {
background-color: #b2f2ff;
}
/* Hidden elements */
.hide, [data-template] {
display: none !important;
}

View file

@ -0,0 +1,278 @@
#messages-compose-form {
display: flex;
flex-direction: column;
height: auto; /* overrides building blocks */
min-height: 4rem;
background-color: #fff;
}
/* Overwrite. No more fixed height */
form[role="search"] p input,
form[role="search"] p textarea,
form[role="search"] button[type="submit"] + p > textarea {
height: auto;
}
/*
* Input now are contentEditable containers. We define
* placeholder management here as well.
*/
#messages-input {
position: relative; /* to position its generated content */
overflow: auto;
box-sizing: border-box;
width: 100%;
height: 100%;
padding-inline-end: 0.5rem;
color: #474747;
border-inline-end: 0.1rem solid #ccc;
font-size: 1.7rem;
line-height: 2.1rem;
unicode-bidi: -moz-plaintext;
}
#messages-input.placeholder {
unicode-bidi: unset;
}
#messages-input:after {
content: attr(title);
position: absolute;
top: 0;
left: 0;
right: 0;
color: #9b9b9b;
font-style: italic;
white-space: nowrap;
pointer-events: none;
visibility: hidden;
}
#messages-input.placeholder:after {
visibility: visible;
}
/* Overwrite BB because of the previous rule */
form[role="search"] button[type="submit"]:after {
content: "";
background: none;
}
.mms-label-line {
display: flex;
justify-content: flex-end;
align-items: flex-end;
background-color: #e1f0ec;
}
.subject-composer {
position: relative;
flex-grow: 1;
min-width: 0;
padding: 1rem 0 0.3rem;
}
.mms-label {
padding: 1rem 0 0.3rem;
}
.mms-label,
.message-counter {
display: none;
flex: none;
text-align: center;
box-sizing: border-box;
width: 6rem;
color: #575757;
font-size: 1.4rem;
}
.message-counter {
padding-top: .7rem;
}
/**
* 1. "MMS" label is displayed near the subject field when subject input is
* visible and message type is MMS;
* 2. Remaining characters counter is displayed in bottom section if character
* count exceeds predefined threshold and message type is SMS.
*/
[data-message-type='mms'] .mms-label,
[data-message-type='sms'] .message-counter:not(:empty) {
display: block;
}
.content-composer {
display: flex;
flex-direction: row;
min-height: 0;
}
.subject-composer-input {
position: relative;
box-sizing: border-box;
min-height: 2.2rem;
padding: 0;
padding-bottom: 0.1rem;
padding-inline-start: 1.5rem;
color: #008787;
font-size: 1.7rem;
}
.subject-composer-placeholder {
position: absolute;
top: 1rem;
/* adding both left/right so that it behaves correctly in RTL */
left: 1.5rem;
right: 1.5rem;
color: #00adad;
font-size: 1.7rem;
font-weight: normal;
font-style: italic;
pointer-events: none;
}
.composer-message-input-container {
position: relative;
flex: auto;
box-sizing: border-box;
min-width: 0;
height: 100%;
min-height: 4rem;
padding: 1rem 0;
}
.composer-button-container {
position: relative;
display: flex;
flex: none;
flex-direction: row;
justify-content: center;
align-content: space-between;
flex-wrap: wrap;
width: 6rem;
-moz-user-select: none; /* without this, tapping on the element selects its content */
}
.messages-attach-container {
display: flex;
}
#messages-send-button {
height: 4rem;
overflow: visible;
background: #fff no-repeat center center / 3rem;
align-self: flex-end;
}
html[dir="ltr"] #messages-send-button {
background-image: url(images/icons/actionicon_sms_send_30x30.png);
/* When we put the icon at the center, an optical illusion makes it look to be
* slightly to the left, so we offset the button by 2px to the right to
* counteract this illusion */
transform: translate(.2rem);
}
html[dir="rtl"] #messages-send-button {
background-image: url(images/icons/actionicon_sms_send_30x30_rtl.png);
transform: translate(-.2rem);
}
#messages-send-button:after {
display: none
}
#messages-send-button[disabled] {
background-blend-mode: luminosity;
opacity: 0.625;
}
#messages-attach-button {
align-self: flex-end;
flex: none;
box-sizing: content-box;
width: 3rem;
margin: 0;
padding: 0;
padding-inline-start: 0.5rem;
padding-inline-end: 1rem;
background: transparent
url('images/icons/actionicon_sms_add_attachment_30x30.png')
no-repeat center / 3rem;
}
#messages-attach-button[disabled] {
background-blend-mode: luminosity;
opacity: 0.3;
}
/*
DSDS
*/
.sim-indication {
position: absolute;
offset-inline-end: 1.8rem;
bottom: 0.4rem;
display: none;
line-height: 1;
color: #5f5f5f;
font-size: 1.4rem;
font-style: normal;
}
.sim-indication:before {
position: absolute;
/* 2.6rem from the end; means .8rem from .sim-indication */
offset-inline-end: .8rem;
bottom: -0.1rem;
content: '...';
color: #b2b2b2;
font-size: 1.6rem;
}
button[disabled] .sim-indication {
color: inherit;
}
.has-preferred-sim .sim-indication {
display: block;
}
/**
* Override Building Block [Input areas] styles
* We should get rid of all [role="search"] Messages rules in bug 1055357.
*/
form.bottom[role="search"] {
z-index: 3;
}

View file

@ -0,0 +1,108 @@
/*
* This file contains styles shared between views when they're in edit mode.
* View edit mode is activated via Options -> Select threads/messages.
*/
/* Override Building Block [Edit mode] styles */
form[role="dialog"][data-type="edit"] gaia-header {
transform: translateY(-110%);
}
form[role="dialog"][data-type="edit"] > menu {
transform: translateY(110%);
}
/* Add a margin at the end of the last thread or message node to avoid overlay
* with the edit mode menu */
.edit .edit-container {
padding-bottom: 7rem;
}
#main-wrapper.edit [data-type="list"] li a {
pointer-events: none;
}
.edit .bottom-tab[role="region"] {
visibility: visible;
}
/* Override Building Block Tabs styles */
form[role="dialog"][data-type="edit"] .bb-tablist:before {
bottom: 5.5rem ;
}
form[role="dialog"][data-type="edit"] .bb-tablist > li {
height: 5.5rem ;
}
.bb-tablist > li button {
background-position: 50% 50% ;
}
.bb-tablist > li button[data-action="delete-button"] {
background-image: url("images/icons/delete.png");
}
/* Lists: edit mode */
html[dir="ltr"] .edit [data-type="list"] li > a {
transform: translateX(-1.5rem);
}
html[dir="rtl"] .edit [data-type="list"] li > a {
transform: translateX(1.5rem);
}
.edit [data-type="list"] li > label {
opacity: 1;
pointer-events: auto;
}
html[dir="ltr"] .edit [data-type="list"] li > label {
transform: translateX(2rem);
}
html[dir="rtl"] .edit [data-type="list"] li > label {
transform: translateX(-2rem);
}
.edit [data-type="list"] li > .pack-checkbox input ~ span:after {
opacity: 1;
}
html[dir="ltr"] .edit [data-type="list"] li > .pack-checkbox input ~ span:after {
transform: translateX(2.5rem);
}
html[dir="rtl"] .edit [data-type="list"] li > .pack-checkbox input ~ span:after {
transform: translateX(-2.5rem);
}
.edit [data-type="list"] li > a > p {
margin-inline-end: 3.4rem;
}
html[dir="ltr"] .edit [data-type="list"] li > a > p {
transform: translateX(5rem);
}
html[dir="rtl"] .edit [data-type="list"] li > a > p {
transform: translateX(-5rem);
}
/* Currently is used in ConversationView only */
html[dir="ltr"] .edit [data-type="list"] .danger {
transform: translateX(2.5rem);
}
html[dir="rtl"] .edit [data-type="list"] .danger {
transform: translateX(-2.5rem);
}
form[role="dialog"][data-type="edit"] {
visibility: hidden;
}
.edit form[role="dialog"][data-type="edit"] {
visibility: visible;
}
.edit form[role="dialog"][data-type="edit"] > menu {
transform: translateY(0);
}
.edit form[role="dialog"][data-type="edit"] gaia-header {
transform: translateY(0);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 B

View file

@ -0,0 +1,34 @@
/* Most of this should probably move to the Building Blocks: Bug 1011418 */
section[role="notification"] {
box-sizing: border-box;
z-index: 1;
position: relative; /* so that we can compete
with absolutely positioned elements */
overflow: hidden;
min-height: 3.5rem;
background-color: rgba(77,77,77,0.95);
font-size: 1.5rem;
font-weight: 300;
font-style: italic;
color: #fff;
}
section[role="notification"] a {
color: #9ef2e7;
text-decoration: underline;
}
section[role="notification"] p {
margin: .9rem 1.5rem;
line-height: 1.7rem;
text-align: center;
}
section[role="notification"] a {
color: #9ef2e7;
text-decoration: underline;
}

View file

@ -0,0 +1,25 @@
.panel {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: auto;
background: white;
animation: var(--transition-duration) both ease;
}
.panel {
transform: translate(100%); /* BiDi proof */
}
.panel-active {
transform: translate(0);
}
.panel-hidden {
display: none;
}

View file

@ -0,0 +1,172 @@
/* Global */
html, body {
height: 100%;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
color: #000;
font-size: 1.6rem;
line-height: 2.2rem;
vertical-align: middle;
background: var(--header-background);
}
/* Text conventions */
h1, h2, h3, h4, h5, h6 {
margin: 0;
padding: 0;
font-weight: normal;
}
ul, ol, dl, dt, dd {
padding: 0;
margin: 0;
list-style-type: none;
}
p, li {
word-wrap: break-word;
margin: 0;
padding: 0;
}
q {
quotes: none;
display: block;
}
pre, blockquote {
padding: 0;
margin: 0;
}
em {
font-style: italic;
}
address {
font-style: normal;
display: inline;
}
abbr, acronym {
cursor: default;
border: none;
}
strong {
font-weight: 700;
}
b {
font-weight: 600;
}
small {
font-size: 1.1rem;
line-height: 1rem;
color: #999;
}
/* Links */
a {
text-decoration: none;
outline: none;
color: #ff4e00;
}
a:hover {
text-decoration: none;
}
a img {
border: none;
}
a abbr {
cursor: pointer;
}
/* Forms */
form {
margin: 0;
}
fieldset {
margin: 2rem 0 0;
padding: 0;
border: none;
}
fieldset.first {
margin: 0;
}
legend {
padding: 0;
display: block;
width: 0;
height: 0;
overflow: hidden;
font: 0/0 a;
}
button::-moz-focus-inner {
padding: 0;
border: none;
}
input, textarea, select, button {
margin: 0;
padding: 0;
vertical-align: middle;
}
input:focus, textarea:focus {
outline: none;
}
textarea {
resize: none;
overflow: auto;
height: 4.6rem;
}
input[type="text"], textarea {
font-size: 1.3rem;
line-height: 4rem;
}
input[type="text"]:focus, textarea:focus {
border-color: #7a7a7a;
}
[contenteditable] {
outline: none;
word-wrap: break-word;
}
/* Media */
img {
display: block;
padding: 0;
margin: 0;
width: 100%;
}
img:-moz-broken {
line-height: 1.1rem;
overflow: hidden;
}
/* HTML 5 */
article, aside, details, figcaption, figure, footer, header, hgroup,
menu, nav, section, video, audio, canvas, progress, meter, time {
display: block;
padding: 0;
margin: 0;
}

View file

@ -0,0 +1,261 @@
html, body {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
font-size: 10px;
overflow-x: hidden !important;
overflow-y: hidden !important;
}
.shim-host {
width: 0;
height: 0;
}
/* Default alignment in case a dir attribute is specified. */
html[dir="ltr"] [dir] { text-align: left; }
html[dir="rtl"] [dir] { text-align: right; }
/*
Override BB. Some CSS tweaks for having a better confirm readability
*/
form[role="dialog"][data-type="confirm"] p {
white-space: pre-line;
}
form[role="dialog"].error-dialog-show-recipient ul {
border: none;
}
form[role="dialog"].error-dialog-show-recipient ul li {
padding: 0;
}
/* This does not override the building block, that's why we use a shorter
* selector */
.error-dialog-show-recipient li {
/* future-proof in case BB moves to "text-align: start" */
width: -moz-fit-content;
/* List items are phone numbers */
direction: ltr;
unicode-bidi: -moz-isolate;
}
gaia-header {
position: relative;
z-index: 10;
}
.subheader {
position: absolute;
z-index: 6; /* should be between 4 and 9 (inclusive). 3 is the z-index for
the input form, 10 is the z-index for the header */
left: 0;
right: 0;
top: 5rem;
}
/* Container for conversation/new message view */
#composer-container {
position: relative;
background: #e1f0ec;
}
/*
* Flex layout is wrapped by 'flex-composer'. Our flex layout has
* 2 well differentiated:
* - 'messages-container'
* - 'messages-compose-form'
*/
.flex-composer {
display: flex;
flex-direction: column;
height: 100%;
top: 0;
overflow: hidden;
}
/* Override Building Block [Lists] styles */
[data-type="list"] {
padding: 0;
}
.bottom-tab[role="region"] {
visibility: hidden;
}
[data-type="list"] header {
margin: 0 1.5rem;
}
[data-type="list"] li > label {
display: block;
overflow: hidden;
opacity: 0;
}
[data-type="list"] aside.pack-end {
margin: 0 .5rem;
}
/* Panel handling */
#main-wrapper {
position: absolute;
overflow: hidden;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
.disabled {
opacity: 0.3;
pointer-events: none;
}
/*
Loading screen while deleting
*/
#loading.show-loading {
display: block;
}
#loading {
display: none;
}
.loading-header {
text-align: start;
}
#loading [role="status"] {
text-align: center;
height: 4rem;
}
#loading p {
border-bottom: none;
}
.article-list[data-type="list"] ul.contact-list {
overflow-x: hidden;
overflow-y: auto;
width: calc(100% - 3.1rem);
padding: 0 1.5rem;
background: #fff;
}
/**
* TODO: It's an adjustment for line-height/padding to prevent bottom of chars
* been cut. This fix will be moved to shared list.css once Bug 1144588 landed
*/
.contact-list .suggestion > .name {
line-height: 2.4rem;
padding-top: 0.8rem;
}
form[role="dialog"][data-type="action"].contact-prompt > header {
height: auto;
min-height: 5rem;
font-weight: 300;
}
.unknown-contact-header {
line-height: 2.5rem;
padding: 1.3rem 0;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 2.3rem;
}
.contact-header {
/* bottom padding is .9rem because we need .1rem more for the
* nice shadow line at the header bottom, defined as a :after element */
padding: 0.8rem 0 0.9rem;
}
.contact-header .name {
line-height: 3.3rem;
padding: 0.1rem 0 0.4rem;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 2.3rem;
}
.contact-header .details {
line-height: 1.5rem;
padding: 0 0 0.7rem;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.5rem;
}
.contact-header span[data-type=img] {
display: block;
width: 6rem;
height: 6rem;
background-size: cover;
background-position: center;
}
.contact-header aside.pack-end {
margin-inline-end: -1.5rem; /* compensate the header's 3rem padding */
}
html[dir="ltr"] .contact-header aside.pack-end { float: right; }
html[dir="rtl"] .contact-header aside.pack-end { float: left; }
.suggestion * {
pointer-events: none;
}
form[role="dialog"][data-type="action"] > header {
text-align: start;
}
/* Phone details label styles */
.phone-type,
.phone-type-separator,
.phone-carrier,
.phone-carrier-separator {
display: none;
}
.has-phone-type > .phone-type,
.has-phone-type > .phone-type-separator,
.has-phone-carrier > .phone-carrier,
.has-phone-carrier > .phone-carrier-separator {
display: unset;
}
/* Text selection control */
html {
-moz-user-select: none;
}
.editable-select-mode [contenteditable="true"] {
-moz-user-select: text;
}
/* ellipsis direction hack before bug 883884 fixed */
.ellipsis-dir-fix {
display: block;
width: -moz-fit-content;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,3 @@
{
"extends": "../../../../test/unit/.jshintrc"
}

View file

@ -0,0 +1,423 @@
/*global ActivityClient,
ActivityHandler,
App,
Attachment,
Drafts,
MessageManager,
MocksHelper,
Navigation,
Settings,
Utils
*/
'use strict';
require('/views/shared/test/unit/mock_attachment.js');
require('/services/test/unit/mock_message_manager.js');
require('/services/test/unit/activity/mock_activity_shim.js');
require('/services/test/unit/activity/mock_activity_client.js');
require('/views/shared/test/unit/mock_app.js');
require('/views/shared/test/unit/mock_settings.js');
require('/views/shared/test/unit/mock_navigation.js');
require('/services/test/unit/mock_drafts.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_utils.js');
require('/views/shared/js/activity_handler.js');
var mocksHelperForActivityHandler = new MocksHelper([
'ActivityClient',
'ActivityShim',
'App',
'Attachment',
'Draft',
'Drafts',
'MessageManager',
'Navigation',
'Settings',
'Utils'
]).init();
suite('ActivityHandler', function() {
mocksHelperForActivityHandler.attachTestHelpers();
setup(function() {
this.sinon.stub(Utils, 'onceDocumentIsVisible').returns(Promise.resolve());
this.sinon.stub(Utils, 'alert').returns(Promise.resolve());
this.sinon.stub(ActivityClient);
});
test('init()', function() {
ActivityHandler.init();
sinon.assert.calledOnce(ActivityClient.init);
sinon.assert.calledWith(ActivityClient.init, App.instanceId);
sinon.assert.calledWith(ActivityClient.on, 'new-activity-request');
sinon.assert.calledWith(ActivityClient.on, 'share-activity-request');
});
suite('"share" activity', function() {
var activityData;
function onceShareActivityCompleted() {
sinon.assert.called(ActivityHandler._onShareActivity);
return ActivityHandler._onShareActivity.lastCall.returnValue;
}
setup(function() {
this.sinon.spy(ActivityHandler, '_onShareActivity');
this.sinon.spy(Navigation, 'toPanel');
this.sinon.spy(Drafts, 'add');
this.sinon.spy(Drafts, 'store');
this.sinon.spy(Drafts, 'request');
activityData = {
type: 'video/*',
blobs: [
new Blob(['test'], { type: 'video/x-video' }),
new Blob(['test2'], { type: 'video/x-video' }),
new Blob(),
new Blob(),
new Blob()
],
filenames: [
'testBlob1', 'testBlob2', 'testBlob3', 'testBlob4', 'testBlob5'
]
};
ActivityHandler.init();
});
test('moves to the composer panel after saving draft', function(done) {
ActivityClient.on.withArgs('share-activity-request').yield(
activityData
);
onceShareActivityCompleted().then(() => {
sinon.assert.calledWithMatch(Drafts.add, {
recipients: null,
type: 'mms',
content: [
sinon.match.instanceOf(Attachment),
sinon.match.instanceOf(Attachment),
sinon.match.instanceOf(Attachment),
sinon.match.instanceOf(Attachment),
sinon.match.instanceOf(Attachment)
]
});
sinon.assert.called(Drafts.store);
sinon.assert.callOrder(Drafts.request, Drafts.add, Drafts.store);
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: sinon.match.falsy }
);
}).then(done, done);
});
test('Attachment size over max mms should not be appended', function(done) {
// Adjust mmsSizeLimitation for verifying alert popup when size over
// limitation.
Settings.mmsSizeLimitation = 1;
ActivityClient.on.withArgs('share-activity-request').yield(
activityData
);
sinon.assert.calledWith(Utils.alert, {
id: 'attached-files-too-large',
args: { n: 5, mmsSize: '0' }
});
Utils.alert.lastCall.returnValue.then(() => {
sinon.assert.calledWithExactly(ActivityClient.postResult);
sinon.assert.notCalled(Navigation.toPanel);
}).then(done, done);
});
test('Should append images even when they are big', function(done) {
activityData.blobs = [new Blob(['test'], { type: 'image/jpeg' })];
Settings.mmsSizeLimitation = 1;
ActivityClient.on.withArgs('share-activity-request').yield(
activityData
);
sinon.assert.notCalled(Utils.alert);
onceShareActivityCompleted().then(() => {
sinon.assert.calledWithMatch(Drafts.add, {
recipients: null,
type: 'mms',
content: [sinon.match.instanceOf(Attachment)]
});
sinon.assert.called(Drafts.store);
sinon.assert.callOrder(Drafts.request, Drafts.add, Drafts.store);
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: sinon.match.falsy }
);
}).then(done, done);
});
test('Should append vcard attachment', function(done) {
activityData.blobs = [new Blob(['test'], { type: 'text/x-vcard' })];
ActivityClient.on.withArgs('share-activity-request').yield(
activityData
);
sinon.assert.notCalled(Utils.alert);
onceShareActivityCompleted().then(() => {
sinon.assert.calledWithMatch(Drafts.add, {
recipients: null,
type: 'mms',
content: [sinon.match.instanceOf(Attachment)]
});
sinon.assert.called(Drafts.store);
sinon.assert.callOrder(Drafts.request, Drafts.add, Drafts.store);
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: sinon.match.falsy }
);
}).then(done, done);
});
test('Appends URL to the Compose field for activity with URL data type',
function(done) {
var urlActivityData = {
type: 'url',
url: 'test_url'
};
ActivityClient.on.withArgs('share-activity-request').yield(
urlActivityData
);
sinon.assert.notCalled(Utils.alert);
onceShareActivityCompleted().then(() => {
sinon.assert.calledWithMatch(Drafts.add, {
recipients: null,
type: 'sms',
content: [urlActivityData.url]
});
sinon.assert.called(Drafts.store);
sinon.assert.callOrder(Drafts.request, Drafts.add, Drafts.store);
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: sinon.match.falsy }
);
}).then(done, done);
});
test('Call activity postError if no data to share', function() {
ActivityClient.on.withArgs('share-activity-request').yield({
type: 'url'
});
sinon.assert.notCalled(Navigation.toPanel);
sinon.assert.notCalled(ActivityClient.postResult);
sinon.assert.calledOnce(ActivityClient.postError);
sinon.assert.calledWith(
ActivityClient.postError, 'No data to share found!'
);
});
test('Call activity postError on unknown activity data type', function() {
ActivityClient.on.withArgs('share-activity-request').yield({
type: 'multipart/mixed'
});
sinon.assert.notCalled(Navigation.toPanel);
sinon.assert.notCalled(ActivityClient.postResult);
sinon.assert.calledOnce(ActivityClient.postError);
sinon.assert.calledWith(
ActivityClient.postError,
'Unsupported activity data type: multipart/mixed'
);
});
});
suite('"new" activity', function() {
var emailActivityData;
function onceNewActivityCompleted() {
sinon.assert.called(ActivityHandler._onNewActivity);
return ActivityHandler._onNewActivity.lastCall.returnValue;
}
function assertDraftSaved(draft) {
sinon.assert.calledWithMatch(Drafts.add, draft);
sinon.assert.called(Drafts.store);
sinon.assert.callOrder(Drafts.request, Drafts.add, Drafts.store);
}
var threadDeferred;
setup(function() {
emailActivityData = {
target: 'abc@exmple.com',
body: 'foo'
};
// configure findThreadFromNumber
threadDeferred = Utils.Promise.defer();
this.sinon.stub(MessageManager, 'findThreadFromNumber').returns(
threadDeferred.promise
);
this.sinon.spy(Navigation, 'toPanel');
this.sinon.spy(Drafts, 'add');
this.sinon.spy(Drafts, 'store');
this.sinon.spy(Drafts, 'request');
this.sinon.spy(ActivityHandler, '_onNewActivity');
ActivityHandler.init();
});
test('Non-existing thread, with number and body', function(done) {
ActivityClient.on.withArgs('new-activity-request').yield({
number: '123',
body: 'foo'
});
// This should not be used but keeping it here to have a consistent
// environment for this test case.
threadDeferred.reject(new Error('No thread for this test'));
onceNewActivityCompleted().then(function() {
sinon.assert.notCalled(MessageManager.findThreadFromNumber);
assertDraftSaved({
recipients: ['123'],
type: 'sms',
content: ['foo']
});
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: 'composer' }
);
}).then(done,done);
});
test('existing thread with specified body, navigate to composer',
function(done) {
ActivityClient.on.withArgs('new-activity-request').yield({
number: '123',
body: 'foo'
});
// This should not be used but keeping it here to have a consistent
// environment for this test case.
threadDeferred.resolve(42);
onceNewActivityCompleted().then(function() {
sinon.assert.notCalled(MessageManager.findThreadFromNumber);
assertDraftSaved({
recipients: ['123'],
type: 'sms',
content: ['foo']
});
sinon.assert.calledWithMatch(
Navigation.toPanel,
'composer', { draftId: 'draftId' }
);
}).then(done,done);
});
test('new message with body only', function(done) {
ActivityClient.on.withArgs('new-activity-request').yield(
{ body: 'foo' }
);
onceNewActivityCompleted().then(() => {
sinon.assert.notCalled(MessageManager.findThreadFromNumber);
assertDraftSaved({
recipients: null,
type: 'sms',
content: ['foo']
});
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: sinon.match.falsy }
);
}).then(done,done);
});
test('Non-existing thread with recipient only, navigate to composer',
function(done) {
ActivityClient.on.withArgs('new-activity-request').yield({
number: '123'
});
threadDeferred.reject(new Error('No thread for this test'));
onceNewActivityCompleted().then(function() {
sinon.assert.calledWith(MessageManager.findThreadFromNumber, '123');
assertDraftSaved({
recipients: ['123'],
type: 'sms',
content: null
});
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: 'composer' }
);
}).then(done,done);
});
test('new message with email and body', function(done) {
ActivityClient.on.withArgs('new-activity-request').yield(
emailActivityData
);
// This is not used but it's kept here for consistency.
threadDeferred.reject(new Error('No thread for this test'));
onceNewActivityCompleted().then(function() {
sinon.assert.notCalled(MessageManager.findThreadFromNumber);
assertDraftSaved({
recipients: [emailActivityData.target],
type: 'mms',
content: [emailActivityData.body]
});
sinon.assert.calledWith(
Navigation.toPanel,
'composer',
{ draftId: 'draftId', focus: 'composer' }
);
}).then(done,done);
});
test('existing thread with recipient only, navigate to the thread',
function(done) {
ActivityClient.on.withArgs('new-activity-request').yield({
number: '123'
});
// this time we can find a thread with id=42
threadDeferred.resolve(42);
onceNewActivityCompleted().then(function() {
sinon.assert.calledWithMatch(
Navigation.toPanel, 'thread', { id: 42, focus: 'composer' }
);
}).then(done,done);
});
});
});

View file

@ -0,0 +1,255 @@
/*global MocksHelper, MockL10n, ActivityPicker, MozActivity */
'use strict';
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/js/activity_picker.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_moz_activity.js');
require('/views/shared/test/unit/mock_utils.js');
var mocksHelperAP = new MocksHelper([
'MozActivity',
'Utils'
]).init();
suite('ActivityPicker', function() {
var realMozL10n, onsuccess, onerror;
suiteSetup(function() {
realMozL10n = document.l10n;
document.l10n = MockL10n;
mocksHelperAP.suiteSetup();
onsuccess = function() {};
onerror = function() {};
});
suiteTeardown(function() {
document.l10n = realMozL10n;
mocksHelperAP.suiteTeardown();
});
setup(function() {
mocksHelperAP.setup();
});
teardown(function() {
mocksHelperAP.teardown();
});
suite('dial', function() {
test('dial(number) ', function() {
ActivityPicker.dial('999');
assert.deepEqual(MozActivity.calls[0], {
name: 'dial',
data: { type: 'webtelephony/number', number: '999' }
});
});
test('dial(number, success) ', function() {
ActivityPicker.dial('999', onsuccess);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.deepEqual(MozActivity.calls[0], {
name: 'dial',
data: { type: 'webtelephony/number', number: '999' }
});
});
test('dial(number, success, error) ', function() {
ActivityPicker.dial('999', onsuccess, onerror);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.equal(
MozActivity.instances[0].onerror, onerror
);
assert.deepEqual(MozActivity.calls[0], {
name: 'dial',
data: { type: 'webtelephony/number', number: '999' }
});
});
});
suite('email', function() {
test('email(email) ', function() {
ActivityPicker.email('a@b.com');
assert.deepEqual(MozActivity.calls[0], {
name: 'new',
data: {
type: 'mail',
URI: 'mailto:a@b.com'
}
});
});
test('email(email, success) ', function() {
ActivityPicker.email('a@b.com', onsuccess);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.deepEqual(MozActivity.calls[0], {
name: 'new',
data: {
type: 'mail',
URI: 'mailto:a@b.com'
}
});
});
test('email(email, success, error) ', function() {
ActivityPicker.email('a@b.com', onsuccess, onerror);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.equal(
MozActivity.instances[0].onerror, onerror
);
assert.deepEqual(MozActivity.calls[0], {
name: 'new',
data: {
type: 'mail',
URI: 'mailto:a@b.com'
}
});
});
});
suite('url', function() {
test('url(url) ', function() {
ActivityPicker.url('http://mozilla.com');
assert.deepEqual(MozActivity.calls[0], {
name: 'view',
data: {
type: 'url',
url: 'http://mozilla.com'
}
});
});
test('url(url, success) ', function() {
ActivityPicker.url('http://mozilla.com', onsuccess);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.deepEqual(MozActivity.calls[0], {
name: 'view',
data: {
type: 'url',
url: 'http://mozilla.com'
}
});
});
test('url(url, success, error) ', function() {
ActivityPicker.url('http://mozilla.com', onsuccess, onerror);
assert.equal(
MozActivity.instances[0].onsuccess, onsuccess
);
assert.equal(
MozActivity.instances[0].onerror, onerror
);
assert.deepEqual(MozActivity.calls[0], {
name: 'view',
data: {
type: 'url',
url: 'http://mozilla.com'
}
});
});
});
test('createNewContact', function() {
ActivityPicker.createNewContact({foo: ['bar']});
assert.deepEqual(MozActivity.calls[0], {
name: 'new',
data: {
type: 'webcontacts/contact',
params: { foo: ['bar'] }
}
});
});
test('addToExistingContact', function() {
ActivityPicker.addToExistingContact({foo: ['bar']});
assert.deepEqual(MozActivity.calls[0], {
name: 'update',
data: {
type: 'webcontacts/contact',
params: { foo: ['bar'] }
}
});
});
test('viewContact', function() {
ActivityPicker.viewContact({foo: ['bar']});
assert.deepEqual(MozActivity.calls[0], {
name: 'open',
data: {
type: 'webcontacts/contact',
params: { foo: ['bar'] }
}
});
});
suite('openSettings', function() {
var params = {
name: 'configure',
data: {
target: 'device',
section: 'messaging'
}
};
test('openSettings() ', function() {
ActivityPicker.openSettings();
assert.deepEqual(MozActivity.calls[0], params);
});
test('openSettings(success) ', function() {
ActivityPicker.openSettings(onsuccess);
assert.equal(MozActivity.instances[0].onsuccess, onsuccess);
assert.deepEqual(MozActivity.calls[0], params);
});
test('openSettings(success, error) ', function() {
ActivityPicker.openSettings(onsuccess, onerror);
assert.equal(MozActivity.instances[0].onsuccess, onsuccess);
assert.equal(MozActivity.instances[0].onerror, onerror);
assert.deepEqual(MozActivity.calls[0], params);
});
});
});

View file

@ -0,0 +1,89 @@
/*global App, Promise */
'use strict';
require('/views/shared/js/app.js');
suite('App', function() {
// Taken from app.js
const APPLICATION_READY_CLASS_NAME = 'js-app-ready';
teardown(function() {
document.body.classList.remove(APPLICATION_READY_CLASS_NAME);
});
test('isReady is false by default', function() {
assert.equal(App.isReady(), false);
});
test('setReady sets body class and isReady', function() {
App.setReady();
assert.equal(App.isReady(), true);
assert.ok(document.body.classList.contains(APPLICATION_READY_CLASS_NAME));
});
test('setReady throws exception if called more than once', function() {
App.setReady();
assert.throws(function() {
App.setReady();
});
});
test('whenReady is resolved immediately if app is ready', function(done) {
App.setReady();
App.whenReady().then(function() {
assert.ok(App.isReady());
}).then(done, done);
});
test('whenReady is resolved when setReady is called', function(done) {
var stub = sinon.stub();
this.sinon.spy(App, 'setReady');
var whenReadyPromise = App.whenReady();
whenReadyPromise.then(stub).then(function() {
assert.ok(App.isReady());
sinon.assert.callOrder(App.setReady, stub);
}).then(done, done);
Promise.resolve().then(function() {
App.setReady();
});
});
test('whenReady is rejected in case of error', function(done) {
var error = new Error('Test error');
this.sinon.stub(MutationObserver.prototype, 'observe', function() {
throw error;
});
App.whenReady().then(function() {
throw new Error('Success callback should not have been called.');
}, function(e) {
assert.equal(e, error);
}).then(done, done);
App.setReady();
});
test('whenReady is rejected in case of error in MutationObserver callback',
function(done) {
var error = new Error('Test error');
// This is called inside MutationObserver callback
this.sinon.stub(MutationObserver.prototype, 'disconnect', function() {
throw error;
});
App.whenReady().then(function() {
throw new Error('Success callback should not have been called.');
}, function(e) {
assert.equal(e, error);
}).then(done, done);
App.setReady();
});
});

View file

@ -0,0 +1,61 @@
/*global Promise */
/*exported AssetsHelper */
'use strict';
var AssetsHelper = {
generateCanvas: function(width, height) {
var canvas = document.createElement('canvas'),
context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
var linearGradient = context.createLinearGradient(0, 0, width, height);
linearGradient.addColorStop(0, 'blue');
linearGradient.addColorStop(1, 'red');
context.fillStyle = linearGradient;
context.fillRect (0, 0, width, height);
return canvas;
},
generateImageDataURL: function(width, height, type, quality) {
var canvas = null;
try {
canvas = this.generateCanvas(width, height);
return canvas.toDataURL(type, quality);
} finally {
canvas.width = canvas.height = 0;
canvas = null;
}
},
generateImageBlob: function(width, height, type, quality) {
var canvas = this.generateCanvas(width, height);
return new Promise(function(resolve) {
canvas.toBlob(function(blob) {
canvas.width = canvas.height = 0;
canvas = null;
resolve(blob);
}, type, quality);
});
},
loadFileBlob: function(filePath) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', filePath, true);
xhr.responseType = 'blob';
xhr.onload = function() {
resolve(xhr.response);
};
xhr.onerror = reject;
xhr.send();
});
}
};

View file

@ -0,0 +1,891 @@
/*global ContactRenderer, loadBodyHTML, MockContact, MockL10n, MocksHelper,
Utils, Template, MockContactPhotoHelper, SharedComponents,
MockSettings,
AssetsHelper
*/
'use strict';
require('/views/shared/js/shared_components.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_utils.js');
require('/views/shared/test/unit/mock_contact.js');
require('/shared/test/unit/mocks/mock_l20n.js');
require('/shared/test/unit/mocks/mock_contact_photo_helper.js');
require('/views/shared/js/contact_renderer.js');
require('/views/shared/test/unit/mock_settings.js');
var mocksHelperForContactRenderer = new MocksHelper([
'Utils',
'ContactPhotoHelper',
'Settings'
]).init();
suite('ContactRenderer', function() {
var realMozL10n;
var ul, contact;
var testImageBlob;
var renderer, unknownRender;
mocksHelperForContactRenderer.attachTestHelpers();
suiteSetup(function(done) {
realMozL10n = document.l10n;
document.l10n = MockL10n;
AssetsHelper.generateImageBlob(400, 400, 'image/jpeg', 0.5).then(
(blob) => {
testImageBlob = blob;
}
).then(done, done);
});
suiteTeardown(function() {
document.l10n = realMozL10n;
realMozL10n = null;
});
setup(function() {
loadBodyHTML('/index.html');
this.sinon.spy(Template.prototype, 'interpolate');
ul = document.createElement('ul');
contact = MockContact();
});
suite('suggestion', function() {
setup(function() {
renderer = ContactRenderer.flavor('suggestion');
});
test('Rendered Contact "givenName familyName"', function() {
var html;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
sinon.assert.calledWithMatch(Template.prototype.interpolate, {
carrier: 'TEF',
name: 'Pepito O\'Hare',
nameHTML: 'Pepito O&apos;Hare',
number: '+346578888888',
phoneDetailsHTML: SharedComponents.phoneDetails(
{
number: '+346578888888',
type: 'Mobile',
carrier: 'TEF'
}
).toString(),
photoHTML: '',
type: 'Mobile'
});
html = ul.firstElementChild.innerHTML;
assert.include(html, 'Pepito O\'Hare');
});
test('Rendered Contact highlighted "givenName familyName"', function() {
var html;
renderer.render({
contact: contact,
input: 'Pepito O\'Hare',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.include(html, '<span class="highlight">Pepito</span>');
assert.include(html, '<span class="highlight">O\'Hare</span>');
});
test('Rendered Contact "number"', function() {
var html;
contact.tel[0].carrier = null;
contact.tel[0].type = null;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.ok(html.includes('+346578888888'));
});
test('Rendered Contact highlighted "number"', function() {
var html;
contact.tel[0].carrier = null;
contact.tel[0].type = null;
renderer.render({
contact: contact,
input: '346578888888',
target: ul
});
sinon.assert.calledWithMatch(Template.prototype.interpolate, {
carrier: '',
name: 'Pepito O\'Hare',
nameHTML: 'Pepito O&apos;Hare',
number: '+346578888888',
phoneDetailsHTML: SharedComponents.phoneDetails(
{ number: '+<span class="highlight">346578888888</span>' },
{ safe: ['number'] }
).toString(),
photoHTML: '',
type: ''
});
html = ul.firstElementChild.innerHTML;
assert.ok(
html.includes('+<span class="highlight">346578888888</span>')
);
});
test('Rendered Contact highlighted "name number"', function() {
var html;
renderer.render({
contact: contact,
input: 'Pepito 346578888888',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.include(html, '<span class="highlight">Pepito</span>');
assert.include(html, '+<span class="highlight">346578888888</span>');
});
test('Rendered Contact with type and number', function() {
this.sinon.spy(SharedComponents, 'phoneDetails');
contact.tel[0].carrier = null;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
sinon.assert.calledWith(
SharedComponents.phoneDetails,
{
number: contact.tel[0].value,
type: contact.tel[0].type[0],
carrier: ''
},
{ safe: ['number'] }
);
});
test('Rendered Contact with type and highlighted number', function() {
this.sinon.spy(SharedComponents, 'phoneDetails');
contact.tel[0].carrier = null;
renderer.render({
contact: contact,
input: '346578888888',
target: ul
});
sinon.assert.calledWith(
SharedComponents.phoneDetails,
{
number: '+<span class="highlight">346578888888</span>',
type: contact.tel[0].type[0],
carrier: ''
},
{ safe: ['number'] }
);
});
test('Rendered Contact with type, number and carrier', function() {
this.sinon.spy(SharedComponents, 'phoneDetails');
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
sinon.assert.calledWith(
SharedComponents.phoneDetails,
{
number: contact.tel[0].value,
type: contact.tel[0].type[0],
carrier: contact.tel[0].carrier
},
{ safe: ['number'] }
);
});
test('Rendered Contact with type, highlighted number and carrier',
function() {
this.sinon.spy(SharedComponents, 'phoneDetails');
renderer.render({
contact: contact,
input: '346578888888',
target: ul
});
sinon.assert.calledWith(
SharedComponents.phoneDetails,
{
number: '+<span class="highlight">346578888888</span>',
type: contact.tel[0].type[0],
carrier: contact.tel[0].carrier
},
{ safe: ['number'] }
);
});
test('Rendered Contact "email"', function() {
MockSettings.supportEmailRecipient = true;
var html;
contact.email[0].type = null;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
html = ul.lastElementChild.innerHTML;
assert.ok(html.includes('a@b.com'));
});
test('Rendered Contact highlighted "email"', function() {
MockSettings.supportEmailRecipient = true;
var html;
contact.email[0].type = null;
renderer.render({
contact: contact,
input: 'a@b.com',
target: ul
});
sinon.assert.calledWithMatch(Template.prototype.interpolate, {
carrier: '',
name: 'Pepito O\'Hare',
nameHTML: 'Pepito O&apos;Hare',
number: 'a@b.com',
numberHTML: '<span class="highlight">a@b.com</span>',
photoHTML: '',
type: ''
});
html = ul.lastElementChild.innerHTML;
assert.ok(
html.includes('<span class="highlight">a@b.com</span>')
);
});
test('Rendered Contact highlighted "name email"', function() {
MockSettings.supportEmailRecipient = true;
var html;
renderer.render({
contact: contact,
input: 'Pepito a@b.com',
target: ul
});
html = ul.lastElementChild.innerHTML;
assert.include(html, '<span class="highlight">Pepito</span>');
assert.include(html, '<span class="highlight">a@b.com</span>');
});
test('Rendered Contact "type | email"', function() {
var html;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
html = ul.lastElementChild.innerHTML;
assert.isFalse(html.includes(
'<span data-l10n-id="Personal">Personal</span> | ' + 'a@b.com'));
});
test('Rendered Contact highlighted "type | email"', function() {
var html;
renderer.render({
contact: contact,
input: 'a@b.com',
target: ul
});
html = ul.lastElementChild.innerHTML;
assert.isFalse(html.includes(
'<span data-l10n-id="Personal">Personal</span> | ' +
'<span class="highlight">a@b.com</span>'
));
});
test('Rendered Contact w/ multiple: all (isSuggestion) ' +
'and No Support EmailRecipient ',
function() {
MockSettings.supportEmailRecipient = false;
renderer.render({
contact: contact,
input: '+12125559999',
target: ul
});
assert.equal(ul.children.length, 2);
});
test('Rendered Contact w/ multiple: all (isSuggestion) ' +
'and Support EmailRecipient ',
function() {
MockSettings.supportEmailRecipient = true;
renderer.render({
contact: contact,
input: '+12125559999',
target: ul
});
assert.equal(ul.children.length, 3);
});
test('Rendered Contact omit numbers already in recipient list ' +
'and No Support EmailRecipient ',
function() {
MockSettings.supportEmailRecipient = false;
var html;
var skip = ['+346578888888'];
// This contact has two tel entries.
renderer.render({
contact: contact,
input: '+346578888888',
target: ul,
skip: skip
});
html = ul.innerHTML;
assert.ok(!html.includes('346578888888'));
assert.equal(ul.children.length, 1);
});
test('Rendered Contact omit numbers already in recipient list ' +
'and Support EmailRecipient ',
function() {
MockSettings.supportEmailRecipient = true;
var html;
var skip = ['+346578888888'];
// This contact has two tel entries.
renderer.render({
contact: contact,
input: '+346578888888',
target: ul,
skip: skip
});
html = ul.innerHTML;
assert.ok(!html.includes('346578888888'));
assert.equal(ul.children.length, 2);
});
test('Rendered Contact omit emails already in recipient list ' +
'and Support EmailRecipient ',
function() {
MockSettings.supportEmailRecipient = true;
var html;
var skip = ['a@b.com'];
// This contact has three tel entries.
renderer.render({
contact: contact,
input: 'a@b.com',
target: ul,
skip: skip
});
html = ul.innerHTML;
assert.ok(!html.includes('a@b.com'));
assert.equal(ul.children.length, 2);
});
test('does not include photo', function() {
var html;
renderer.render({
contact: contact,
input: 'foo',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.isFalse(html.includes('span[data-type=img]'));
});
test('append information block in the li', function() {
var li;
var block = document.createElement('div');
var selector = '.suggestion';
renderer.render({
contact: contact,
input: 'Additional info',
infoBlock: block,
infoBlockParentSelector: selector,
target: ul
});
li = ul.lastElementChild;
assert.isTrue(!!li.querySelector(selector));
assert.equal(li.querySelector(selector).lastElementChild, block);
});
test('Rendered no "Tel" and No Support EmailRecipient ', function() {
contact.tel = null;
MockSettings.supportEmailRecipient = false;
var result = renderer.render({
contact: contact,
input: 'foo',
target: ul
});
assert.isFalse(result);
});
test('Rendered no "email" and No Support EmailRecipient ', function() {
contact.email = null;
MockSettings.supportEmailRecipient = false;
var result = renderer.render({
contact: contact,
input: 'foo',
target: ul
});
assert.ok(result);
});
test('Rendered no "Tel" and Support EmailRecipient ', function() {
contact.tel = null;
MockSettings.supportEmailRecipient = true;
var result = renderer.render({
contact: contact,
input: 'foo',
target: ul
});
assert.ok(result);
});
test('Rendered no "email" and Support EmailRecipient ', function() {
contact.email = null;
MockSettings.supportEmailRecipient = true;
var result = renderer.render({
contact: contact,
input: 'foo',
target: ul
});
assert.ok(result);
});
test('Rendered no "Tel"/"email" and Support EmailRecipient ', function() {
contact.tel = null;
contact.email = null;
MockSettings.supportEmailRecipient = true;
var result = renderer.render({
contact: contact,
input: 'foo',
target: ul
});
assert.isFalse(result);
});
});
suite('suggestionUnknown', function() {
var html;
var unknownContact = {
name: ['unknown'],
tel: [{value: '+346578888888'}]
};
setup(function() {
unknownRender = ContactRenderer.flavor('suggestionUnknown');
});
test('Rendered unknownContact "number"', function() {
unknownContact.tel[0].carrier = null;
unknownContact.tel[0].type = null;
unknownRender.render({
contact: unknownContact,
input: 'foo',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.ok(html.includes('+346578888888'));
});
test('Rendered unknownContact highlighted "number"', function() {
unknownContact.tel[0].carrier = null;
unknownContact.tel[0].type = null;
unknownRender.render({
contact: unknownContact,
input: '346578888888',
target: ul
});
sinon.assert.calledWithMatch(Template.prototype.interpolate, {
carrier: '',
name: 'unknown',
nameHTML: 'unknown',
number: '+346578888888',
phoneDetailsHTML: SharedComponents.phoneDetails(
{ number: '+<span class="highlight">346578888888</span>' },
{ safe: ['number'] }
).toString(),
photoHTML: '',
type: ''
});
html = ul.firstElementChild.innerHTML;
assert.ok(
html.includes('+<span class="highlight">346578888888</span>')
);
});
test('Rendered unknownContact omit numbers already in recipient list',
function() {
var skip = ['+346578888888'];
// This unknownContact has two tel entries.
unknownRender.render({
contact: unknownContact,
input: '+346578888888',
target: ul,
skip: skip
});
html = ul.innerHTML;
assert.ok(!html.includes('346578888888'));
assert.equal(ul.children.length, 0);
});
test('does not include photo', function() {
unknownRender.render({
contact: unknownContact,
input: 'foo',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.isFalse(html.includes('span[data-type=img]'));
});
});
suite('prompt', function() {
setup(function() {
renderer = ContactRenderer.flavor('prompt');
});
test('Rendered Contact w/ multiple: one', function() {
renderer.render({
contact: contact,
input: '+12125559999',
target: ul
});
assert.equal(ul.children.length, 1);
});
test('Rendered Contact w/ multiple: one w/ minimal match', function() {
renderer.render({
contact: contact,
input: '5559999',
target: ul
});
assert.equal(ul.children.length, 1);
});
test('Render contact without photo does not render the photo', function() {
var html;
renderer.render({
contact: contact,
input: '5559999',
target: ul
});
html = ul.firstElementChild.innerHTML;
assert.isFalse(html.includes('span[data-type=img]'));
});
test('Render contact with photo renders the image', function() {
var html;
var blob = testImageBlob;
this.sinon.stub(MockContactPhotoHelper, 'getThumbnail').returns(blob);
this.sinon.spy(Utils, 'asyncLoadRevokeURL');
this.sinon.spy(Utils, 'getContactDetails');
this.sinon.spy(window, 'encodeURI');
renderer.render({
contact: contact,
input: '5559999',
target: ul
});
sinon.assert.calledWith(Template.prototype.interpolate, undefined);
var photo = 'data-type="img"';
sinon.assert.calledWithMatch(Template.prototype.interpolate, {
carrier: 'XXX',
name: 'Pepito O\'Hare',
nameHTML: 'Pepito O&apos;Hare',
number: '+12125559999',
phoneDetailsHTML: SharedComponents.phoneDetails(
{
number: '+12125559999',
type: 'B',
carrier: 'XXX'
},
{ safe: ['number'] }
).toString(),
photoHTML: sinon.match(photo),
type: 'B'
});
html = ul.firstElementChild.innerHTML;
var contactPhotoElement = ul.firstElementChild.querySelector(
'.contact-photo'
);
assert.ok(html.includes('span'));
assert.ok(contactPhotoElement.style.backgroundImage.indexOf('blob:') > 0);
sinon.assert.calledWith(
encodeURI,
Utils.getContactDetails.returnValues[0].photoURL
);
sinon.assert.calledWith(
Utils.asyncLoadRevokeURL,
Utils.getContactDetails.returnValues[0].photoURL
);
});
});
suite('Secure User Input', function() {
setup(function() {
this.sinon.stub(Utils, 'getContactDetails', function(number, contacts) {
return {
isContact: !!contacts,
title: number
};
});
});
test('+99', function() {
var ul = document.createElement('ul');
assert.doesNotThrow(function() {
var renderer = ContactRenderer.flavor('suggestion');
renderer.render({
contact: {
name: 'Spider Monkey',
tel: [{ value: '...' }]
},
input: '+99',
target: ul
});
});
sinon.assert.calledWith(Utils.getContactDetails, '...');
});
test('*67 [800]-555-1212', function() {
var ul = document.createElement('ul');
assert.doesNotThrow(function() {
var renderer = ContactRenderer.flavor('suggestion');
renderer.render({
contact: {
name: 'Spider Monkey',
tel: [{ value: '...' }]
},
input: '*67 [800]-555-1212',
target: ul
});
});
sinon.assert.calledWith(Utils.getContactDetails, '...');
});
test('\\^$*+?.', function() {
var ul = document.createElement('ul');
assert.doesNotThrow(function() {
var renderer = ContactRenderer.flavor('suggestion');
renderer.render({
contact: {
name: 'Spider Monkey',
tel: [{ value: '...' }]
},
input: '\\^$*+?.',
target: ul
});
});
sinon.assert.calledWith(Utils.getContactDetails, '...');
});
});
suite('Defensive Contact Rendering', function() {
test('has tel number', function() {
var ul = document.createElement('ul');
var contact = new MockContact();
var renderer = ContactRenderer.flavor('suggestion');
var isRendered = renderer.render({
contact: contact,
input: contact.tel[0].value,
target: ul
});
assert.isTrue(isRendered);
});
test('no tel number, has email address', function() {
MockSettings.supportEmailRecipient = true;
var ul = document.createElement('ul');
var contact = new MockContact();
contact.tel = null;
var renderer = ContactRenderer.flavor('suggestion');
var isRendered = renderer.render({
contact: contact,
input: contact.email[0].value,
target: ul
});
assert.isTrue(isRendered);
});
test('has tel number, no email address', function() {
MockSettings.supportEmailRecipient = true;
var ul = document.createElement('ul');
var contact = new MockContact();
contact.email = null;
var renderer = ContactRenderer.flavor('suggestion');
var isRendered = renderer.render({
contact: contact,
input: contact.tel[0].value,
target: ul
});
assert.isTrue(isRendered);
});
test('no tel number, no email address', function() {
MockSettings.supportEmailRecipient = true;
var ul = document.createElement('ul');
var contact = new MockContact();
contact.tel = null;
contact.email = null;
var renderer = ContactRenderer.flavor('suggestion');
var isNotRendered = renderer.render({
contact: contact,
input: null,
target: ul
});
assert.isFalse(isNotRendered);
});
});
suite('report-view', function() {
var renderer;
setup(function() {
renderer = ContactRenderer.flavor('report-view');
});
test('correct rendering', function() {
var selector = '.js-contact-info';
var infoBlock = document.createElement('div');
renderer.render({
contact: contact,
target: ul,
input: contact.tel[0].value,
infoBlock: infoBlock,
infoBlockParentSelector: selector,
});
var contactInfo = ul.querySelector(selector);
assert.isTrue(contactInfo.contains(infoBlock));
assert.notEqual(contactInfo.tagName, 'A');
});
});
suite('group-view', function() {
var renderer;
setup(function() {
renderer = ContactRenderer.flavor('group-view');
});
test('correct rendering', function() {
var selector = '.js-contact-info';
renderer.render({
contact: contact,
target: ul,
input: contact.tel[0].value
});
var contactInfo = ul.querySelector(selector);
assert.equal(contactInfo.tagName, 'A');
});
});
});

View file

@ -0,0 +1,890 @@
/*global MockContact, Contacts, fb, MockFbReaderUtilsObj,
MockSettings, MocksHelper */
'use strict';
require('/shared/test/unit/mocks/mock_moz_phone_number_service.js');
require('/shared/test/unit/mocks/mock_fb_reader_utils.js');
require('/views/shared/js/utils.js');
require('/views/shared/test/unit/mock_contact.js');
require('/views/shared/js/contacts.js');
require('/views/shared/test/unit/mock_settings.js');
require('/views/shared/test/unit/mock_utils.js');
var mocksHelperForContactsUnitTest = new MocksHelper([
'Settings',
'Utils'
]).init();
function withFilter(str) {
return sinon.match( { filterValue: str } );
}
suite('Contacts', function(done) {
mocksHelperForContactsUnitTest.attachTestHelpers();
var realFb = window.fb;
var targetFbNumber = '+34658789147';
var fbContactName = 'Carlos Facebook';
var targetLocalNumber = '+34698745123';
var targetLocalEmail = 'a@b.com';
var localContactName = 'Jose Local';
var notFoundNumber = '+34633789102';
var notFoundEmail = 'a@c.com';
suiteSetup(function() {
var mockReaderUtils = new MockFbReaderUtilsObj();
mockReaderUtils.targetFbNumber = targetFbNumber;
mockReaderUtils.fbContactName = fbContactName;
window.fb = mockReaderUtils;
});
setup(function(){
// Do not use the Native API
this.sinon.stub(navigator.mozContacts, 'find');
});
suiteTeardown(function() {
window.fb = realFb;
});
suite('Contacts.findContactByString, single-term', function() {
test('(string[tel,givenName,familyName], ...) Match', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('O\'Hare')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('O\'Hare').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('(string[tel,givenName,familyName], ...) No Match', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('wontmatch')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('wontmatch').then(function(contacts) {
// contacts were not found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('(string[tel,email,givenName,familyName], ...) Match', function(done) {
MockSettings.supportEmailRecipient = true;
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('a@b.com')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('a@b.com').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('(string[tel,email,givenName,familyName], ...) No Match',
function(done) {
MockSettings.supportEmailRecipient = true;
navigator.mozContacts.find.withArgs(
withFilter('z@y.com')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('z@y.com').then(function(contacts) {
// contacts were not found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('(string[tel], ...) Match', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('+346578888888')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('+346578888888').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('(string[tel], ...) No Match', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('911')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('911').then(function(contacts) {
// contacts were not found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
});
suite('Contacts.findContactByString, multi-term', function() {
test('no predominate', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('Pepito O\'Hare').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('no predominate, reversed', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('O\'Hare')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('O\'Hare Pepito').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('predominate first, upper', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepi')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('Pepi O').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('predominate last, upper', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepi')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('O Pepi').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('predominate first, lower', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('pepi')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('pepi o').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('predominate last, lower', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('pepi')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('o pepi').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('multi-word name', function(done) {
var result = MockContact.list([
{ givenName: ['Mary Anne'], familyName: ['Jones'] }
]);
navigator.mozContacts.find.withArgs(
withFilter('mary')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('mary anne').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('name first, part of tel number last', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('Pepito 8888').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('part of tel number first, name last', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('8888 Pepito').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('name first, part of email address last', function(done) {
MockSettings.supportEmailRecipient = true;
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve(result)
);
Contacts.findByString('Pepito a@b').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('part of email address first, name last', function(done) {
MockSettings.supportEmailRecipient = true;
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve(result)
);
Contacts.findByString('a@b Pepito').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('string search yields a contact without familyName', function(done) {
var result = MockContact.list([
{ givenName: ['julien'] }
]);
navigator.mozContacts.find.withArgs(
withFilter('julien')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('julien 123').then((contacts) => {
// "julien" yields a result that has only a givenName, no familyName.
// This test checks that our algorithm works also in such cases.
// See Bug 952533
assert.isNotNull(contacts);
assert.lengthOf(contacts, 0);
}).then(done, done);
});
test('no matches, predominate first, upper', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('Pepito S').then(function(contacts) {
// No contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('no matches, predominate last, upper', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('Pepito')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('S Pepito').then(function(contacts) {
// No contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('no matches, predominate first, lower', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('pepi')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('pepi s').then(function(contacts) {
// No contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('no matches, predominate last, lower', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('pepi')
).returns(
Promise.resolve([])
);
Contacts.findContactByString('s pepi').then(function(contacts) {
// No contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
});
suite('Contacts.findExact', function() {
test('yields a match ', function(done) {
var result = MockContact.list([
{ givenName: ['Jane'], familyName: ['Doozer'] }
]);
navigator.mozContacts.find.withArgs(
withFilter('doozer')
).returns(
Promise.resolve(result)
);
Contacts.findExact('jane doozer').then(function(contacts) {
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('yields no matches ', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('doozer')
).returns(
Promise.resolve([])
);
Contacts.findExact('j doozer').then(function(contacts) {
assert.equal(contacts.length, 0);
}).then(done, done);
});
});
suite('Contacts.findByPhoneNumber', function() {
test('removes spaces', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('+33123456789')
).returns(
Promise.resolve(result)
);
Contacts.findByPhoneNumber('+33 1 23 45 67 89').then(function(contacts){
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('The mozContacts find() call returned an error', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('callonerror')
).returns(
Promise.resolve([])
);
Contacts.findByPhoneNumber('callonerror').then((results) => {
assert.isArray(results);
assert.lengthOf(results, 0);
}).then(done, done);
});
test('Local number found.', function(done) {
var result = [{
name: [localContactName]
}];
navigator.mozContacts.find.withArgs(
withFilter(targetLocalNumber)
).returns(
Promise.resolve(result)
);
Contacts.findByPhoneNumber(targetLocalNumber).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.isTrue(!contacts[0].isFbContact);
assert.equal(contacts[0].name[0], localContactName);
}).then(done, done);
});
test('Local number not found. FB Number found', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(targetFbNumber)
).returns(
Promise.resolve([])
);
Contacts.findByPhoneNumber(targetFbNumber).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.equal(contacts[0].name[0], fbContactName);
assert.equal(contacts[0].isFbContact, true);
}).then(done, done);
});
test('Local number not found. FB Number not found either', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(notFoundNumber)
).returns(
Promise.resolve([])
);
Contacts.findByPhoneNumber(notFoundNumber).then(function(contacts) {
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('Local number not found. FB returns error', function(done) {
fb.inError = true;
navigator.mozContacts.find.withArgs(
withFilter(targetFbNumber)
).returns(
Promise.resolve([])
);
Contacts.findByPhoneNumber(targetFbNumber).then(function(contacts) {
assert.equal(contacts.length, 0);
delete window.fb.inError;
}).then(done, done);
});
});
suite('Contacts.findByAddress', function() {
test('removes spaces', function(done) {
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('+33123456789')
).returns(
Promise.resolve(result)
);
Contacts.findByAddress('+33 1 23 45 67 89').then(function(contacts){
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('The mozContacts find() call returned an error', function(done) {
navigator.mozContacts.find.withArgs(
withFilter('callonerror')
).returns(
Promise.resolve([])
);
Contacts.findByAddress('callonerror').then((results) => {
assert.isArray(results);
assert.lengthOf(results, 0);
}).then(done, done);
});
test('Local number found.', function(done) {
var result = [{
name: [localContactName]
}];
navigator.mozContacts.find.withArgs(
withFilter(targetLocalNumber)
).returns(
Promise.resolve(result)
);
Contacts.findByAddress(targetLocalNumber).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.isTrue(!contacts[0].isFbContact);
assert.equal(contacts[0].name[0], localContactName);
}).then(done, done);
});
test('Local email found.', function(done) {
var result = [{
name: [localContactName]
}];
navigator.mozContacts.find.withArgs(
withFilter(targetLocalEmail)
).returns(
Promise.resolve(result)
);
Contacts.findByAddress(targetLocalEmail).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.isTrue(!contacts[0].isFbContact);
assert.equal(contacts[0].name[0], localContactName);
}).then(done, done);
});
test('Local number not found. FB Number found', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(targetFbNumber)
).returns(
Promise.resolve([])
);
Contacts.findByAddress(targetFbNumber).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.equal(contacts[0].name[0], fbContactName);
assert.equal(contacts[0].isFbContact, true);
}).then(done, done);
});
test('Local number not found. FB Number not found either', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(notFoundNumber)
).returns(
Promise.resolve([])
);
Contacts.findByAddress(notFoundNumber).then(function(contacts) {
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('Local email not found. FB Number not found either', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(notFoundEmail)
).returns(
Promise.resolve([])
);
Contacts.findByAddress(notFoundEmail).then(function(contacts) {
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('Local number not found. FB returns error', function(done) {
fb.inError = true;
navigator.mozContacts.find.withArgs(
withFilter(targetFbNumber)
).returns(
Promise.resolve([])
);
Contacts.findByAddress(targetFbNumber).then(function(contacts) {
assert.equal(contacts.length, 0);
delete window.fb.inError;
}).then(done, done);
});
});
suite('Contacts.findExactByEmail', function() {
test('Local email found.', function(done) {
var result = [{
name: [localContactName]
}];
navigator.mozContacts.find.withArgs(
withFilter(targetLocalEmail)
).returns(
Promise.resolve(result)
);
Contacts.findExactByEmail(targetLocalEmail).then(function(contacts) {
assert.equal(contacts.length, 1);
assert.isTrue(!contacts[0].isFbContact);
assert.equal(contacts[0].name[0], localContactName);
}).then(done, done);
});
test('Local email not found. FB Email not found either', function(done) {
navigator.mozContacts.find.withArgs(
withFilter(notFoundEmail)
).returns(
Promise.resolve([])
);
Contacts.findExactByEmail(notFoundEmail).then(function(contacts) {
assert.equal(contacts.length, 0);
}).then(done, done);
});
test('Local email not found. FB returns error', function(done) {
fb.inError = true;
var result = [{
name: [localContactName]
}];
navigator.mozContacts.find.withArgs(
withFilter(targetLocalEmail)
).returns(
Promise.resolve(result)
);
Contacts.findExactByEmail(targetLocalEmail).then(function(contacts) {
assert.equal(contacts.length, 1);
delete window.fb.inError;
}).then(done, done);
});
});
suite('Contacts.addUnknown', function() {
test('For adding an unknown contact', function() {
var length = Contacts.getunknownLength();
Contacts.addUnknown('123456');
assert.equal(length + 1, Contacts.getunknownLength());
});
test('For not adding the duplicate unknown contact', function() {
var length = Contacts.getunknownLength();
Contacts.addUnknown('123456');
assert.equal(length, Contacts.getunknownLength());
});
});
suite('Contacts.clearUnknown', function() {
test('For clearing unknown contact', function() {
Contacts.clearUnknown();
var length = Contacts.getunknownLength();
assert.equal(length, 0);
});
});
suite('Contacts.findByUnknown function', function() {
setup(function() {
Contacts.clearUnknown();
Contacts.addUnknown('123123456');
Contacts.addUnknown('789789789');
Contacts.addUnknown('456456456');
Contacts.addUnknown('123456789');
});
test('checking single unknown contact using substring', function(done) {
Contacts.findByUnknown('6456').then(function(list) {
assert.equal(list[0].name == '456456456', true);
assert.equal(list.length, 1);
}).then(done, done);
});
test('checking multiple unknown contact using substring', function(done) {
Contacts.findByUnknown('456').then(function(list) {
assert.equal(list[0].name == '123123456', true);
assert.equal(list[1].name == '456456456', true);
assert.equal(list[2].name == '123456789', true);
assert.equal(list.length, 3);//List can have at max 3 unknown elements
}).then(done, done);
});
test('checking multiple unknown contact using fullstring', function(done) {
Contacts.findByUnknown('456456456').then(function(list) {
assert.equal(list[0].name == '456456456', true);
assert.equal(list.length, 1);
}).then(done, done);
});
test('checking unknown contact after clearUnknown', function(done) {
Contacts.clearUnknown();
Contacts.findByUnknown('456').then(function(list) {
assert.equal(list.length, 0);
}).then(done, done);
});
test('Unknown List cannot have more than 3 prediction', function(done) {
Contacts.clearUnknown();
Contacts.addUnknown('123123456');
Contacts.addUnknown('789123789');
Contacts.addUnknown('456456123');
Contacts.addUnknown('123456789');
Contacts.findByUnknown('123').then(function(list) {
//Prediction should had been 4 but since we restict it to a max of 3
assert.equal(list.length, 3);
}).then(done, done);
});
});
suite('Contacts.findBy (success)', function() {
test('(object, ...), Match', function(done) {
var filter = {
filterBy: ['tel'],
filterOp: 'contains',
filterValue: '12125559999'
};
var result = MockContact.list();
navigator.mozContacts.find.withArgs(
withFilter('12125559999')
).returns(
Promise.resolve(result)
);
Contacts.findBy(filter).then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 1);
}).then(done, done);
});
test('(object, ...), No Match', function(done) {
var filter = {
filterBy: ['tel'],
filterOp: 'contains',
filterValue: '911'
};
navigator.mozContacts.find.withArgs(
withFilter('911')
).returns(
Promise.resolve([])
);
Contacts.findBy(filter).then(function(contacts) {
// contacts were not found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
});
suite('Contacts.findBy (error)', function() {
test('({}, ...)', function(done) {
Contacts.findBy({}).then(function(contacts) {
assert.isTrue(Array.isArray(contacts));
assert.equal(contacts.length, 0);
}).then(done, done);
});
});
suite('Contacts validation', function() {
test('Contact validation, predominate first', function(done) {
var result = MockContact.list([
{ givenName: ['Jane'], familyName: ['D'] },
{ givenName: ['jane'], familyName: ['doe'] },
{ givenName: ['jane'], familyName: ['dow'] },
{ givenName: ['Jane'], familyName: ['Doozer'] }
]);
navigator.mozContacts.find.withArgs(
withFilter('jane')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('jane d').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
// This was relaxed by the change from "startsWith" to "contains"
assert.equal(contacts.length, 4);
}).then(done, done);
});
test('Contact validation, predominate last', function(done) {
var result = MockContact.list([
{ givenName: ['Jane'], familyName: ['Doozer'] },
{ givenName: ['jane'], familyName: ['doe'] },
{ givenName: ['jerry'], familyName: ['doe'] },
{ givenName: ['j'], familyName: ['dow'] },
{ givenName: ['john'], familyName: ['doland'] }
]);
navigator.mozContacts.find.withArgs(
withFilter('do')
).returns(
Promise.resolve(result)
);
Contacts.findContactByString('j do').then(function(contacts) {
// contacts were found
assert.ok(Array.isArray(contacts));
assert.equal(contacts.length, 5);
}).then(done, done);
});
});
});

View file

@ -0,0 +1,288 @@
/*global loadBodyHTML,
Dialog,
MockL10n,
TransitionEvent
*/
'use strict';
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/js/dialog.js');
suite('Dialog', function() {
var nativeMozL10n = document.l10n;
var params = null;
suiteSetup(function() {
loadBodyHTML('/index.html');
document.l10n = MockL10n;
params = {
title: {
raw: 'Foo Title'
},
body: {
raw: 'Foo Body'
},
options: {
cancel: {
text: {
raw: 'Foo Cancel'
}
}
}
};
});
suiteTeardown(function() {
document.l10n = nativeMozL10n;
params = null;
});
test('Creation', function() {
var dialog = new Dialog(params);
// We check if the object is created properly
assert.isFalse(!dialog.show);
assert.equal(typeof dialog.show, 'function');
assert.isFalse(!dialog.hide);
assert.equal(typeof dialog.hide, 'function');
});
test('Appending to DOM', function() {
var previouslyDefinedForms = document.getElementsByTagName('form').length;
// In this case we have several forms pre-defined (5):
// - "messages-compose-form"
// - "messages-edit-form"
// - "loading"
// - "threads-edit-form"
assert.equal(previouslyDefinedForms, 4);
// Now we create the new element
var dialog = new Dialog(params);
// We check if the object is appended to the DOM
dialog.show();
// Is appended properly?
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
assert.equal(currentlyDefinedFormsLength, 5);
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
assert.equal(dialogForm.dataset.type, 'confirm');
});
test('Has GaiaSimPicker', function() {
assert.ok(document.getElementById('sim-picker'));
});
test('Focus', function() {
var dialog = new Dialog(params);
this.sinon.stub(dialog.form, 'focus');
this.sinon.stub(HTMLElement.prototype, 'blur');
dialog.show();
sinon.assert.called(HTMLElement.prototype.blur);
var transitionend = new TransitionEvent('transitionend', {
bubbles: true,
cancelable: true,
propertyName: 'transform'
});
dialog.form.dispatchEvent(transitionend);
sinon.assert.called(dialog.form.focus);
});
test('Redundant shows have no effect', function() {
var dialog = new Dialog(params);
var spy = this.sinon.spy(document.body, 'appendChild');
dialog.show();
dialog.show();
assert.ok(spy.calledOnce);
});
test('Hiding removes element after transition', function() {
var dialog = new Dialog(params);
dialog.show();
assert.notEqual(dialog.form.parentElement, null);
dialog.hide();
assert.notEqual(dialog.form.parentElement, null);
var transitionend = new TransitionEvent('transitionend', {
bubbles: true,
cancelable: true,
propertyName: 'transform'
});
dialog.form.dispatchEvent(transitionend);
assert.equal(dialog.form.parentElement, null);
});
test('Checking the structure. Default.', function() {
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
// We retrieve the last created form
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
// We check how many buttons we have (only the mandatory one)
var formOptions = dialogForm.getElementsByTagName('button');
assert.equal(formOptions.length, 1);
});
test('Checking the structure. Confirm.', function() {
// We add the confirm
params.options.confirm = {
text: {
raw: 'Foo Cancel'
}
};
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
// We retrieve the last created form
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
// We check how many buttons we have (mandatory + confirm one)
var formOptions = dialogForm.getElementsByTagName('button');
assert.equal(formOptions.length, 2);
// We check if there is a 'recommend' style
var optionalOptions = dialogForm.getElementsByClassName('recommend');
assert.equal(optionalOptions.length, 1);
});
test('Checking the structure. Confirm (with custom class name).', function() {
// We add the confirm
params.options.confirm = {
text: {
raw: 'Foo Cancel'
},
className: 'test-class'
};
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
// We retrieve the last created form
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
// We check whether default 'recommend' class wasn't applied
var optionalOptions = dialogForm.getElementsByClassName('recommend');
assert.equal(optionalOptions.length, 0);
// We check whether custom class name was applied
optionalOptions = dialogForm.getElementsByClassName('test-class');
assert.equal(optionalOptions.length, 1);
});
test('Checking the localization.', function() {
params.title = 'l10n Title',
params.body = { raw: 'non-l10n Body' },
params.options.cancel = {
text: { id: 'l10n keyCancel' }
};
params.options.confirm = {
text: { id: 'l10n keyConfirm', args: { n: 1 } }
};
var l10nSpy = this.sinon.spy(document.l10n, 'setAttributes');
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
// We retrieve the last created form
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
// We check how many buttons we have (mandatory + confirm one)
var titleDOM = dialogForm.querySelector('h1');
var bodyDOM = dialogForm.querySelector('p');
var formOptions = dialogForm.getElementsByTagName('button');
assert.equal(formOptions.length, 2);
// We check localization
assert.equal(titleDOM.getAttribute('data-l10n-id'), params.title);
assert.equal(bodyDOM.textContent, params.body.raw);
assert.isFalse(bodyDOM.hasAttribute('data-l10n-id'));
sinon.assert.calledWith(
l10nSpy, formOptions[0], params.options.cancel.text.id
);
sinon.assert.calledWith(
l10nSpy, formOptions[1],
params.options.confirm.text.id, params.options.confirm.text.args
);
});
test('Checking parametrized body localization.', function() {
params.title = 'l10n Title';
params.body = {
id: 'l10n Body',
args: {
n: 3,
numbers: ['123', '456', '789']
}
};
params.options.cancel = {
text: 'l10n keyCancel'
};
params.options.confirm = {
text: 'l10n keyConfirm'
};
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
// We retrieve the last created form
var currentlyDefinedForms = document.getElementsByTagName('form');
var currentlyDefinedFormsLength = currentlyDefinedForms.length;
// We check the type
var dialogForm = currentlyDefinedForms[currentlyDefinedFormsLength - 1];
// We check how many buttons we have (mandatory + confirm one)
var bodyDOM = dialogForm.querySelector('p');
// We check localization
assert.equal(
bodyDOM.getAttribute('data-l10n-id'), params.body.id,
'Body DOM localized with proper string'
);
assert.equal(
bodyDOM.getAttribute('data-l10n-args'), JSON.stringify(params.body.args),
'Body DOM localized with proper string'
);
});
test('Should prevent pointer events before transitionend', function() {
// Now we create the new element
var dialog = new Dialog(params);
// We append the element to the DOM
dialog.show();
assert.isTrue(document.body.classList.contains('dialog-animating'));
var transitionend = new TransitionEvent('transitionend', {
bubbles: true,
propertyName: 'transform'
});
dialog.form.dispatchEvent(transitionend);
assert.isFalse(document.body.classList.contains('dialog-animating'));
});
test('adding a class name', function() {
params.classes = ['specific-class1', 'specific-class2'];
var dialog = new Dialog(params);
dialog.show();
var elt = document.querySelector('.specific-class1');
assert.ok(elt);
assert.ok(elt.classList.contains('specific-class2'));
});
});

View file

@ -0,0 +1,306 @@
/*global Dialog,
ErrorDialog,
MockL10n,
MocksHelper,
Settings
*/
'use strict';
require('/shared/test/unit/mocks/mock_l20n.js');
require('/views/shared/test/unit/mock_dialog.js');
require('/views/shared/test/unit/mock_settings.js');
require('/views/shared/js/dialog.js');
require('/views/shared/js/error_dialog.js');
var mocksHelperForDialog = new MocksHelper([
'Dialog',
'Settings'
]).init();
suite('ErrorDialog', function() {
// Silencing jshint complaints related to "new ErrorDialog(..)" invocations
/*jshint nonew: false */
var realL10n;
mocksHelperForDialog.attachTestHelpers();
setup(function() {
realL10n = document.l10n;
document.l10n = MockL10n;
this.sinon.spy(Dialog, 'call');
});
teardown(function() {
document.l10n = realL10n;
});
suite('basic tests', function() {
test('uses correct Dialog title, body and cancel button l10nIds',
function() {
var errorDescription = {
prefix: 'TEST'
};
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: errorDescription.prefix + 'Title',
body: errorDescription.prefix + 'Body',
options: {
cancel: {
text: errorDescription.prefix + 'BtnOk'
}
},
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('throws if prefix is not passed', function() {
assert.throws(function() {
(new ErrorDialog({}));
});
});
});
suite('uses correct Dialog options', function() {
test('confirm handler is not set, errorDescription.executeHandler is true',
function() {
var errorDescription = {
prefix: 'TEST',
executeHandler: true
};
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: sinon.match.any,
options: {
cancel: {
text: errorDescription.prefix + 'BtnOk'
}
},
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('confirm handler is set, errorDescription.executeHandler is false',
function() {
var errorDescription = {
prefix: 'TEST',
executeHandler: false
};
var dialogOptions = { confirmHandler: () => {} };
(new ErrorDialog(errorDescription, dialogOptions));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: sinon.match.any,
options: {
cancel: {
text: errorDescription.prefix + 'BtnOk'
}
},
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('confirm handler is set, errorDescription.executeHandler is true',
function() {
var errorDescription = {
prefix: 'TEST',
executeHandler: true
};
var dialogOptions = { confirmHandler: () => {} };
(new ErrorDialog(errorDescription, dialogOptions));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: sinon.match.any,
options: {
cancel: {
text: errorDescription.prefix + 'BtnOk'
},
confirm: {
text: errorDescription.prefix + 'Confirm',
method: dialogOptions.confirmHandler
}
},
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
});
suite('respects errorDescription.showRecipient', function() {
test('errorDescription.showRecipient is true, recipients array is set',
function() {
var errorDescription = {
prefix: 'TEST',
showRecipient: true
};
var dialogOptions = {
recipients: ['111', '222', '333']
};
(new ErrorDialog(errorDescription, dialogOptions));
var fragmentContainsBody = sinon.match((fragment) => {
var body = fragment.querySelector('span');
var expectedL10nId = errorDescription.prefix + 'Body';
var hasL10nId = (body.dataset.l10nId === expectedL10nId);
var expectedArgs = JSON.stringify(
{ n: dialogOptions.recipients.length }
);
var hasL10nArgs = (body.dataset.l10nArgs === expectedArgs);
return hasL10nId && hasL10nArgs;
}, 'fragment contains correct body');
var fragmentContainsRecipients = sinon.match((fragment) => {
var recipients = fragment.querySelector('ul');
return dialogOptions.recipients.every((recipient, i) => {
var li = recipients.children[i];
return li.textContent === recipient;
});
}, 'fragment contains all recipients');
var fragmentContainsBodyAndRecipients =
sinon.match.instanceOf(DocumentFragment)
.and(fragmentContainsBody)
.and(fragmentContainsRecipients);
var expectedClasses = [
'error-dialog-TEST', 'error-dialog', 'error-dialog-show-recipient'
];
sinon.assert.calledWithMatch(Dialog.call, sinon.match.any, {
body: {
raw: fragmentContainsBodyAndRecipients
},
classes: expectedClasses
});
});
test('errorDescription.showRecipient is true, recipients array is not set',
function() {
var errorDescription = {
prefix: 'TEST',
showRecipient: true
};
assert.throws(function() {
(new ErrorDialog(errorDescription));
});
});
});
suite('respects errorDescription.showDsdsStatus', function() {
test('errorDescription.showDsdsStatus is true, SIM 1 is active',
function() {
var errorDescription = {
prefix: 'TEST',
showDsdsStatus: true
};
Settings.mmsServiceId = 0;
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: {
id: errorDescription.prefix + 'Body',
args: {
activeSimId: '1',
nonActiveSimId: '2'
}
},
options: sinon.match.any,
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('errorDescription.showDsdsStatus is true, SIM 2 is active',
function() {
var errorDescription = {
prefix: 'TEST',
showDsdsStatus: true
};
Settings.mmsServiceId = 1;
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: {
id: errorDescription.prefix + 'Body',
args: {
activeSimId: '2',
nonActiveSimId: '1'
}
},
options: sinon.match.any,
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('errorDescription.showDsdsStatus is false, any SIM is active',
function() {
var errorDescription = {
prefix: 'TEST',
showDsdsStatus: false
};
Settings.mmsServiceId = 0;
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: errorDescription.prefix + 'Body',
options: sinon.match.any,
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
test('errorDescription.showDsdsStatus is true, mmsServiceId is not valid',
function() {
var errorDescription = {
prefix: 'TEST',
showDsdsStatus: true
};
// No mmsServiceId available
Settings.mmsServiceId = null;
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: {
id: errorDescription.prefix + 'Body',
args: null
},
options: sinon.match.any,
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
Dialog.call.reset();
// Unknown mmsServiceId
Settings.mmsServiceId = 2;
(new ErrorDialog(errorDescription));
sinon.assert.calledWith(Dialog.call, sinon.match.any, {
title: sinon.match.any,
body: {
id: errorDescription.prefix + 'Body',
args: null
},
options: sinon.match.any,
classes: ['error-dialog-' + errorDescription.prefix, 'error-dialog']
});
});
});
});

View file

@ -0,0 +1,34 @@
/*global Errors */
'use strict';
require('/views/shared/js/errors.js');
suite('Errors', function() {
test('Gets default value for unknown error code', function() {
var defaultError = Errors.get('UnknownError');
assert.isNotNull(defaultError);
assert.isString(defaultError.prefix);
assert.deepEqual(defaultError, Errors.get('I do not know this error!'));
});
test('Gets correct error description for known error code', function() {
var defaultError = Errors.get('UnknownError');
var knownErrorCodes = [
'NoSignalError', 'NotFoundError', 'FdnCheckError', 'InvalidAddressError',
'NoSimCardError', 'RadioDisabledError', 'NonActiveSimCardError',
'SimNotMatchedError', 'NonActiveSimCardToSendError',
'RadioDisabledToDownloadError'
];
knownErrorCodes.forEach((code) => {
var error = Errors.get(code);
assert.isNotNull(error);
assert.isString(error.prefix);
assert.notEqual(error, defaultError);
});
});
});

View file

@ -0,0 +1,100 @@
/*exported FixturePhones */
'use strict';
/**
* Derived from
* /dom/phonenumberutils/tests/test_phonenumber.xul
*
* isTestable: true|false
*
* Fixtures marked isTestable: false are effectively
* placeholders that stand-in to show the correct
* behaviour in cases where
* navigator.mozPhoneNumberService.normalize is
* available (sufficient permissions required)
*
*
* title: string
*
* Description used for the suite title.
*
* values: array
*
* Array of values that are tested by comparing
* each value to every other value.
*
*/
var FixturePhones = [
{
// This is only tested where there are sufficient permissions
// to use navigator.mozPhoneNumberService.normalize
isTestable: !!(navigator.mozPhoneNumberService &&
navigator.mozPhoneNumberService.normalize),
title: 'Non-Digit in Contact',
values: [
'1-800-BUY-A-CAR', '1 800 BUY A CAR', '1 (800) BUY A CAR',
'+18002892227', '18002892227', '8002892227'
]
},
{
isTestable: true,
title: 'US',
values: [
'9995551234', '+19995551234', '(999) 555-1234',
'1 (999) 555-1234', '+1 (999) 555-1234', '+1 999-555-1234'
]
},
{
isTestable: true,
title: 'DE',
values: [
'01149451491934', '49451491934', '451491934',
'0451 491934', '+49 451 491934', '+49451491934'
]
},
{
isTestable: true,
title: 'IT',
values: [
'0577-555-555', '0577555555', '05 7755 5555', '+39 05 7755 5555'
]
},
{
isTestable: true,
title: 'ES',
values: [
'612123123', '612 12 31 23', '+34 612 12 31 23'
]
},
{
isTestable: true,
title: 'BR',
values: [
'01187654321', '0411187654321', '551187654321',
'90411187654321', '+551187654321'
]
},
{
isTestable: true,
title: 'CL',
values: [
'0997654321', '997654321', '(99) 765 4321', '+56 99 765 4321'
]
},
{
isTestable: true,
title: 'CO',
values: [
'5712234567', '12234567', '(1) 2234567', '+57 1 2234567'
]
},
{
isTestable: true,
title: 'FR',
values: [
'0123456789', '+33123456789', '0033123456789',
'01.23.45.67.89', '01 23 45 67 89', '01-23-45-67-89',
'+33 1 23 45 67 89', '+33 (1) 23456789'
]
}
];

View file

@ -0,0 +1,121 @@
/*global InterInstanceEventDispatcher,
MockBroadcastChannel,
BroadcastChannel
*/
'use strict';
require('/shared/js/event_dispatcher.js');
require('/views/shared/js/inter_instance_event_dispatcher.js');
require('/views/shared/test/unit/mock_broadcast_channel.js');
suite('InterInstanceEventDispatcher >', function() {
var realBroadcastChannel;
suiteSetup(function() {
realBroadcastChannel = Object.getOwnPropertyDescriptor(
window, 'BroadcastChannel'
);
window.BroadcastChannel = MockBroadcastChannel;
});
suiteTeardown(function() {
Object.defineProperty(window, 'BroadcastChannel', realBroadcastChannel);
});
setup(function() {
this.sinon.spy(window, 'BroadcastChannel');
this.sinon.stub(BroadcastChannel.prototype, 'addEventListener');
this.sinon.stub(BroadcastChannel.prototype, 'removeEventListener');
this.sinon.stub(BroadcastChannel.prototype, 'close');
this.sinon.stub(BroadcastChannel.prototype, 'postMessage');
this.sinon.spy(window, 'addEventListener');
InterInstanceEventDispatcher.connect();
});
teardown(function() {
InterInstanceEventDispatcher.disconnect();
});
test('correctly connects to BroadcastChannel', function() {
sinon.assert.calledWith(BroadcastChannel, 'messages-channel');
sinon.assert.calledWith(
BroadcastChannel.prototype.addEventListener,
'message'
);
InterInstanceEventDispatcher.connect();
InterInstanceEventDispatcher.connect();
sinon.assert.calledOnce(BroadcastChannel);
InterInstanceEventDispatcher.disconnect();
InterInstanceEventDispatcher.connect();
sinon.assert.calledTwice(BroadcastChannel);
});
test('correctly disconnects from BroadcastChannel', function() {
var addEventListenerArgs =
BroadcastChannel.prototype.addEventListener.lastCall.args;
window.addEventListener.withArgs('unload').yield();
sinon.assert.called(BroadcastChannel.prototype.close);
sinon.assert.calledWith(
BroadcastChannel.prototype.removeEventListener,
addEventListenerArgs[0],
// Reference to "onmessage" listener function
addEventListenerArgs[1]
);
});
test('correctly handles incoming BroadcastChannel messages', function() {
BroadcastChannel.prototype.postMessage.reset();
var onDraftsChangedStub = sinon.stub();
InterInstanceEventDispatcher.on('draft-saved', onDraftsChangedStub);
BroadcastChannel.prototype.addEventListener.withArgs('message').yield({
data: {
name: 'draft-saved',
parameters: { key: 'value' }
}
});
sinon.assert.calledWith(onDraftsChangedStub, { key: 'value' });
BroadcastChannel.prototype.addEventListener.withArgs('message').yield({
data: {
name: 'draft-saved',
parameters: { key: 'value#2' }
}
});
sinon.assert.calledTwice(onDraftsChangedStub);
sinon.assert.calledWith(onDraftsChangedStub, { key: 'value#2' });
sinon.assert.notCalled(BroadcastChannel.prototype.postMessage);
});
test('correctly handles outgoing BroadcastChannel messages', function() {
var onDraftsChangedStub = sinon.stub();
InterInstanceEventDispatcher.on('draft-saved', onDraftsChangedStub);
assert.throws(() => {
InterInstanceEventDispatcher.emit('something-changed');
}, 'Event "something-changed" is not allowed!');
InterInstanceEventDispatcher.disconnect();
assert.throws(() => {
InterInstanceEventDispatcher.emit('draft-saved', { key: 'value' });
}, 'Channel is not created!');
InterInstanceEventDispatcher.connect();
InterInstanceEventDispatcher.emit('draft-saved', { key: 'value' });
sinon.assert.calledWith(BroadcastChannel.prototype.postMessage, {
name: 'draft-saved',
parameters: { key: 'value' }
});
sinon.assert.notCalled(onDraftsChangedStub, 'Does not broadcast to itself');
});
});

View file

@ -0,0 +1,217 @@
/*global LocalizationHelper,
MockL10n
*/
'use strict';
require('/views/shared/js/localization_helper.js');
require('/shared/test/unit/mocks/mock_l20n.js');
suite('LocalizationHelper >', function() {
var navigatorMozL10n;
function onDOMRetranslated() {
document.addEventListener.withArgs('DOMRetranslated').yield();
return false;
}
function onTimeFormatChange() {
window.addEventListener.withArgs('timeformatchange').yield();
return true;
}
setup(function(done) {
navigatorMozL10n = document.l10n;
document.l10n = MockL10n;
this.sinon.stub(document.l10n, 'translateFragment');
this.sinon.stub(window, 'addEventListener');
this.sinon.stub(document, 'addEventListener');
const getAttr = this.sinon.stub(document.documentElement, 'getAttribute');
getAttr.withArgs('lang').returns('be-BY');
getAttr.withArgs('dir').returns('rtl');
[
'attachment-container',
'attachment-container',
'custom'
].forEach((iframeClassName) => {
var iframe = document.createElement('iframe');
iframe.className = iframeClassName;
document.body.appendChild(iframe);
});
var customIframeDocument = document.querySelector(
'iframe.custom'
).contentDocument;
customIframeDocument.documentElement.lang = 'en-US';
customIframeDocument.documentElement.dir = 'ltr';
LocalizationHelper.init().then(
() => undefined).then(done, done);
});
teardown(function() {
document.l10n = navigatorMozL10n;
});
test('localizes only attachment iframes', function() {
sinon.assert.calledTwice(document.l10n.translateFragment);
var attachmentContainers = document.querySelectorAll(
'iframe.attachment-container'
);
Array.forEach(attachmentContainers, (iframe) => {
var doc = iframe.contentDocument;
assert.equal(doc.documentElement.lang, 'be-BY');
assert.equal(doc.documentElement.dir, 'rtl');
sinon.assert.calledWith(document.l10n.translateFragment, doc.body);
});
// Custom iframe should stay untouched.
var customIframeDocument = document.querySelector(
'iframe.custom'
).contentDocument;
assert.equal(customIframeDocument.documentElement.lang, 'en-US');
assert.equal(customIframeDocument.documentElement.dir, 'ltr');
});
[onDOMRetranslated, onTimeFormatChange].forEach(function(forceUpdateMethod) {
suite('localizes date & time ' + forceUpdateMethod.name +' >', function() {
var node;
setup(function() {
node = document.createElement('div');
node.classList.add('l10n-contains-date');
document.body.appendChild(node);
});
teardown(function() {
node.remove();
});
test('ignores if date or format are unavailable', function() {
node.dataset.l10nDate = '';
node.dataset.l10nDateFormat = 'format';
node.textContent = 'not-changed-content';
forceUpdateMethod();
assert.equal(node.textContent, 'not-changed-content');
assert.isFalse(node.hasAttribute('data-l10n-id'));
assert.isFalse(node.hasAttribute('data-l10n-args'));
node.dataset.l10nDate = Date.now();
node.dataset.l10nDateFormat = '';
node.textContent = 'not-changed-content';
forceUpdateMethod();
assert.equal(node.textContent, 'not-changed-content');
assert.isFalse(node.hasAttribute('data-l10n-id'));
assert.isFalse(node.hasAttribute('data-l10n-args'));
});
test('correctly formats node content', function() {
var timestamp = Date.now();
var options = {
month: 'long',
day: '2-digit',
year: 'numeric',
};
function assertChange(options, doNotFormat) {
options.hour12 = navigator.mozHour12;
var formatter =
new Intl.DateTimeFormat(navigator.languages, options);
// In case of time format change date-only elements aren't updated
if (doNotFormat) {
assert.equal(
node.textContent, '', 'Date-only elements should not be updated'
);
} else {
assert.equal(
node.textContent,
formatter.format(new Date(timestamp))
);
}
assert.isFalse(node.hasAttribute('data-l10n-id'));
assert.isFalse(node.hasAttribute('data-l10n-args'));
}
node.dataset.l10nDate = timestamp;
node.dataset.l10nDateFormat = JSON.stringify(options);
// In case of time format change date-only elements shouldn't be updated
assertChange(options, forceUpdateMethod());
options.hour = 'numeric';
options.minute = 'numeric';
node.dataset.l10nDateFormat = JSON.stringify(options);
navigator.mozHour12 = true;
forceUpdateMethod();
assertChange(options);
navigator.mozHour12 = false;
forceUpdateMethod();
assertChange(options);
});
test('correctly formats node l10n attributes', function() {
var timestamp = Date.now();
var options = {
month: 'long',
day: '2-digit',
year: 'numeric',
};
function assertChange(options, doNotFormat) {
options.hour12 = navigator.mozHour12;
var formatter =
new Intl.DateTimeFormat(navigator.languages, options);
var l10nAttributes = document.l10n.getAttributes(node);
// In case of time format change date-only elements aren't updated
if (doNotFormat) {
assert.deepEqual(l10nAttributes.args, {
data: 'custom'
}, 'Date-only elements should not be updated');
} else {
assert.deepEqual(l10nAttributes.args, {
data: 'custom',
date: formatter.format(new Date(timestamp))
});
}
assert.equal(l10nAttributes.id, 'custom');
assert.equal(node.textContent, 'not-changed-content');
}
document.l10n.setAttributes(node, 'custom', {
data: 'custom'
});
node.dataset.l10nDate = timestamp;
node.dataset.l10nDateFormat = JSON.stringify(options);
node.textContent = 'not-changed-content';
// In case of time format change date-only elements shouldn't be updated
assertChange(options, forceUpdateMethod());
options.hour = 'numeric';
options.minute = 'numeric';
node.dataset.l10nDateFormat = JSON.stringify(options);
navigator.mozHour12 = true;
forceUpdateMethod();
assertChange(options);
navigator.mozHour12 = false;
forceUpdateMethod();
assertChange(options);
});
});
});
});

Binary file not shown.

View file

@ -0,0 +1,6 @@
BEGIN:VCARD
VERSION:3.0
FN:dennis
N:;dennis;;;
TEL;TYPE=CELL:0975557726
END:VCARD

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,88 @@
/*global getMockupedDate */
/*exported MockThreadMessages */
'use strict';
function MockThreadMessages() {
var messagesMockup = [
{
sender: null,
receiver: '197746797',
body: 'Error message:)',
delivery: 'sending',
deliveryInfo: [{
receiver: '197746797',
deliveryStatus: 'pending',
deliveryTimestamp: null,
readStatus: 'not-applicable',
readTimestamp: null
}],
error: true,
id: 47,
threadId: 1,
timestamp: +getMockupedDate(0)
},
{
sender: null,
receiver: '197746797',
body: 'Nothing :)',
delivery: 'sent',
deliveryInfo: [{
receiver: '197746797',
deliveryStatus: 'success',
deliveryTimestamp: +getMockupedDate(0),
readStatus: 'not-applicable',
readTimestamp: null
}],
id: 46,
threadId: 1,
timestamp: +getMockupedDate(0)
},
{
sender: '197746797',
body: 'Recibido!',
delivery: 'received',
id: 40,
threadId: 1,
timestamp: +getMockupedDate(2)
},
{
sender: null,
receiver: '197746797',
body: 'Nothing :)',
delivery: 'error',
deliveryInfo: [{
receiver: '197746797',
deliveryStatus: 'errpr',
deliveryTimestamp: null,
readStatus: 'not-applicable',
readTimestamp: null
}],
id: 460,
threadId: 1,
timestamp: +getMockupedDate(6)
},
{
sender: null,
receiver: '197746797',
body: 'Nothing at all :)',
delivery: 'error',
deliveryInfo: [{
receiver: '197746797',
deliveryStatus: 'error',
deliveryTimestamp: null,
readStatus: 'not-applicable',
readTimestamp: null
}],
id: 461,
threadId: 1,
timestamp: +getMockupedDate(6)
}];
messagesMockup.sort(function(a, b) {
return b.timestamp - a.timestamp;
});
return messagesMockup;
}

View file

@ -0,0 +1,6 @@
/*exported MockActivityHandler */
'use strict';
var MockActivityHandler = {
init() {}
};

View file

@ -0,0 +1,25 @@
'use strict';
var MockActivityPicker = {};
[
'email', 'dial', 'url',
'createNewContact', 'addToExistingContact'
].forEach(function(fn) {
MockActivityPicker[fn] = function() {
MockActivityPicker[fn].called = true;
MockActivityPicker[fn].calledWith = [].slice.call(arguments);
};
MockActivityPicker[fn].mSetup = function() {
MockActivityPicker[fn].called = false;
MockActivityPicker[fn].calledWith = null;
};
MockActivityPicker[fn].mTeardown = function() {
delete MockActivityPicker[fn].called;
delete MockActivityPicker[fn].calledWith;
};
});

View file

@ -0,0 +1,11 @@
/* exported MockApp */
(function(exports) {
'use strict';
exports.MockApp = {
instanceId: 'app-instance-id',
whenReady: () => Promise.resolve(),
setReady: () => {}
};
})(window);

View file

@ -0,0 +1,36 @@
/*exported MockasyncStorage */
'use strict';
var MockasyncStorage = {
keys: {},
getItem: function(key, cb) {
if (!cb) {
return;
}
cb(this.keys[key]);
},
setItem: function(key, value, cb) {
this.keys[key] = value;
if (typeof cb === 'function') {
cb();
}
},
removeItem: function(key) {
delete this.keys[key];
},
clear: function() {
this.keys = {};
},
mSetup: function() {
this.clear();
},
mTeardown: function() {
this.clear();
}
};

View file

@ -0,0 +1,27 @@
/*global Utils */
/*exported MockAttachment */
'use strict';
function MockAttachment(blob, options) {
this.blob = blob;
options = options || {};
this.name = options.name;
this.isDraft = !!options.isDraft;
this.mNextRender = document.createElement('iframe');
this.mNextRender.className = 'attachment-container';
}
MockAttachment.prototype = {
get size() {
return this.blob && this.blob.size;
},
get type() {
return Utils.typeFromMimeType(this.blob.type);
},
render: function() {
return this.mNextRender;
},
view: function() {},
updateFileSize: function() {}
};

View file

@ -0,0 +1,15 @@
'use strict';
function MockAttachmentRenderer() {}
MockAttachmentRenderer.prototype = {
render: function() {
return Promise.resolve();
},
getAttachmentContainer: function() {},
updateFileSize: function() {}
};
MockAttachmentRenderer.for = function() {
return new MockAttachmentRenderer();
};

View file

@ -0,0 +1,12 @@
/*exported MockBroadcastChannel */
'use strict';
function MockBroadcastChannel(name) {
this.name = name;
}
MockBroadcastChannel.prototype.addEventListener = () => {};
MockBroadcastChannel.prototype.removeEventListener = () => {};
MockBroadcastChannel.prototype.postMessage = () => {};
MockBroadcastChannel.prototype.close = () => {};

View file

@ -0,0 +1,35 @@
/*exported MockCompose */
'use strict';
var MockCompose = {
isSubjectVisible: false,
init: function() {},
on: function(type, handler) {},
off: function(type, handler) {},
offAll: function() {},
getContent: function() {},
getText: function() {},
isEmpty: () => true,
isSubjectMaxLength: () => false,
setupLock() {},
refresh() {},
scrollToTarget: function(target) {},
scrollMessageContent: function() {},
prepend: function(item) {},
append: () => {},
clear: () => {},
focus: function() {},
getSubject: function() {},
setSubject: function() {},
showSubject: function() {},
hideSubject: function() {},
fromDraft: function() {},
mSetup: function() {
this.size = null;
this.type = 'sms';
}
};

View file

@ -0,0 +1,70 @@
'use strict';
function MockContact(name) {
name = typeof name !== 'undefined' ? name : {
familyName: ['O\'Hare'],
givenName: ['Pepito']
};
if (!(this instanceof MockContact)) {
return new MockContact(name);
}
this.id = '1';
this.familyName = name.familyName;
this.givenName = name.givenName;
this.name = [
[this.givenName, this.familyName].join(' ')
];
this.tel = [
{
'value': '+346578888888',
'type': ['Mobile'],
'carrier': 'TEF'
},
{
'value': '+12125559999',
'type': 'Batphone', // Make sure we cover both strings and arrays
'carrier': 'XXX'
}
];
this.email = [
{
'value': 'a@b.com'
}
];
this.category = [
'favorite'
];
this.org = [
''
];
}
/*
MockContact.list([names])
Create contacts lists
MockContact.list();
MockContact.list([
{ givenName: ['Jane'], familyName: ['Doozer'] },
{ givenName: ['doug'], familyName: ['dooley'] }
]);
*/
MockContact.list = function(names) {
if (names && names.length) {
return names.map(function(name) {
return new MockContact(name);
});
} else {
return [new MockContact()];
}
};

View file

@ -0,0 +1,14 @@
/*exported MockContactRenderer */
'use strict';
function MockContactRenderer(opts) {
}
MockContactRenderer.prototype = {
render: function() {}
};
MockContactRenderer.flavor = function() {
return new MockContactRenderer();
};

View file

@ -0,0 +1,23 @@
/*global MockContact */
/*exported MockContacts */
'use strict';
require('/views/shared/test/unit/mock_contact.js');
(function(exports) {
function returnMock() {
var result = MockContact.list();
return Promise.resolve(result);
}
exports.MockContacts = {
findBy: returnMock,
findByPhoneNumber: returnMock,
findByAddress: returnMock,
findExactByEmail: returnMock,
findByString: returnMock,
findExact: returnMock,
addUnknown: () => {},
};
})(window);

View file

@ -0,0 +1,102 @@
/*global MockRecipients, Template */
/*exported MockConversationView */
'use strict';
require('/test/unit/mock_recipients.js');
var MockConversationView = {
CHUNK_SIZE: 10,
CONVERTED_MESSAGE_DURATION: 3000,
IMAGE_RESIZE_DURATION: 3000,
recipients: {
add: function() {},
focus: function() {}
},
recipientsList: document.createElement('div'),
// For Information view testing. Need to be updated with Conversation View
// layout.
optionsButton: document.createElement('button'),
callNumberButton: document.createElement('button'),
attachButton: document.createElement('button'),
subheader: document.createElement('div'),
container: document.createElement('article'),
composeForm: document.createElement('form'),
headerText: document.createElement('h1'),
inEditMode: false,
init: function() {},
initRecipients: function() {
this.recipients = new MockRecipients({
outer: 'messages-to-field',
inner: 'messages-recipients-list',
template: new Template('messages-recipient-tmpl')
});
},
on: function() {},
once: () => {},
initSentAudio: function() {},
getIdIterator: function() {},
onMessageContentChange: function() {},
assimilateRecipients: function() {},
resizeHandler: function() {},
requestContact: function() {},
updateComposerHeader: function() {},
isScrolledManually: false,
manageScroll: function() {},
scrollViewToBottom: function() {},
back: function() {},
isKeyboardDisplayed: function() {},
getMessageContainer: function() {},
updateHeaderData: function() {},
initializeRendering: function() {},
stopRendering: function() {},
showFirstChunk: function() {},
createMmsContent: function() {},
renderMessages: function() {},
_createNotDownloadedHTML: function() {},
buildMessageDOM: function() {},
appendMessage: function() {},
showChunkOfMessages: function() {},
setHeaderAction: function() {},
setHeaderContent: function() {},
cleanForm: function() {},
clear: function() {},
toggleCheckedAll: function() {},
startEdit: function() {},
delete: function() {},
cancelEdit: function() {},
updateSelectionStatus: function() {},
handleMessageClick: function() {},
handleEvent: function() {},
cleanFields: function() {},
onSendClick: function() {},
onMessageSending: function() {},
onMessageSent: function() {},
onMessageFailed: function() {},
onDeliverySuccess: function() {},
onReadSuccess: function() {},
removeMessageDOM: function() {},
retrieveMMS: function() {},
resendMessage: function() {},
toFieldKeypress: function() {},
toFieldInput: function() {},
searchContact: function() {},
onHeaderActivation: function() {},
promptContact: () => Promise.resolve(),
prompt: () => Promise.resolve(),
saveDraft: function() {},
discardDraft: function() {},
isShowMessageErrorCalledTimes: 0,
showMessageError: function() {
this.isShowMessageErrorCalledTimes += 1;
},
mSetup: function() {
this.isShowMessageErrorCalledTimes = 0;
},
mTeardown: function() {
this.isShowMessageErrorCalledTimes = 0;
}
};

View file

@ -0,0 +1,57 @@
'use strict';
function MockDialog(params) {
MockDialog.calls.push(params);
MockDialog.instances.push(this);
Object.keys(params.options).forEach(function(option) {
MockDialog.triggers[option] = function() {
MockDialog.triggers[option].called = true;
params.options[option].method();
};
// Setup the initial |false| value, this prevents assert.isFalse from
// failing on |undefined| trigger properties
MockDialog.triggers[option].called = false;
});
}
MockDialog.triggers = {};
MockDialog.prototype = {
show: function() {
this.show.called = true;
}
};
MockDialog.mSetup = function() {
MockDialog.calls = [];
MockDialog.instances = [];
};
MockDialog.mTeardown = function() {
delete MockDialog.calls;
delete MockDialog.instances;
Object.keys(MockDialog.triggers).forEach(function(trigger) {
MockDialog.triggers[trigger].called = false;
});
};
function MockErrorDialog() {
MockErrorDialog.calls.push(arguments);
}
MockErrorDialog.prototype = {
show: function() {
this.show.called = true;
}
};
MockErrorDialog.mSetup = function() {
MockErrorDialog.calls = [];
};
MockErrorDialog.mTeardown = function() {
delete MockErrorDialog.calls;
};

View file

@ -0,0 +1,38 @@
/*exported MockInboxView */
'use strict';
var MockInboxView = {
count: 0,
inEditMode: false,
container: document.createElement('div'),
init: function() {},
updateThread: function() {},
getIdIterator: function() {},
setContact: function() {},
handleEvent: function() {},
updateSelectionStatus: function() {},
cleanForm: function() {},
toggleCheckedAll: function() {},
removeConversationDOM: function() {},
delete: function() {},
setEmpty: function() {},
showOptions: function() {},
startEdit: function() {},
cancelEdit: function() {},
renderThreads: () => {},
createThread: function() {},
insertThreadContainer: function() {},
onMessageSending: function() {},
onMessageReceived: function() {},
onThreadsDeleted: function() {},
appendThread: function() {},
onDraftSaved: function() {},
onDraftDeleted: () => {},
showDraftSavedBanner: () => {},
createThreadContainer: function() {},
updateContactsInfo: function() {},
mark: function() {},
markReadUnread: () => {},
once: () => {}
};

View file

@ -0,0 +1,18 @@
/*exported MockInformation, MockGroupView, MockReportView */
'use strict';
function MockInformation(type) {
}
MockInformation.initDefaultViews = () => {};
MockInformation.prototype = {
onDeliverySuccess: function() {},
onReadSuccess: function() {},
show: function() {},
refresh: function() {},
reset: function() {}
};
var MockGroupView = new MockInformation();
var MockReportView = new MockInformation();

View file

@ -0,0 +1,10 @@
/*exported MockInterInstanceEventDispatcher */
'use strict';
function MockInterInstanceEventDispatcher() {}
MockInterInstanceEventDispatcher.on = () => {};
MockInterInstanceEventDispatcher.emit = () => {};
MockInterInstanceEventDispatcher.connect = () => {};
MockInterInstanceEventDispatcher.disconnect = () => {};

View file

@ -0,0 +1,8 @@
/*exported MockLinkActionHandler */
'use strict';
var MockLinkActionHandler = {
onContextMenu: function() {},
onClick: function() {}
};

View file

@ -0,0 +1,9 @@
/*exported MockLinkHelper */
'use strict';
var MockLinkHelper = {
searchAndLinkClickableData: function(input) {
return input;
}
};

View file

@ -0,0 +1,9 @@
/*exported MockLocalizationHelper*/
(function(exports) {
'use strict';
exports.MockLocalizationHelper = {
init: () => {}
};
})(window);

View file

@ -0,0 +1,82 @@
/*exported MockMessages */
'use strict';
var MockMessages = {
smilMockup: '<smil><head><layout>' +
'<root-layout width="320px" height="480px"/>' +
'<region id="Image" left="0px" top="0px" width="320px" ' +
'height="320px" fit="meet"/><region id="Text" left="0px" ' +
'top="320px" width="320px" height="160px" fit="meet"/>' +
'</layout></head><body><par dur="5000ms"><img src="IMG_0011.jpg" ' +
'region="Image"/></par></body></smil>',
sms: function(opts = {}) {
// default sms message
var message = {
id: 1,
iccId: null,
threadId: 1,
sender: 'sender',
receiver: 'receiver',
body: 'body',
delivery: 'received',
deliveryStatus: 'success',
deliveryTimestamp: Date.now(),
type: 'sms',
messageClass: 'normal',
timestamp: Date.now(),
sentTimestamp: Date.now(),
read: true
};
for (var key in message) {
if (opts.hasOwnProperty(key)) {
message[key] = opts[key];
}
}
return message;
},
mms: function(opts = {}) {
var now = new Date();
var tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// default mms message
var message = {
id: 1,
iccId: null,
threadId: 1,
sender: 'sender',
receivers: ['receiver'],
delivery: 'received',
deliveryInfo: [{
receiver: 'receiver',
deliveryStatus: 'success',
readStatus: 'success',
deliveryTimestamp: +now,
readTimestamp: +now,
}],
type: 'mms',
timestamp: +now,
sentTimestamp: +now,
read: true,
subject: '',
smil: this.smilMockup,
attachments: [{
location: 'text1',
content: new Blob(['This is a plain text'], { type: 'text/plain' })
}],
expiryDate: +tomorrow
};
for (var key in message) {
if (opts.hasOwnProperty(key)) {
message[key] = opts[key];
}
}
return message;
}
};

View file

@ -0,0 +1,16 @@
/*exported MockMimeMapper */
'use strict';
var MockMimeMapper = {
guessTypeFromFileProperties: function(filename, mimetype) {
return mimetype;
},
ensureFilenameMatchesType: function(filename, mimetype) {
if (filename.indexOf('.') !== -1) {
return filename;
}
return filename + '.' + 'jpg';
}
};

View file

@ -0,0 +1,21 @@
'use strict';
function MockMozActivity(activity) {
MockMozActivity.calls.push(activity);
MockMozActivity.instances.push(this);
this.onsuccess = function() {};
this.onerror = function() {};
}
// create an array to hold any calls to this function to test them
MockMozActivity.mSetup = function() {
MockMozActivity.calls = [];
MockMozActivity.instances = [];
};
// destroy the array so it resets every time
MockMozActivity.mTeardown = function() {
delete MockMozActivity.calls;
delete MockMozActivity.instances;
};

View file

@ -0,0 +1,8 @@
/* exported MockMozAlarms */
'use strict';
var MockMozAlarms = {
add: function() {},
remove: function() {}
};

View file

@ -0,0 +1,19 @@
/* global
Promise
*/
/* exported MockNavigation */
'use strict';
var MockNavigation = {
back: () => Promise.resolve(),
init: () => Promise.resolve(),
isCurrentPanel: () => {},
isDefaultPanel: () => false,
toPanel: () => Promise.resolve(),
setReady: () => {},
on: () => {},
off: () => {},
once: () => {},
hasPendingInit: () => {}
};

View file

@ -0,0 +1,10 @@
/*exported MockMozPhoneNumberService */
'use strict';
var MockMozPhoneNumberService = {
normalize: function nmpns_normalize() {
return true;
},
fuzzyMatch: function nmpns_fuzzyMatch() {
}
};

View file

@ -0,0 +1,5 @@
/*exported MockVibrate */
'use strict';
function MockVibrate() {}

View file

@ -0,0 +1,7 @@
/* exported MockNotify */
'use strict';
var MockNotify = {
ringtone: function() {},
vibrate: function() {}
};

View file

@ -0,0 +1,98 @@
'use strict';
function MockRecipients(setup) {
this.setup = setup;
this.recipientsList = document.getElementById(setup.inner);
Object.defineProperty(this, 'length', {
get: function() {
return this.numbersList.length;
},
set: function(val) {
this.numbersList.length = val;
}
});
Object.defineProperty(this, 'numbers', {
get: function() {
return Array.from(this.numbersList);
}
});
this.events = {
add: [],
remove: [],
modechange: []
};
this.numbersList = [];
this.inputValue = '';
this.list = [];
}
MockRecipients.prototype.add = function(contact) {
var span = document.createElement('span');
span.textContent = contact.number;
this.recipientsList.appendChild(span);
this.numbersList.push(contact.number);
this.list.push(contact);
this.emit('add', this.length, contact);
return this;
};
MockRecipients.prototype.remove = function(phone) {
var index = this.numbersList.indexOf(phone);
if (index != -1) {
this.numbersList.splice(this.numbersList.indexOf(phone), 1);
this.emit('remove', this.length);
}
return this;
};
MockRecipients.prototype.render = function() {
return this;
};
MockRecipients.prototype.focus = function() {
return this;
};
MockRecipients.prototype.update = function() {
return this;
};
MockRecipients.prototype.visible = function() {
return this;
};
MockRecipients.prototype.on = function(event, callback) {
this.events[event].push(callback);
return this;
};
MockRecipients.prototype.off = function() {
return this;
};
MockRecipients.prototype.emit = function(type) {
var handlers = this.events;
var args = [].slice.call(arguments, 1);
var handler, stack;
if (!handlers[type]) {
throw new Error('Invalid event type: ' + type);
}
stack = handlers[type].slice();
if (stack.length) {
while ((handler = stack.pop())) {
handler.apply(null, args);
}
}
return this;
};
MockRecipients.View = function() {};
MockRecipients.View.isFocusable = true;

View file

@ -0,0 +1,22 @@
/*exported MockSelectionHandler */
'use strict';
function MockSelectionHandler(options) {
this.selected = null;
}
MockSelectionHandler.prototype = {
get selectedCount() {
return this.selected.size;
},
get selectedList() {
return Array.from(this.selected);
},
select: function() {},
unselect: function() {},
toggleCheckedAll: function() {},
cleanForm: function() {},
allSelected: function() {}
};

View file

@ -0,0 +1,32 @@
(function(exports) {
'use strict';
var MockSettings = {
SERVICE_ID_KEYS: {
mmsServiceId: 'ril.mms.defaultServiceId',
smsServiceId: 'ril.sms.defaultServiceId'
},
mmsSizeLimitation: 295 * 1024,
maxConcatenatedMessages: 10,
mmsServiceId: null,
smsServiceId: null,
supportEmailRecipient: false,
init: () => {},
switchMmsSimHandler: function() {},
isDualSimDevice: function() { return false; },
hasSeveralSim: function() { return false; },
getServiceIdByIccId: function() { return null; },
setReadAheadThreadRetrieval: function() {},
mSetup: function() {
MockSettings.mmsSizeLimitation = 295 * 1024;
MockSettings.maxConcatenatedMessages = 10;
MockSettings.mmsServiceId = null;
MockSettings.smsServiceId = null;
MockSettings.supportEmailRecipient = false;
}
};
exports.MockSettings = MockSettings;
}(this));

View file

@ -0,0 +1,18 @@
/* global Promise */
/* exported MockSilentSms */
'use strict';
var MockSilentSms = {
mSilentSmsNumber: 9090,
checkSilentModeFor: function (smsNumber) {
return Promise.resolve(smsNumber === this.mSilentSmsNumber);
},
init: () => {},
mSetup: function() {
this.mSilentSmsNumber = 9090;
},
mTeardown: function() {
this.mSilentSmsNumber = 9090;
}
};

Some files were not shown because too many files have changed in this diff Show more