/* -*- 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.} 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.} 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.} */ 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));