define(function(require) { var appSelf = require('app_self'), evt = require('evt'), queryString = require('query_string'), queryURI = require('query_uri'); var pending = {}; // htmlCacheRestorePendingMessage defined in html_cache_restore, // see comment for it. var cachedList = (window.htmlCacheRestorePendingMessage && window.htmlCacheRestorePendingMessage.length) ? window.htmlCacheRestorePendingMessage : []; // Convert the cached list to named properties on pending. cachedList.forEach(function(type) { pending[type] = true; }); var appMessages = evt.mix({ /** * Whether or not we have pending messages. * * @param {string} type message type. * @return {boolean} Whether or there are pending message(s) of the type. */ hasPending: function(type) { return pending.hasOwnProperty(type) || (navigator.mozHasPendingMessage && navigator.mozHasPendingMessage(type)); }, /** * Perform requested activity. * * @param {MozActivityRequestHandler} req activity invocation. */ onActivityRequest: function(req) { // Parse the activity request. var source = req.source; var sourceData = source.data; var activityName = source.name; var dataType = sourceData.type; var url = sourceData.url || sourceData.URI; // To assist in bug analysis, log the start of the activity here. console.log('Received activity: ' + activityName); // Dynamically load util, since it is only needed for certain // pathways in this module. require(['attachment_name'], function(attachmentName) { var data = {}; if (dataType === 'url' && activityName === 'share') { data.body = url; } else { var urlParts = url ? queryURI(url) : []; data.to = urlParts[0]; data.subject = urlParts[1]; data.body = typeof urlParts[2] === 'string' ? urlParts[2] : null; data.cc = urlParts[3]; data.bcc = urlParts[4]; data.attachmentBlobs = sourceData.blobs || []; data.attachmentNames = sourceData.filenames || []; attachmentName.ensureNameList(data.attachmentBlobs, data.attachmentNames); } this.emitWhenListener('activity', activityName, data, req); }.bind(this)); }, onNotification: function(msg) { // Skip notification events that are not from a notification // "click". The system app will also notify this method of // any close events for notificaitons, which are not at all // interesting, at least for the purposes here. if (!msg.clicked) return; // Need to manually get all notifications and close the one // that triggered this event due to fallout from 890440 and // 966481. if (typeof Notification !== 'undefined' && Notification.get) { Notification.get().then(function(notifications) { if (notifications) { notifications.some(function(notification) { // Compare tags, as the tag is based on the account ID and // we only have one notification per account. Plus, there // is no "id" field on the notification. if (notification.tag === msg.tag && notification.close) { notification.close(); return true; } }); } }); } // icon url parsing is a cray cray way to pass day day var data = queryString.toObject((msg.imageURL || '').split('#')[1]); if (document.hidden) { appSelf.latest('self', function(app) { app.launch(); }); } this.emitWhenListener('notification', data); } }); if ('mozSetMessageHandler' in navigator) { navigator.mozSetMessageHandler( 'activity', appMessages.onActivityRequest.bind(appMessages)); navigator.mozSetMessageHandler( 'notification', appMessages.onNotification.bind(appMessages)); evt.on( 'notification', appMessages.onNotification.bind(appMessages)); // Do not listen for navigator.mozSetMessageHandler('alarm') type, that is // only done in the back end's cronsync for now. } else { console.warn('Activity support disabled!'); } return appMessages; });
define('mail_app', function(require, exports, module) { var appMessages = require('app_messages'), htmlCache = require('html_cache'), mozL10n = require('l10n!'), common = require('mail_common'), evt = require('evt'), model = require('model'), headerCursor = require('header_cursor').cursor, Cards = common.Cards, waitingForCreateAccountPrompt = false, activityCallback = null; require('sync'); require('wake_locks'); model.latestOnce('api', function(api) { // If our password is bad, we need to pop up a card to ask for the updated // password. api.onbadlogin = function(account, problem, whichSide) { switch (problem) { case 'bad-user-or-pass': Cards.pushCard('setup_fix_password', 'default', 'animate', { account: account, whichSide: whichSide, restoreCard: Cards.activeCardIndex }, 'right'); break; case 'imap-disabled': case 'pop3-disabled': Cards.pushCard('setup_fix_gmail', 'default', 'animate', { account: account, restoreCard: Cards.activeCardIndex }, 'right'); break; case 'needs-app-pass': Cards.pushCard('setup_fix_gmail_twofactor', 'default', 'animate', { account: account, restoreCard: Cards.activeCardIndex }, 'right'); break; } }; api.useLocalizedStrings({ wrote: mozL10n.get('reply-quoting-wrote'), originalMessage: mozL10n.get('forward-original-message'), forwardHeaderLabels: { subject: mozL10n.get('forward-header-subject'), date: mozL10n.get('forward-header-date'), from: mozL10n.get('forward-header-from'), replyTo: mozL10n.get('forward-header-reply-to'), to: mozL10n.get('forward-header-to'), cc: mozL10n.get('forward-header-cc') }, folderNames: { inbox: mozL10n.get('folder-inbox'), sent: mozL10n.get('folder-sent'), drafts: mozL10n.get('folder-drafts'), trash: mozL10n.get('folder-trash'), queue: mozL10n.get('folder-queue'), junk: mozL10n.get('folder-junk'), archives: mozL10n.get('folder-archives'), localdrafts: mozL10n.get('folder-localdrafts') } }); }); // Handle cases where a default card is needed for back navigation // after a non-default entry point (like an activity) is triggered. Cards.pushDefaultCard = function(onPushed) { model.latestOnce('foldersSlice', function() { Cards.pushCard('message_list', 'nonsearch', 'none', { onPushed: onPushed }, // Default to "before" placement. 'left'); }); }; Cards._init(); var finalCardStateCallback, waitForAppMessage = false, startedInBackground = false, cachedNode = Cards._cardsNode.children[0], startCardId = cachedNode && cachedNode.getAttribute('data-type'); var startCardArgs = { 'setup_account_info': [ 'setup_account_info', 'default', 'immediate', { onPushed: function(impl) { htmlCache.delayedSaveFromNode(impl.domNode.cloneNode(true)); } } ], 'message_list': [ 'message_list', 'nonsearch', 'immediate', {} ] }; function pushStartCard(id, addedArgs) { var args = startCardArgs[id]; if (!args) { throw new Error('Invalid start card: ' + id); } //Add in cached node to use (could be null) args[3].cachedNode = cachedNode; // Mix in addedArgs to the args object that is passed to pushCard. if (addedArgs) { Object.keys(addedArgs).forEach(function(key) { args[3][key] = addedArgs[key]; }); } return Cards.pushCard.apply(Cards, args); } if (appMessages.hasPending('activity') || appMessages.hasPending('notification')) { // There is an activity, do not use the cache node, start fresh, // and block normal first card selection, wait for activity. cachedNode = null; waitForAppMessage = true; } if (appMessages.hasPending('alarm')) { // There is an alarm, do not use the cache node, start fresh, // as we were woken up just for the alarm. cachedNode = null; startedInBackground = true; } // If still have a cached node, then show it. if (cachedNode) { // Wire up a card implementation to the cached node. if (startCardId) { pushStartCard(startCardId); } else { cachedNode = null; } } /** * When determination of real start state is known after * getting data, then make sure the correct card is * shown. If the card used from cache is not correct, * wipe out the cards and start fresh. * @param {String} cardId the desired card ID. */ function resetCards(cardId, args) { cachedNode = null; var startArgs = startCardArgs[cardId], query = [startArgs[0], startArgs[1]]; if (!Cards.hasCard(query)) { Cards.removeAllCards(); pushStartCard(cardId, args); } } /* * Determines if current card is a nonsearch message_list * card, which is the default kind of card. */ function isCurrentCardMessageList() { var cardType = Cards.getCurrentCardType(); return (cardType && cardType[0] === 'message_list' && cardType[1] === 'nonsearch'); } /** * Tracks what final card state should be shown. If the * app started up hidden for a cronsync, do not actually * show the UI until the app becomes visible, so that * extra work can be avoided for the hidden cronsync case. */ function showFinalCardState(fn) { if (startedInBackground && document.hidden) { finalCardStateCallback = fn; } else { fn(); } } /** * Shows the message list. Assumes that the correct * account and inbox have already been selected. */ function showMessageList(args) { showFinalCardState(function() { resetCards('message_list', args); }); } // Handles visibility changes: if the app becomes visible // being hidden via a cronsync startup, trigger UI creation. document.addEventListener('visibilitychange', function onVisibilityChange() { if (startedInBackground && finalCardStateCallback && !document.hidden) { finalCardStateCallback(); finalCardStateCallback = null; } }, false); // Some event modifications during setup do not have full account // IDs. This listener catches those modifications and applies // them when the data is available. evt.on('accountModified', function(accountId, data) { model.latestOnce('acctsSlice', function() { var account = model.getAccount(accountId); if (account) { account.modifyAccount(data); } }); }); // The add account UI flow is requested. evt.on('addAccount', function() { Cards.removeAllCards(); // Show the first setup card again. pushStartCard('setup_account_info', { allowBack: true }); }); function resetApp() { // Clear any existing local state and reset UI/model state. waitForAppMessage = false; waitingForCreateAccountPrompt = false; activityCallback = null; Cards.removeAllCards(); model.init(); } function activityContinued() { if (activityCallback) { var activityCb = activityCallback; activityCallback = null; activityCb(); return true; } return false; } // An account was deleted. Burn it all to the ground and // rise like a phoenix. Prefer a UI event vs. a slice // listen to give flexibility about UI construction: // an acctsSlice splice change may not warrant removing // all the cards. evt.on('accountDeleted', resetApp); evt.on('resetApp', resetApp); // A request to show the latest account in the UI. // Usually triggered after an account has been added. evt.on('showLatestAccount', function() { Cards.removeAllCards(); model.latestOnce('acctsSlice', function(acctsSlice) { var account = acctsSlice.items[acctsSlice.items.length - 1]; model.changeAccount(account, function() { pushStartCard('message_list', { // If waiting to complete an activity, do so after pushing the // message list card. onPushed: activityContinued }); }); }); }); model.on('acctsSlice', function() { if (!model.hasAccount()) { if (!waitingForCreateAccountPrompt) { resetCards('setup_account_info'); } } else { model.latestOnce('foldersSlice', function() { if (waitForAppMessage) { return; } // If an activity was waiting for an account, trigger it now. if (activityContinued()) { return; } showMessageList(); }); } }); var lastActivityTime = 0; appMessages.on('activity', function(type, data, rawActivity) { // Rate limit rapid fire activity triggers, like an accidental // double tap. While the card code adjusts for the taps, in // the case of configured account, user can end up with multiple // compose cards in the stack, which is probably confusing, // and the rapid tapping is likely just an accident, or an // incorrect user belief that double taps are needed for // activation. var activityTime = Date.now(); if (activityTime < lastActivityTime + 1000) { return; } lastActivityTime = activityTime; function initComposer() { Cards.pushCard('compose', 'default', 'immediate', { activity: rawActivity, composerData: { onComposer: function(composer) { var attachmentBlobs = data.attachmentBlobs; /* to/cc/bcc/subject/body all have default values that shouldn't be clobbered if they are not specified in the URI*/ if (data.to) { composer.to = data.to; } if (data.subject) { composer.subject = data.subject; } if (data.body) { composer.body = { text: data.body }; } if (data.cc) { composer.cc = data.cc; } if (data.bcc) { composer.bcc = data.bcc; } if (attachmentBlobs) { for (var iBlob = 0; iBlob < attachmentBlobs.length; iBlob++) { composer.addAttachment({ name: data.attachmentNames[iBlob], blob: attachmentBlobs[iBlob] }); } } } } }); } function promptEmptyAccount() { common.ConfirmDialog.show(mozL10n.get('setup-empty-account-prompt'), function(confirmed) { if (!confirmed) { rawActivity.postError('cancelled'); } waitingForCreateAccountPrompt = false; // No longer need to wait for the activity to complete, it needs // normal card flow waitForAppMessage = false; activityCallback = initComposer; // Always just reset to setup account in case the system does // not properly close out the email app on a cancelled activity. resetCards('setup_account_info'); }); } // Remove previous cards because the card stack could get // weird if inserting a new card that would not normally be // at that stack level. Primary concern: going to settings, // then trying to add a compose card at that stack level. // More importantly, the added card could have a "back" // operation that does not mean "back to previous state", // but "back in application flowchart". Message list is a // good known jump point, so do not needlessly wipe that one // out if it is the current one. Message list is a good // known jump point, so do not needlessly wipe that one out // if it is the current one. if (!isCurrentCardMessageList()) { Cards.removeAllCards(); } if (model.inited) { if (model.hasAccount()) { initComposer(); } else { waitingForCreateAccountPrompt = true; promptEmptyAccount(); } } else { // Be optimistic and start rendering compose as soon as possible // In the edge case that email is not configured, then the empty // account prompt will be triggered quickly in the next section. initComposer(); waitingForCreateAccountPrompt = true; model.latestOnce('acctsSlice', function activityOnAccount() { if (!model.hasAccount()) { promptEmptyAccount(); } }); } }); appMessages.on('notification', function(data) { var type = data ? data.type : ''; model.latestOnce('foldersSlice', function latestFolderSlice() { function onCorrectFolder() { function onPushed() { waitForAppMessage = false; } // Remove previous cards because the card stack could get // weird if inserting a new card that would not normally be // at that stack level. Primary concern: going to settings, // then trying to add a reader or message list card at that // stack level. More importantly, the added card could have // a "back" operation that does not mean "back to previous // state", but "back in application flowchart". Message // list is a good known jump point, so do not needlessly // wipe that one out if it is the current one. if (!isCurrentCardMessageList()) { Cards.removeAllCards(); } if (type === 'message_list') { showMessageList({ onPushed: onPushed }); } else if (type === 'message_reader') { headerCursor.setCurrentMessageBySuid(data.messageSuid); Cards.pushCard(type, 'default', 'immediate', { messageSuid: data.messageSuid, onPushed: onPushed }); } else { console.error('unhandled notification type: ' + type); } } var acctsSlice = model.acctsSlice, accountId = data.accountId; if (model.account.id === accountId) { return model.selectInbox(onCorrectFolder); } else { var newAccount; acctsSlice.items.some(function(account) { if (account.id === accountId) { newAccount = account; return true; } }); if (newAccount) { model.changeAccount(newAccount, onCorrectFolder); } } }); }); model.init(); });
createdCallback: function() { // Sync display this._needsSizeLastSync = true; this.updateLastSynced(); this.curFolder = null; this.isIncomingFolder = true; // Binding "this" to some functions as they are used for event listeners. this._hideSearchBoxByScrolling = this._hideSearchBoxByScrolling.bind(this); this._folderChanged = this._folderChanged.bind(this); this.onNewMail = this.onNewMail.bind(this); this.onFoldersSliceChange = this.onFoldersSliceChange.bind(this); this.usingCachedNode = this.dataset.cached === 'cached'; this.msgVScroll.on('messagesSpliceStart', function(index, howMany, addedItems, requested, moreExpected) { this._clearCachedMessages(); }.bind(this)); this.msgVScroll.on('messagesSpliceEnd', function(index, howMany, addedItems, requested, moreExpected) { // Only cache if it is an add or remove of items if (addedItems.length || howMany) { this._considerCacheDom(index); } }.bind(this)); this.msgVScroll.on('messagesChange', function(message, index) { this.onMessagesChange(message, index); }.bind(this)); this._emittedContentEvents = false; this.msgVScroll.on('messagesComplete', function(newEmailCount) { this.onNewMail(newEmailCount); // Inform that content is ready. There could actually be a small delay // with vScroll.updateDataBind from rendering the final display, but it // is small enough that it is not worth trying to break apart the design // to accommodate this metrics signal. if (!this._emittedContentEvents) { evt.emit('metrics:contentDone'); this._emittedContentEvents = true; } }.bind(this)); // Outbox has some special concerns, override status method to account for // it. Do this **before** initing the vscroll, so that the override is // used. var oldMessagesStatus = this.msgVScroll.messages_status; this.msgVScroll.messages_status = function(newStatus) { // The outbox's refresh button is used for sending messages, so we // ignore any syncing events generated by the slice. The outbox // doesn't need to show many of these indicators (like the "Load // More Messages..." node, etc.) and it has its own special // "refreshing" display, as documented elsewhere in this file. if (!this.curFolder || this.curFolder.type === 'outbox') { return; } return oldMessagesStatus.call(this.msgVScroll, newStatus); }.bind(this); this.msgVScroll.on('emptyLayoutShown', function() { this._clearCachedMessages(); // The outbox can't refresh anything if there are no messages. if (this.curFolder.type === 'outbox') { this.refreshBtn.disabled = true; } this.editBtn.disabled = true; this._hideSearchBoxByScrolling(); }.bind(this)); this.msgVScroll.on('emptyLayoutHidden', function() { this.editBtn.disabled = false; this.refreshBtn.disabled = false; }.bind(this)); this.msgVScroll.on('syncInProgress', function(syncInProgress) { if (syncInProgress) { this.setRefreshState(true); } else { this.setRefreshState(false); this._manuallyTriggeredSync = false; } }.bind(this)); var vScrollBindData = (function bindNonSearch(model, node) { model.element = node; node.message = model; this.updateMessageDom(model); }).bind(this); this.msgVScroll.init(this.scrollContainer, vScrollBindData, defaultVScrollData); // Event listeners for VScroll events. this.msgVScroll.vScroll.on('inited', this._hideSearchBoxByScrolling); this.msgVScroll.vScroll.on('dataChanged', this._hideSearchBoxByScrolling); this.msgVScroll.vScroll.on('recalculated', function(calledFromTop) { if (calledFromTop) { this._hideSearchBoxByScrolling(); } }.bind(this)); this._topBar = new MessageListTopBar( this.querySelector('.message-list-topbar') ); this._topBar.bindToElements(this.scrollContainer, this.msgVScroll.vScroll); this.onFolderPickerClosing = this.onFolderPickerClosing.bind(this); evt.on('folderPickerClosing', this.onFolderPickerClosing); },
define(function(require) { 'use strict'; var lockTimeouts = {}, evt = require('evt'), allLocks = {}, // Using an object instead of an array since dataIDs are unique // strings. dataOps = {}, dataOpsTimeoutId = 0, // Only allow keeping the locks for a maximum of 45 seconds. // This is to prevent a long, problematic sync from consuming // all of the battery power in the phone. A more sophisticated // method may be to adjust the size of the timeout based on // past performance, but that would mean keeping a persistent // log of attempts. This naive approach just tries to catch the // most likely set of failures: just a temporary really bad // cell network situation that once the next sync happens, the // issue is resolved. maxLockInterval = 45000, // Allow UI-triggered data operations to complete in a wake lock timeout // case, but only for a certain amount of time, because they could be the // cause of the wake lock timeout. dataOpsTimeout = 5000; // START failsafe close support, bug 1025727. // If the wake locks are timed out, it means sync went on too long, and there // is likely a problem. Reset state via app shutdown. Allow for UI-triggered // data operations to complete though before finally releasing the wake locks // and shutting down. function close() { // Reset state in case a close does not actually happen. dataOps = {}; dataOpsTimeoutId = 0; // Only really close if the app is hidden. if (document.hidden) { console.log('email: cronsync wake locks expired, force closing app'); window.close(); } else { console.log('email: cronsync wake locks expired, but app visible, ' + 'not force closing'); // User is using the app. Just clear all locks so we do not burn battery. // This means the app could still be in a bad data sync state, so just // need to rely on the next sync attempt or OOM from other app usage. Object.keys(allLocks).forEach(function(accountKey) { clearLocks(accountKey); }); } } function closeIfNoDataOps() { var dataOpsKeys = Object.keys(dataOps); if (!dataOpsKeys.length) { // All clear, no waiting data operations, shut it down. return close(); } console.log('email: cronsync wake lock force shutdown waiting on email ' + 'data operations: ' + dataOpsKeys.join(', ')); // Allow data operations to complete, but also set a cap on that since // they could be the ones causing the sync to fail. Give it 5 seconds. dataOpsTimeoutId = setTimeout(close, dataOpsTimeout); } // Listen for data operation events that might want to delay the failsafe // close switch. evt.on('uiDataOperationStart', function(dataId) { dataOps[dataId] = true; }); evt.on('uiDataOperationStop', function(dataId) { delete dataOps[dataId]; if (dataOpsTimeoutId && !Object.keys(dataOps).length) { clearTimeout(dataOpsTimeoutId); close(); } }); // END failsafe close function clearLocks(accountKey) { console.log('email: clearing wake locks for "' + accountKey + '"'); // Clear timer var lockTimeoutId = lockTimeouts[accountKey]; if (lockTimeoutId) { clearTimeout(lockTimeoutId); } lockTimeouts[accountKey] = 0; // Clear the locks var locks = allLocks[accountKey]; allLocks[accountKey] = null; if (locks) { locks.forEach(function(lock) { lock.unlock(); }); } } // Creates a string key from an array of string IDs. Uses a space // separator since that cannot show up in an ID. function makeAccountKey(accountIds) { return 'id' + accountIds.join(' '); } function onCronStop(accountIds) { clearLocks(makeAccountKey(accountIds)); } evt.on('cronSyncWakeLocks', function(accountKey, locks) { if (lockTimeouts[accountKey]) { // Only support one set of locks. Better to err on the side of // saving the battery and not continue syncing vs supporting a // pathologic error that leads to a compound set of locks but // end up with more syncs completing. clearLocks(accountKey); } allLocks[accountKey] = locks; // If timeout is reached, means app is stuck in a bad state, and just // shut it down via the failsafe close. lockTimeouts[accountKey] = setTimeout(closeIfNoDataOps, maxLockInterval); }); evt.on('cronSyncStop', onCronStop); });
define(function(require) { var appSelf = require('app_self'), evt = require('evt'), queryURI = require('query_uri'), queryString = require('query_string'), appMessages = evt.mix({}), pending = {}, lastNotifyId = 0, // htmlCacheRestorePendingMessage defined in html_cache_restore, // see comment for it. cachedList = (window.htmlCacheRestorePendingMessage && window.htmlCacheRestorePendingMessage.length) ? window.htmlCacheRestorePendingMessage : []; // Convert the cached list to named properties on pending. cachedList.forEach(function(type) { pending[type] = true; }); appMessages.hasPending = function(type) { return pending.hasOwnProperty(type) || (navigator.mozHasPendingMessage && navigator.mozHasPendingMessage(type)); }; function onNotification(msg) { console.log('email got notification: ' + JSON.stringify(msg, null, ' ')); // icon url parsing is a cray cray way to pass day day var data = queryString.toObject((msg.imageURL || '').split('#')[1]); // Do not handle duplicate notifications. May be a bug in the // notifications system. if (data.notifyId && data.notifyId === lastNotifyId) return; lastNotifyId = data.notifyId; console.log('dispatching notification'); if (document.hidden) { appSelf.latest('self', function(app) { app.launch(); }); } appMessages.emitWhenListener('notification', data); } if ('mozSetMessageHandler' in navigator) { navigator.mozSetMessageHandler('activity', function onActivity(message) { var activityName = message.source.name, attachmentBlobs = message.source.data.blobs, attachmentNames = message.source.data.filenames, url = message.source.data.url || message.source.data.URI, urlParts = url ? queryURI(url) : []; // To assist in bug analysis, log the start of the activity here. console.log('activity!', activityName); var composeData = { to: urlParts[0], subject: urlParts[1], body: typeof urlParts[2] === 'string' ? urlParts[2] : null, cc: urlParts[3], bcc: urlParts[4], attachmentBlobs: attachmentBlobs, attachmentNames: attachmentNames }; appMessages.emitWhenListener('activity', activityName, composeData, message); }); // Notifications can come from outside the app via the system, // by calling the mozSetMessageHandler listener. That happens // if the app is closed. If the app is open, then the app has // to listen for the onclick on the notification, and a // synthetic "event" is triggered via evt. navigator.mozSetMessageHandler('notification', onNotification); evt.on('notification', onNotification); // Do not listen for navigator.mozSetMessageHandler('alarm') type, that is // only done in the back end's cronsync for now. } else { console.warn('Activity support disabled!'); } return appMessages; });
define('mail_app', function(require, exports, module) { var appMessages = require('app_messages'), htmlCache = require('html_cache'), mozL10n = require('l10n!'), cards = require('cards'), ConfirmDialog = require('confirm_dialog'), evt = require('evt'), model = require('model'), headerCursor = require('header_cursor').cursor, slice = Array.prototype.slice, waitingForCreateAccountPrompt = false, activityCallback = null; require('shared/js/font_size_utils'); require('metrics'); require('sync'); require('wake_locks'); model.latestOnce('api', function(api) { // If our password is bad, we need to pop up a card to ask for the updated // password. api.onbadlogin = function(account, problem, whichSide) { switch (problem) { case 'bad-user-or-pass': cards.pushCard('setup_fix_password', 'animate', { account: account, whichSide: whichSide, restoreCard: cards.activeCardIndex }, 'right'); break; case 'imap-disabled': case 'pop3-disabled': cards.pushCard('setup_fix_gmail', 'animate', { account: account, restoreCard: cards.activeCardIndex }, 'right'); break; case 'needs-app-pass': cards.pushCard('setup_fix_gmail_twofactor', 'animate', { account: account, restoreCard: cards.activeCardIndex }, 'right'); break; case 'needs-oauth-reauth': cards.pushCard('setup_fix_oauth2', 'animate', { account: account, restoreCard: cards.activeCardIndex }, 'right'); break; } }; api.useLocalizedStrings({ wrote: mozL10n.get('reply-quoting-wrote'), originalMessage: mozL10n.get('forward-original-message'), forwardHeaderLabels: { subject: mozL10n.get('forward-header-subject'), date: mozL10n.get('forward-header-date'), from: mozL10n.get('forward-header-from'), replyTo: mozL10n.get('forward-header-reply-to'), to: mozL10n.get('forward-header-to'), cc: mozL10n.get('forward-header-cc') }, folderNames: { inbox: mozL10n.get('folder-inbox'), outbox: mozL10n.get('folder-outbox'), sent: mozL10n.get('folder-sent'), drafts: mozL10n.get('folder-drafts'), trash: mozL10n.get('folder-trash'), queue: mozL10n.get('folder-queue'), junk: mozL10n.get('folder-junk'), archives: mozL10n.get('folder-archives'), localdrafts: mozL10n.get('folder-localdrafts') } }); }); // Handle cases where a default card is needed for back navigation // after a non-default entry point (like an activity) is triggered. cards.pushDefaultCard = function(onPushed) { model.latestOnce('foldersSlice', function() { cards.pushCard('message_list', 'none', { onPushed: onPushed }, // Default to "before" placement. 'left'); }); }; cards._init(); var finalCardStateCallback, waitForAppMessage = false, startedInBackground = false, cachedNode = cards._cardsNode.children[0], startCardId = cachedNode && cachedNode.getAttribute('data-type'); function getStartCardArgs(id) { // Use a function that returns fresh arrays for each call so that object // in that last array position is fresh for each call and does not have other // properties mixed in to it by multiple reuse of the same object // (bug 1031588). if (id === 'setup_account_info') { return [ 'setup_account_info', 'immediate', { onPushed: function(cardNode) { var cachedNode = htmlCache.cloneAsInertNodeAvoidingCustomElementHorrors( cardNode); cachedNode.dataset.cached = 'cached'; htmlCache.delayedSaveFromNode(cachedNode); } } ]; } else if (id === 'message_list') { return [ 'message_list', 'immediate', {} ]; } } function pushStartCard(id, addedArgs) { var args = getStartCardArgs(id); if (!args) { throw new Error('Invalid start card: ' + id); } //Add in cached node to use (could be null) args[2].cachedNode = cachedNode; // Mix in addedArgs to the args object that is passed to pushCard. if (addedArgs) { Object.keys(addedArgs).forEach(function(key) { args[2][key] = addedArgs[key]; }); } return cards.pushCard.apply(cards, args); } if (appMessages.hasPending('activity') || appMessages.hasPending('notification')) { // There is an activity, do not use the cache node, start fresh, // and block normal first card selection, wait for activity. cachedNode = null; waitForAppMessage = true; console.log('email waitForAppMessage'); } if (appMessages.hasPending('alarm')) { // There is an alarm, do not use the cache node, start fresh, // as we were woken up just for the alarm. cachedNode = null; startedInBackground = true; console.log('email startedInBackground'); } // If still have a cached node, then show it. if (cachedNode) { // l10n may not see this as it was injected before l10n.js was loaded, // so let it know it needs to translate it. mozL10n.translateFragment(cachedNode); // Wire up a card implementation to the cached node. if (startCardId) { pushStartCard(startCardId); } else { cachedNode = null; } } /** * When determination of real start state is known after * getting data, then make sure the correct card is * shown. If the card used from cache is not correct, * wipe out the cards and start fresh. * @param {String} cardId the desired card ID. */ function resetCards(cardId, args) { cachedNode = null; var startArgs = getStartCardArgs(cardId), query = startArgs[0]; if (!cards.hasCard(query)) { cards.removeAllCards(); pushStartCard(cardId, args); } } /* * Determines if current card is a nonsearch message_list * card, which is the default kind of card. */ function isCurrentCardMessageList() { var cardType = cards.getCurrentCardType(); return (cardType && cardType === 'message_list'); } /** * Tracks what final card state should be shown. If the * app started up hidden for a cronsync, do not actually * show the UI until the app becomes visible, so that * extra work can be avoided for the hidden cronsync case. */ function showFinalCardState(fn) { if (startedInBackground && document.hidden) { finalCardStateCallback = fn; } else { fn(); } } /** * Shows the message list. Assumes that the correct * account and inbox have already been selected. */ function showMessageList(args) { showFinalCardState(function() { resetCards('message_list', args); }); } // Handles visibility changes: if the app becomes visible // being hidden via a cronsync startup, trigger UI creation. document.addEventListener('visibilitychange', function onVisibilityChange() { if (startedInBackground && finalCardStateCallback && !document.hidden) { finalCardStateCallback(); finalCardStateCallback = null; } }, false); // The add account UI flow is requested. evt.on('addAccount', function() { cards.removeAllCards(); // Show the first setup card again. pushStartCard('setup_account_info', { allowBack: true }); }); function resetApp() { // Clear any existing local state and reset UI/model state. waitForAppMessage = false; waitingForCreateAccountPrompt = false; activityCallback = null; cards.removeAllCards(); model.init(); } function activityContinued() { if (activityCallback) { var activityCb = activityCallback; activityCallback = null; activityCb(); return true; } return false; } // An account was deleted. Burn it all to the ground and // rise like a phoenix. Prefer a UI event vs. a slice // listen to give flexibility about UI construction: // an acctsSlice splice change may not warrant removing // all the cards. evt.on('accountDeleted', resetApp); evt.on('resetApp', resetApp); // A request to show the latest account in the UI. // Usually triggered after an account has been added. evt.on('showLatestAccount', function() { cards.removeAllCards(); model.latestOnce('acctsSlice', function(acctsSlice) { var account = acctsSlice.items[acctsSlice.items.length - 1]; model.changeAccount(account, function() { pushStartCard('message_list', { // If waiting to complete an activity, do so after pushing the // message list card. onPushed: activityContinued }); }); }); }); model.on('acctsSlice', function() { if (!model.hasAccount()) { if (!waitingForCreateAccountPrompt) { resetCards('setup_account_info'); } } else { model.latestOnce('foldersSlice', function() { if (waitForAppMessage) { return; } // If an activity was waiting for an account, trigger it now. if (activityContinued()) { return; } showMessageList(); }); } }); // Rate limit rapid fire entries, like an accidental double tap. While the card // code adjusts for the taps, in the case of configured account, user can end up // with multiple compose or reader cards in the stack, which is probably // confusing, and the rapid tapping is likely just an accident, or an incorrect // user belief that double taps are needed for activation. // Using one entry time tracker across all gate entries since ideally we do not // want to handle a second fast action regardless of source. We want the first // one to have a chance of getting a bit further along. If this becomes an issue // though, the closure created inside getEntry could track a unique time for // each gateEntry use. var lastEntryTime = 0; function gateEntry(fn) { return function() { var entryTime = Date.now(); // Only one entry per second. if (entryTime < lastEntryTime + 1000) { console.log('email entry gate blocked fast repeated action'); return; } lastEntryTime = entryTime; return fn.apply(null, slice.call(arguments)); }; } appMessages.on('activity', gateEntry(function(type, data, rawActivity) { function initComposer() { cards.pushCard('compose', 'immediate', { activity: rawActivity, composerData: { onComposer: function(composer, composeCard) { var attachmentBlobs = data.attachmentBlobs; /* to/cc/bcc/subject/body all have default values that shouldn't be clobbered if they are not specified in the URI*/ if (data.to) { composer.to = data.to; } if (data.subject) { composer.subject = data.subject; } if (data.body) { composer.body = { text: data.body }; } if (data.cc) { composer.cc = data.cc; } if (data.bcc) { composer.bcc = data.bcc; } if (attachmentBlobs) { var attachmentsToAdd = []; for (var iBlob = 0; iBlob < attachmentBlobs.length; iBlob++) { attachmentsToAdd.push({ name: data.attachmentNames[iBlob], blob: attachmentBlobs[iBlob] }); } composeCard.addAttachmentsSubjectToSizeLimits(attachmentsToAdd); } } } }); } function promptEmptyAccount() { ConfirmDialog.show(mozL10n.get('setup-empty-account-prompt'), function(confirmed) { if (!confirmed) { rawActivity.postError('cancelled'); } waitingForCreateAccountPrompt = false; // No longer need to wait for the activity to complete, it needs // normal card flow waitForAppMessage = false; activityCallback = initComposer; // Always just reset to setup account in case the system does // not properly close out the email app on a cancelled activity. resetCards('setup_account_info'); }); } // Remove previous cards because the card stack could get // weird if inserting a new card that would not normally be // at that stack level. Primary concern: going to settings, // then trying to add a compose card at that stack level. // More importantly, the added card could have a "back" // operation that does not mean "back to previous state", // but "back in application flowchart". Message list is a // good known jump point, so do not needlessly wipe that one // out if it is the current one. Message list is a good // known jump point, so do not needlessly wipe that one out // if it is the current one. if (!isCurrentCardMessageList()) { cards.removeAllCards(); } if (model.inited) { if (model.hasAccount()) { initComposer(); } else { waitingForCreateAccountPrompt = true; console.log('email waitingForCreateAccountPrompt'); promptEmptyAccount(); } } else { // Be optimistic and start rendering compose as soon as possible // In the edge case that email is not configured, then the empty // account prompt will be triggered quickly in the next section. initComposer(); waitingForCreateAccountPrompt = true; console.log('email waitingForCreateAccountPrompt'); model.latestOnce('acctsSlice', function activityOnAccount() { if (!model.hasAccount()) { promptEmptyAccount(); } }); } })); appMessages.on('notification', gateEntry(function(data) { data = data || {}; // Default to message_list in case no type is set, which could happen if email // is awoken by a notification that was generated before email switched to use // Notifcation.data in 2.2. Previous versions used fragment IDs on iconURLs. // By choosing the message list, then at least some UI is shown. var type = data.type || 'message_list'; var folderType = data.folderType || 'inbox'; model.latestOnce('foldersSlice', function latestFolderSlice() { function onCorrectFolder() { function onPushed() { waitForAppMessage = false; } // Remove previous cards because the card stack could get // weird if inserting a new card that would not normally be // at that stack level. Primary concern: going to settings, // then trying to add a reader or message list card at that // stack level. More importantly, the added card could have // a "back" operation that does not mean "back to previous // state", but "back in application flowchart". Message // list is a good known jump point, so do not needlessly // wipe that one out if it is the current one. if (!isCurrentCardMessageList()) { cards.removeAllCards(); } if (type === 'message_list') { showMessageList({ onPushed: onPushed }); } else if (type === 'message_reader') { headerCursor.setCurrentMessageBySuid(data.messageSuid); cards.pushCard(type, 'immediate', { messageSuid: data.messageSuid, onPushed: onPushed }); } } var acctsSlice = model.acctsSlice, accountId = data.accountId; // accountId could be undefined if this was a notification that was // generated before 2.2. In that case, just default to the current account. if (!accountId || model.account.id === accountId) { // folderType will often be 'inbox' (in the case of a new message // notification) or 'outbox' (in the case of a "failed send" // notification). return model.selectFirstFolderWithType(folderType, onCorrectFolder); } else { var newAccount; acctsSlice.items.some(function(account) { if (account.id === accountId) { newAccount = account; return true; } }); if (newAccount) { model.changeAccount(newAccount, function() { model.selectFirstFolderWithType(folderType, onCorrectFolder); }); } } }); })); /* * IMPORTANT: place this event listener after the one for 'notification'. In the * case where email is not running and is opened by a notification, these * listeners are added after the initial notifications have been received by * app_messages, and so the order in which they are registered affect which one * is called first for pending listeners. evt.js does not keep an absolute order * on all events. */ appMessages.on('notificationClosed', gateEntry(function(data) { // The system notifies the app of closed messages. This really is not helpful // for the email app, but since it wakes up the app, if we do not at least try // to show a usable UI, the user could end up with a blank screen. As the app // could also have been awakened by sync or just user action and running in // the background, we cannot just close the app. So just make sure there is // some usable UI. if (waitForAppMessage && !cards.getCurrentCardType()) { resetApp(); } })); model.init(); });
return function syncInit(model, api) { var hasBeenVisible = !document.hidden, waitingOnCron = {}; // Let the back end know the app is interactive, not just // a quick sync and shutdown case, so that it knows it can // do extra work. if (hasBeenVisible) { api.setInteractive(); } // If the page is ever not hidden, then do not close it later. document.addEventListener('visibilitychange', function onVisibilityChange() { if (!document.hidden) { hasBeenVisible = true; api.setInteractive(); } }, false); // Creates a string key from an array of string IDs. Uses a space // separator since that cannot show up in an ID. function makeAccountKey(accountIds) { return 'id' + accountIds.join(' '); } var sendNotification; if (typeof Notification !== 'function') { console.log('email: notifications not available'); sendNotification = function() {}; } else { sendNotification = function(notificationId, titleL10n, bodyL10n, iconUrl, data, behavior) { console.log('Notification sent for ' + notificationId); if (Notification.permission !== 'granted') { console.log('email: notification skipped, permission: ' + Notification.permission); return; } data = data || {}; //TODO: consider setting dir and lang? //https://developer.mozilla.org/en-US/docs/Web/API/notification var notificationOptions = { bodyL10n: bodyL10n, icon: iconUrl, tag: notificationId, data: data, mozbehavior: { noscreen: true } }; if (behavior) { Object.keys(behavior).forEach(function(key) { notificationOptions.mozbehavior[key] = behavior[key]; }); } notificationHelper.send(titleL10n, notificationOptions) .then(function(notification){ // If the app is open, but in the background, when the notification // comes in, then we do not get notifived via our // mozSetMessageHandler that is set elsewhere. Instead need to // listen to click event and synthesize an "event" ourselves. notification.onclick = function() { evt.emit('notification', { clicked: true, imageURL: iconUrl, tag: notificationId, data: data }); }; }); }; } api.oncronsyncstart = function(accountIds) { console.log('email oncronsyncstart: ' + accountIds); cronSyncStartTime = Date.now(); var accountKey = makeAccountKey(accountIds); waitingOnCron[accountKey] = true; }; /** * Fetches notification data for the notification type, ntype. This method * assumes there is only one ntype of notification per account. * @param {String} ntype The notification type, like 'sync'. * @return {Promise} Promise that resolves to a an object whose keys * are account IDs and values are notification data. */ function fetchNotificationsData(ntype) { if (typeof Notification !== 'function' || !Notification.get) { return Promise.resolve({}); } return Notification.get().then(function(notifications) { var result = {}; notifications.forEach(function(notification) { var data = notification.data; // Want to avoid unexpected data formats. So if not a version match // then just close it since it cannot be processed as expected. This // means that notifications not generated by this module may be // closed. However, ideally only this module generates notifications, // for localization of concerns. if (!data.v || data.v !== notificationDataVersion) { notification.close(); } else if (data.ntype === ntype) { data.notification = notification; result[data.accountId] = data; } }); return result; }, function(err) { // Do not care about errors, just log and keep going. console.error('email notification.get call failed: ' + err); return {}; }); } /** * Helper to just get some environment data for dealing with sync-based * notfication data. Exists to reduce the curly brace pyramid of doom and * to normalize existing sync notification info. * @param {Function} fn function to call once env info is fetched. */ function getSyncEnv(fn) { appSelf.latest('self', function(app) { model.latestOnce('account', function(currentAccount) { fetchNotificationsData('sync').then( function(existingNotificationsData) { mozL10n.formatValue('senders-separation-sign') .then(function(separator) { var localized = { separator }; mozL10n.formatValue('notification-no-subject') .then(function(noSubject) { localized.noSubject = noSubject; fn(app, currentAccount, existingNotificationsData, localized); }); }); }); }); }); } /** * Generates a list of unique top names sorted by most recent sender first, * and limited to a max number. The max number is just to limit amount of * work and likely display limits. * @param {Array} latestInfos array of result.latestMessageInfos. Modifies * result.latestMessageInfos via a sort. * @param {Array} oldFromNames old from names from a previous notification. * @return {Array} a maxFromList array of most recent senders. */ function topUniqueFromNames(latestInfos, oldFromNames) { var names = [], maxCount = 3; // Get the new from senders from the result. First, // need to sort by most recent. // Note that sort modifies result.latestMessageInfos latestInfos.sort(function(a, b) { return b.date - a.date; }); // Only need three unique names, and just the name, not // the full info object. latestInfos.some(function(info) { if (names.length > maxCount) { return true; } if (names.indexOf(info.from) === -1) { names.push(info.from); } }); // Now add in old names to fill out a list of // max names. oldFromNames.some(function(name) { if (names.length > maxCount) { return true; } if (names.indexOf(name) === -1) { names.push(name); } }); return names; } /* accountsResults is an object with the following structure: accountIds: array of string account IDs. updates: array of objects includes properties: id: accountId, name: account name, count: number of new messages total latestMessageInfos: array of latest message info objects, with properties: - from - subject - accountId - messageSuid */ api.oncronsyncstop = function(accountsResults) { console.log('email oncronsyncstop: ' + accountsResults.accountIds); function finishSync() { evt.emit('cronSyncStop', accountsResults.accountIds); // Mark this accountId set as no longer waiting. var accountKey = makeAccountKey(accountsResults.accountIds); waitingOnCron[accountKey] = false; var stillWaiting = Object.keys(waitingOnCron).some(function(key) { return !!waitingOnCron[key]; }); if (!hasBeenVisible && !stillWaiting) { console.log('sync completed in ' + ((Date.now() - cronSyncStartTime) / 1000) + ' seconds, closing mail app'); window.close(); } } // If no sync updates, wrap it up. if (!accountsResults.updates) { finishSync(); return; } // There are sync updates, get environment and figure out how to notify // the user of the updates. getSyncEnv(function( app, currentAccount, existingNotificationsData, localized) { var iconUrl = notificationHelper.getIconURI(app); accountsResults.updates.forEach(function(result) { // If the current account is being shown, then just send an update // to the model to indicate new messages, as the notification will // happen within the app for that case. The 'inboxShown' pathway // will be sure to close any existing notification for the current // account. if (currentAccount.id === result.id && !document.hidden) { model.notifyInboxMessages(result); return; } // If this account does not want notifications of new messages // or if no Notification object, stop doing work. if (!model.getAccount(result.id).notifyOnNew || typeof Notification !== 'function') { return; } var dataObject, subjectL10n, bodyL10n, behavior, count = result.count, oldFromNames = []; // Adjust counts/fromNames based on previous notification. var existingData = existingNotificationsData[result.id]; if (existingData) { if (existingData.count) { count += parseInt(existingData.count, 10); } if (existingData.fromNames) { oldFromNames = existingData.fromNames; } } if (count > 1) { // Multiple messages were synced. // topUniqueFromNames modifies result.latestMessageInfos var newFromNames = topUniqueFromNames(result.latestMessageInfos, oldFromNames); dataObject = { v: notificationDataVersion, ntype: 'sync', type: 'message_list', accountId: result.id, count: count, fromNames: newFromNames }; // If already have a notification, then do not bother with sound or // vibration for this update. Longer term, the notification standard // will have a "silent" option, but using a non-existent URL as // suggested in bug 1042361 in the meantime. if (existingData && existingData.count) { behavior = { soundFile: 'does-not-exist-to-simulate-silent', // Cannot use 0 since system/js/notifications.js explicitly // ignores [0] values. [1] is good enough for this purpose. vibrationPattern: [1] }; } if (model.getAccountCount() === 1) { subjectL10n = { id: 'new-emails-notify-one-account', args: { n: count } }; } else { subjectL10n = { id: 'new-emails-notify-multiple-accounts', args: { n: count, accountName: result.address } }; } bodyL10n = { raw: newFromNames.join(localized.separator) }; } else { // Only one message to notify about. var info = result.latestMessageInfos[0]; dataObject = { v: notificationDataVersion, ntype: 'sync', type: 'message_reader', accountId: info.accountId, messageSuid: info.messageSuid, count: 1, fromNames: [info.from] }; var rawSubject = info.subject || localized.noSubject; if (model.getAccountCount() === 1) { subjectL10n = { raw: rawSubject }; bodyL10n = { raw: info.from }; } else { subjectL10n = { id: 'new-emails-notify-multiple-accounts', args: { n: count, accountName: result.address } }; bodyL10n = { id: 'new-emails-notify-multiple-accounts-body', args: { from: info.from, subject: rawSubject } }; } } sendNotification( result.id, subjectL10n, bodyL10n, iconUrl, dataObject, behavior ); }); finishSync(); }); }; // Background Send Notifications var BACKGROUND_SEND_NOTIFICATION_ID = 'backgroundSendFailed'; var sentAudio = null; // Lazy-loaded when first needed /** * The API passes through background send notifications with the * following data (see the "sendOutboxMessages" job and/or * `GELAM/js/jobs/outbox.js`): * * @param {int} accountId * @param {string} suid * SUID of the message * @param {string} state * 'pending', 'syncing', 'success', or 'error' * @param {string} err * (if applicable, otherwise null) * @param {array} badAddresses * (if applicable) * @param {int} sendFailures * Count of the number of times the message failed to send. * @param {Boolean} emitNotifications * True if this message is being sent as a direct result of * the user sending a message from the compose window. False * otherwise, as in when the user "refreshes" the outbox. * @param {Boolean} willSendMore * True if we will send a subsequent message from the outbox * immediately after sending this message. * * Additionally, this function appends the following to that * structured data: * * @param {string} localizedDescription Notification text. * * If the application is in the foreground, we notify the user on * both success and failure. If the application is in the * background, we only post a system notifiaction on failure. */ api.onbackgroundsendstatus = function(data) { console.log('outbox: Message', data.suid, 'status =', JSON.stringify({ state: data.state, err: data.err, sendFailures: data.sendFailures, emitNotifications: data.emitNotifications })); // Grab an appropriate localized string here. This description // may be displayed in a number of different places, so it's // cleaner to do the localization here. var descId; switch (data.state) { case 'pending': descId = 'background-send-pending'; break; case 'sending': descId = 'background-send-sending'; break; case 'success': descId = 'background-send-success'; break; case 'error': if ((data.badAddresses && data.badAddresses.length) || data.err === 'bad-recipient') { descId = 'background-send-error-recipients'; } else { descId = 'background-send-error'; } break; case 'syncDone': // We will not display any notification for a 'syncDone' // message, except to stop refresh icons from spinning. No // need to attempt to populate a description. break; default: console.error('No state description for background send state "' + data.state + '"'); return; } // If the message sent successfuly, and we're sending this as a // side-effect of the user hitting "send" on the compose screen, // (i.e. emitNotifications is true), we may need to play a sound. if (data.state === 'success') { // Grab an up-to-date reading of the "play sound on send" // preference to decide if we're going to play a sound or not. model.latestOnce('acctsSlice', function() { var account = model.getAccount(data.accountId); if (!account) { console.error('Invalid account ID', data.accountId, 'for a background send notification.'); return; } // If email is in the background, we should still be able to // play audio due to having the 'audio-channel-notification' // permission (unless higher priority audio is playing). // TODO: As of June 2014, this behavior is still in limbo; // see the following links for relevant discussion. We may // need to follow up to ensure we get the behavior we want // (which is to play a sound when possible, even if we're in // the background). // Thread on dev-gaia: http://goo.gl/l6REZy // AUDIO_COMPETING bugs: https://bugzil.la/911238 if (account.playSoundOnSend) { if (!sentAudio) { sentAudio = new Audio('/sounds/firefox_sent.opus'); sentAudio.mozAudioChannelType = 'notification'; } sentAudio.play(); } }.bind(this)); } // If we are in the foreground, notify through the model, which // will display an in-app toast notification when appropriate. if (!document.hidden) { mozL10n.formatValue(descId).then(function(localizedDescription) { data.localizedDescription = localizedDescription; model.notifyBackgroundSendStatus(data); }); } // Otherwise, notify with a system notification in the case of // an error. By design, we don't use system-level notifications // to notify the user on success, lest they get inundated with // notifications. else if (data.state === 'error' && data.emitNotifications) { appSelf.latest('self', function(app) { var iconUrl = notificationHelper.getIconURI(app); var dataObject = { v: notificationDataVersion, ntype: 'outbox', type: 'message_reader', folderType: 'outbox', accountId: data.accountId, messageSuid: data.suid }; sendNotification( BACKGROUND_SEND_NOTIFICATION_ID, 'background-send-error-title', descId, iconUrl, dataObject ); }); } }; // When inbox is viewed, be sure to clear out any possible notification // for that account. evt.on('inboxShown', function(accountId) { fetchNotificationsData('sync').then(function(notificationsData) { if (notificationsData.hasOwnProperty(accountId)) { notificationsData[accountId].notification.close(); } }); }); };
M.on = function(evt, handler){ _evt.on(evt, handler); };