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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
155
gaia/apps/email/js/cards/account_prefs_mixins.js
Executable file
155
gaia/apps/email/js/cards/account_prefs_mixins.js
Executable file
|
|
@ -0,0 +1,155 @@
|
|||
'use strict';
|
||||
|
||||
/*jshint browser: true */
|
||||
/*global define, console, _secretDebug */
|
||||
|
||||
define(function(require) {
|
||||
var mozL10n = require('l10n!'),
|
||||
cards = require('cards');
|
||||
|
||||
/**
|
||||
* mixin properties for cards that share similar actions around the account
|
||||
* preferences.
|
||||
* ASSUMES the following properties have been initialized on the object
|
||||
* - this.account
|
||||
* - this.identity
|
||||
*/
|
||||
|
||||
return {
|
||||
// Call this in target object's constructor to wire up the common prefs.
|
||||
_bindPrefs: function(checkIntervalClassName, //sync interval select box
|
||||
notifyEmailClassName, //notify email checkbox
|
||||
soundOnSendClassName, //send sound on send checkbox
|
||||
signatureEnabledClassName,
|
||||
signatureButtonClassName) {
|
||||
|
||||
if (checkIntervalClassName) {
|
||||
// Wire up the sync interval select box.
|
||||
var checkIntervalNode = this.nodeFromClass(checkIntervalClassName),
|
||||
currentInterval = this.account.syncInterval,
|
||||
syncIntervalString = String(currentInterval),
|
||||
extraOptions = [];
|
||||
|
||||
// Allow for fast sync options set via the settings_debug
|
||||
// secret debugging screen.
|
||||
if (typeof _secretDebug !== 'undefined' && _secretDebug.fastSync) {
|
||||
extraOptions = extraOptions.concat(_secretDebug.fastSync);
|
||||
}
|
||||
|
||||
// If existing sync option is not in the set shown in the UI,
|
||||
// allow for dynamically inserting it.
|
||||
var hasOption = Array.slice(checkIntervalNode.options, 0)
|
||||
.some(function(option) {
|
||||
return syncIntervalString === option.value;
|
||||
});
|
||||
if (!hasOption && extraOptions.indexOf(currentInterval) === -1) {
|
||||
extraOptions.push(currentInterval);
|
||||
}
|
||||
|
||||
// Add any extra sync interval options.
|
||||
extraOptions.forEach(function(interval) {
|
||||
var node = document.createElement('option'),
|
||||
seconds = interval / 1000;
|
||||
|
||||
node.value = String(interval);
|
||||
mozL10n.setAttributes(node, 'settings-check-dynamic',
|
||||
{ n: seconds });
|
||||
checkIntervalNode.appendChild(node);
|
||||
});
|
||||
|
||||
checkIntervalNode.value = syncIntervalString;
|
||||
checkIntervalNode.addEventListener('change',
|
||||
this.onChangeSyncInterval.bind(this),
|
||||
false);
|
||||
}
|
||||
|
||||
if (notifyEmailClassName) {
|
||||
var notifyMailNode = this.nodeFromClass(notifyEmailClassName);
|
||||
notifyMailNode.addEventListener('click',
|
||||
this.onNotifyEmailClick.bind(this),
|
||||
false);
|
||||
notifyMailNode.checked = this.account.notifyOnNew;
|
||||
}
|
||||
|
||||
if (soundOnSendClassName) {
|
||||
var soundOnSendNode = this.nodeFromClass(soundOnSendClassName);
|
||||
soundOnSendNode.addEventListener('click',
|
||||
this.onSoundOnSendClick.bind(this),
|
||||
false);
|
||||
soundOnSendNode.checked = this.account.playSoundOnSend;
|
||||
}
|
||||
|
||||
if (signatureEnabledClassName) {
|
||||
var signatureEnabledNode =
|
||||
this.nodeFromClass(signatureEnabledClassName);
|
||||
signatureEnabledNode.addEventListener('click',
|
||||
this.onSignatureEnabledClick.bind(this),
|
||||
false);
|
||||
signatureEnabledNode.checked = !!this.identity.signatureEnabled;
|
||||
}
|
||||
|
||||
if (signatureButtonClassName) {
|
||||
this.signatureButton = this.nodeFromClass(signatureButtonClassName);
|
||||
this.updateSignatureButton();
|
||||
this.signatureButton.addEventListener('click',
|
||||
this.onClickSignature.bind(this), false);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
nodeFromClass: function(className) {
|
||||
return this.getElementsByClassName(className)[0];
|
||||
},
|
||||
|
||||
onChangeSyncInterval: function(event) {
|
||||
var value = parseInt(event.target.value, 10);
|
||||
console.log('sync interval changed to', value);
|
||||
this.account.modifyAccount({ syncInterval: value });
|
||||
},
|
||||
|
||||
onNotifyEmailClick: function(event) {
|
||||
var checked = event.target.checked;
|
||||
console.log('notifyOnNew changed to: ' + checked);
|
||||
this.account.modifyAccount({ notifyOnNew: checked });
|
||||
},
|
||||
|
||||
onSoundOnSendClick: function(event) {
|
||||
var checked = event.target.checked;
|
||||
console.log('playSoundOnSend changed to: ' + checked);
|
||||
this.account.modifyAccount({ playSoundOnSend: checked });
|
||||
},
|
||||
|
||||
onSignatureEnabledClick: function(event) {
|
||||
var checked = event.target.checked;
|
||||
console.log('signatureEnabled changed to: ' + checked);
|
||||
this.identity.modifyIdentity({ signatureEnabled: checked });
|
||||
},
|
||||
|
||||
updateSignatureButton: function() {
|
||||
// Allow the text to be just whitespace, but treat it as
|
||||
// empty as far as labeling is concerned.
|
||||
var text = this.identity.signature || '',
|
||||
isEmpty = text.trim().length === 0,
|
||||
node = this.signatureButton.firstElementChild;
|
||||
|
||||
node.textContent = text;
|
||||
node.classList.toggle('empty-placeholder', isEmpty);
|
||||
|
||||
if (isEmpty) {
|
||||
mozL10n.setAttributes(node, 'settings-empty-signature-label');
|
||||
} else {
|
||||
node.removeAttribute('data-l10n-id');
|
||||
}
|
||||
},
|
||||
|
||||
onClickSignature: function(index) {
|
||||
cards.pushCard(
|
||||
'settings_signature', 'animate',
|
||||
{
|
||||
account: this.account,
|
||||
index: index
|
||||
},
|
||||
'right');
|
||||
}
|
||||
};
|
||||
});
|
||||
66
gaia/apps/email/js/cards/base.js
Executable file
66
gaia/apps/email/js/cards/base.js
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var Emitter = require('evt').Emitter;
|
||||
|
||||
/**
|
||||
* Returns an array of objects that can be fed to the 'element' module to
|
||||
* create a prototype for a custom element. It takes an optional
|
||||
* `templateMixins` object that is the first object to be mixed in by the
|
||||
* "mixins insted of prototypes" construction that 'element' favors. This
|
||||
* templateMixins should come first, as it sets up the inner DOM structure
|
||||
* for an instance of the element, and needs to have been inserted before the
|
||||
* other mixins in this base are applied. The templateMixins are normally
|
||||
* passed to this function via a `require('template!...')` dependency. The
|
||||
* 'template' loader plugin knows how to set up an object for use in this type
|
||||
* of 'element' target. See the README.md in this file's directory for more
|
||||
* information on the custom element approach.
|
||||
* @param {Object|Array} [templateMixins] Handles the templating duties
|
||||
* for the inner HTML structure of the element.
|
||||
* @returns {Array} Array of objects for use in a mixin construction.
|
||||
*/
|
||||
return function base(templateMixins) {
|
||||
// Set up the base mixin
|
||||
return [
|
||||
// Mix in the template first, so that its createdCallback is
|
||||
// called before the other createdCallbacks, so that the
|
||||
// template is there for things like l10n mixing and node
|
||||
// binding inside the template.
|
||||
templateMixins ? templateMixins : {},
|
||||
|
||||
// Wire up support for auto-node binding
|
||||
require('./mixins/data-prop'),
|
||||
require('./mixins/data-event'),
|
||||
|
||||
// Every custom element is an evt Emitter!
|
||||
Emitter.prototype,
|
||||
|
||||
{
|
||||
createdCallback: function() {
|
||||
// Mark the email custom elements that are generated in this fashion
|
||||
// with a specific class. This allows much more efficient query
|
||||
// selector calls to grab them all vs using something like '*' and
|
||||
// then filtering out the custom elements based on nodeName.
|
||||
this.classList.add('email-ce');
|
||||
|
||||
Emitter.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Shortcut for triggering a DOM custom event with a detail object. Use
|
||||
* this instead of evt when dealing with a custom element that wants to
|
||||
* communicate with ancestor elements about an event (so therefore could
|
||||
* bubble) that was based on a plain DOM event that happened inside the
|
||||
* custom element.
|
||||
*
|
||||
* @param {String} eventName The event name
|
||||
* @param {Object} detail The state info passed in event.detail
|
||||
*/
|
||||
emitDomEvent: function(eventName, detail) {
|
||||
this.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: detail
|
||||
}));
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
});
|
||||
64
gaia/apps/email/js/cards/base_card.js
Executable file
64
gaia/apps/email/js/cards/base_card.js
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var date = require('date'),
|
||||
mozL10n = require('l10n!'),
|
||||
base = require('./base');
|
||||
|
||||
// Set up the global time updates for all nodes.
|
||||
(function() {
|
||||
var updatePrettyDate = function updatePrettyDate() {
|
||||
var labels = [...document.querySelectorAll('[data-time]')];
|
||||
labels.forEach(label => {
|
||||
date.relativeDateElement(label, label.dataset.time);
|
||||
});
|
||||
};
|
||||
var timer = setInterval(updatePrettyDate, 60 * 1000);
|
||||
|
||||
function updatePrettyDateOnEvent() {
|
||||
clearInterval(timer);
|
||||
updatePrettyDate();
|
||||
timer = setInterval(updatePrettyDate, 60 * 1000);
|
||||
}
|
||||
// When user changes the language, update timestamps.
|
||||
mozL10n.ready(updatePrettyDateOnEvent);
|
||||
|
||||
// On visibility change to not hidden, update timestamps
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document && !document.hidden) {
|
||||
updatePrettyDateOnEvent();
|
||||
} else {
|
||||
// If not visible, clear the interval to be a battery good citizen.
|
||||
clearInterval(timer);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
return function baseCard(templateMixins) {
|
||||
// Set up the base mixin
|
||||
return [
|
||||
base(templateMixins),
|
||||
{
|
||||
createdCallback: function() {
|
||||
// Set up extra classes and other node information that distinguishes
|
||||
// as a card. Doing this here so that by the time the createdCallback
|
||||
// provided by the card so that the DOM at that point can be used for
|
||||
// HTML caching purposes.
|
||||
if (this.extraClasses) {
|
||||
this.classList.add.apply(this.classList,
|
||||
this.extraClasses);
|
||||
}
|
||||
|
||||
this.classList.add('card');
|
||||
},
|
||||
|
||||
batchAddClass: function(searchClass, classToAdd) {
|
||||
var nodes = this.getElementsByClassName(searchClass);
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
nodes[i].classList.add(classToAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
});
|
||||
9
gaia/apps/email/js/cards/cmp/attachment_item.html
Executable file
9
gaia/apps/email/js/cards/cmp/attachment_item.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<li class="cmp-attachment-item">
|
||||
<span class="cmp-attachment-icon" role="presentation"></span>
|
||||
<span class="cmp-attachment-fileinfo">
|
||||
<span dir="auto" class="cmp-attachment-filename"></span>
|
||||
<span class="cmp-attachment-filesize"></span>
|
||||
</span>
|
||||
<span class="cmp-attachment-remove" role="button"
|
||||
data-l10n-id="compose-attachment-remove"></span>
|
||||
</li>
|
||||
542
gaia/apps/email/js/cards/cmp/autocomplete.js
Executable file
542
gaia/apps/email/js/cards/cmp/autocomplete.js
Executable file
|
|
@ -0,0 +1,542 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var GestureDetector = require('shared/js/gesture_detector'),
|
||||
itemTemplate = require('tmpl!./autocomplete_item.html'),
|
||||
mozL10n = require('l10n!'),
|
||||
regExpEscape = require('regExpEscape'),
|
||||
transitionEnd = require('transition_end'),
|
||||
validTypes = {
|
||||
email: true,
|
||||
text: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a text string into a span and some text nodes that are placed
|
||||
* as the children of node. The span contains the match for the queryRegExp.
|
||||
* @param {RegExp} queryRegExp The regexp whose match is highlighted via a
|
||||
* span in the target text.
|
||||
* @param {String} text The target text to find the match.
|
||||
* @param {Element} node The element that will display the matched text.
|
||||
*/
|
||||
function formatMatchString(queryRegExp, text, node) {
|
||||
// Just highlight the first match.
|
||||
var match = queryRegExp.exec(text);
|
||||
|
||||
if (match) {
|
||||
var index = match.index,
|
||||
endIndex = index + queryRegExp.source.length;
|
||||
|
||||
var startString = text.substring(0, index);
|
||||
if (startString) {
|
||||
node.appendChild(document.createTextNode(startString));
|
||||
}
|
||||
|
||||
var matchString = text.substring(index, endIndex);
|
||||
if (matchString) {
|
||||
var span = document.createElement('span');
|
||||
span.classList.add('highlight');
|
||||
span.textContent = matchString;
|
||||
node.appendChild(span);
|
||||
}
|
||||
|
||||
var endString = text.substring(endIndex);
|
||||
if (endString) {
|
||||
node.appendChild(document.createTextNode(endString));
|
||||
}
|
||||
} else {
|
||||
node.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a node, if it or an ancestor has the CSS className, return the node
|
||||
* with that className. Returns null if not a match.
|
||||
* @param {Node} node
|
||||
* @return {Node}
|
||||
*/
|
||||
function findAncestorWithClass(node, className) {
|
||||
for (; node; node = node.parentNode) {
|
||||
if (node.classList && node.classList.contains(className)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a node, if it or an ancestor is an autocomplete item in the
|
||||
* autocomplete list, return the autcomplete item node. Returns null if not a
|
||||
* match.
|
||||
* @param {Node} node
|
||||
* @return {Node}
|
||||
*/
|
||||
function getAutocompleteItem(node) {
|
||||
return findAncestorWithClass(node, 'cmp-autocomplete-item');
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete custom element that contains a list of elements that show the
|
||||
* autocomplete matches.
|
||||
*
|
||||
* This custom element relies on a CSS class-based API to bind to nodes outside
|
||||
* of this autocomplete. The CSS/HTML structure expected by this autocomplete:
|
||||
*
|
||||
* - An element with the CSS class of "cmp-autocomplete-origin" that is the the
|
||||
* master scroll area for the view. It should be an ancestor node to the
|
||||
* autocomplete and also contain the 'cmp-autocomplete-input-list' element that
|
||||
* has all the input elements. This element is used as the "origin" for the
|
||||
* style.top placement of the autocomplete list.
|
||||
*
|
||||
* - An element that holds all the input fields, with the CSS class of
|
||||
* "cmp-autocomplete-input-list". That inputArea will be translateY'd to make
|
||||
* sure the current input focus is visible at the top of the view.
|
||||
*
|
||||
* - Individual rows that hold an autocomplete must specify a
|
||||
* "cmp-autocomplete-input-label" CSS class, so that for a given input, that
|
||||
* parent can be found to be marked as "active". This allows just that row to
|
||||
* change its alignment so its label and + icon are aligned at the bottom with
|
||||
* the text input.
|
||||
*
|
||||
* The autocomplete custom element also expects a "data-source" HTML attribute
|
||||
* to be set on it, which indicates the module ID to use as the data source for
|
||||
* the matches.
|
||||
*
|
||||
* The owner of this custom element instance (typically a view card), should
|
||||
* override the getExistingEntries() method on the instance to return the
|
||||
* current entries, to avoid dupes showing up in subsequent autocomplete
|
||||
* matches.
|
||||
*/
|
||||
return [
|
||||
require('../base')(),
|
||||
{
|
||||
createdCallback: function() {
|
||||
this.lastTranslateY = 0;
|
||||
this.inputAreaTranslated = Promise.resolve();
|
||||
|
||||
this.runQuery = this.runQuery.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onDocumentEvent = this.onDocumentEvent.bind(this);
|
||||
this.onInput = this.onInput.bind(this);
|
||||
|
||||
// Find (when cached HTML) or create list to hold the autocomplete matches
|
||||
// Put the list inside the autocomplete element instead of off the
|
||||
// document, as it should be within a given UI card, in case in-app
|
||||
// overlay UIs like confirm dialogs need to be shown.
|
||||
this.list = this.querySelector('.cmp-autocomplete-list');
|
||||
if (!this.list) {
|
||||
this.list = document.createElement('ul');
|
||||
this.list.classList.add('cmp-autocomplete-list');
|
||||
this.appendChild(this.list);
|
||||
}
|
||||
this.list.addEventListener('keydown', this.onMatchKeyDown.bind(this));
|
||||
this.list.addEventListener('click', this.onMatchClick.bind(this));
|
||||
this.list.innerHTML = '';
|
||||
this.hideList();
|
||||
|
||||
// Find parent that is scrollable and is the origin for measurements.
|
||||
var parentNode = this;
|
||||
while((parentNode = parentNode.parentNode)) {
|
||||
if (parentNode.classList.contains('cmp-autocomplete-origin')) {
|
||||
this.originNode = parentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Hold on to the element that holds all the input fields, to use it for
|
||||
// sizing and setting scroll behavior amongst the inputs while the
|
||||
// autocomplete is active.
|
||||
this.inputArea = this.originNode
|
||||
.querySelector('.cmp-autocomplete-input-list');
|
||||
|
||||
// Listen for transitionend events on the inputArea, since translateY with
|
||||
// a transition is used to move the input to the top of the view. Want to
|
||||
// wait for the transition to end before showing autocomplete results.
|
||||
// This allows the user to better track where the addresses have gone,
|
||||
// and feels smoother.
|
||||
transitionEnd(this.inputArea, this._translateYTransitionEnd.bind(this));
|
||||
|
||||
// Bind to events triggered by input field children. Need to mark the
|
||||
// elements with an autocomplete class, and do not want to target just
|
||||
// any input in the inputArea, so using explicit per-element bindings
|
||||
// instead of event delegation.
|
||||
var nodes = this.inputArea.querySelectorAll('input');
|
||||
Array.from(nodes, function(inputNode) {
|
||||
if (!validTypes.hasOwnProperty(inputNode.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a class to the input to mark it as participating in autocomplete.
|
||||
// This is used to know if taps in them should be disregarded for
|
||||
// onDocumentEvent purposes.
|
||||
inputNode.classList.add('cmp-autocomplete-input');
|
||||
|
||||
inputNode.addEventListener('focus', this.onFocus);
|
||||
inputNode.addEventListener('input', this.onInput);
|
||||
}, this);
|
||||
|
||||
// Use a GestureDetector to know when user tries to scroll the inputArea
|
||||
// element to see addresses that have been scrolled out of the way for
|
||||
// autocomplete maximum real estate. No need to clean this up later, the
|
||||
// destruction of the card using the autocomplete is enough: the watched
|
||||
// element is destroyed along with the autocomplete.
|
||||
this.detector = new GestureDetector(this.inputArea);
|
||||
this.inputArea.addEventListener('pan', (event) => {
|
||||
this.hide();
|
||||
});
|
||||
this.detector.startDetecting();
|
||||
|
||||
// Set up data source.
|
||||
var dataSourceId = this.dataset.source;
|
||||
if (dataSourceId) {
|
||||
this.bindDataSource(dataSourceId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom element callback when element is placed in the document.
|
||||
*/
|
||||
attachedCallback: function() {
|
||||
this.onResize = this.onResize.bind(this);
|
||||
window.addEventListener('resize', this.onResize, false);
|
||||
|
||||
// Explicitly want to listen during the capture phase, since most code
|
||||
// listens in bubble phase, and may stopPropagation. For autocomplete
|
||||
// hiding though we always want it to disappear if event is outside of
|
||||
// the interesting areas.
|
||||
document.addEventListener('click', this.onDocumentEvent, true);
|
||||
document.addEventListener('focus', this.onDocumentEvent, true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Custom element callback when element is removed from the document.
|
||||
*/
|
||||
detachedCallback: function() {
|
||||
window.removeEventListener('resize', this.onResize, false);
|
||||
document.removeEventListener('click', this.onDocumentClick, true);
|
||||
document.addEventListener('focus', this.onDocumentEvent, true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered by input focus events.
|
||||
*/
|
||||
onFocus: function(event) {
|
||||
this.inputNode = event.target;
|
||||
|
||||
// If existing label node, be sure to remove the active class from it. In
|
||||
// cases where the focus goes from one input to the other, hide() does not
|
||||
// run to clear this state.
|
||||
if (this.inputLabelNode) {
|
||||
this.inputLabelNode.classList.remove('cmp-autocomplete-active');
|
||||
}
|
||||
|
||||
this.inputLabelNode = findAncestorWithClass(this.inputNode,
|
||||
'cmp-autocomplete-input-label');
|
||||
this.inputLabelNode.classList.add('cmp-autocomplete-active');
|
||||
|
||||
this.query = this.inputNode.value.trim();
|
||||
this.runThrottledQuery();
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered by any click or focus in the document. Used to determine if the
|
||||
* autocomplete should be hidden.
|
||||
*/
|
||||
onDocumentEvent: function(event) {
|
||||
var target = event.target;
|
||||
|
||||
// If no classList, then it is not an element, and should be discarded.
|
||||
if (!target.classList) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// If an autocomplete input, do not do anything, handled by other code in
|
||||
// this module.
|
||||
if (target.classList.contains('cmp-autocomplete-input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also ignore if it is part of an autocomplete-item.
|
||||
if (getAutocompleteItem(target)) {
|
||||
return;
|
||||
}
|
||||
this.hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggered by input element input events.
|
||||
*/
|
||||
onInput: function(event) {
|
||||
this.query = this.inputNode.value.trim();
|
||||
this.runThrottledQuery();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles window resizes. Could be triggered by keyboard size changes.
|
||||
*/
|
||||
onResize: function(event) {
|
||||
if (this.inputNode && !this.isHidden) {
|
||||
this.positionElements();
|
||||
}
|
||||
},
|
||||
|
||||
onMatchKeyDown: function(event) {
|
||||
if (event.key === 'Enter' && !this.isHidden) {
|
||||
this.onMatchClick(event);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles taps inside the autocomplete. The tap could occur outside a match
|
||||
* element, in which case means the autocomplete should just close.
|
||||
*/
|
||||
onMatchClick: function(event) {
|
||||
var node = getAutocompleteItem(event.originalTarget);
|
||||
|
||||
if (node && node.match) {
|
||||
event.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('autocompleteSelected', {
|
||||
detail: {
|
||||
match: node.match,
|
||||
inputNode: this.inputNode
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the dataSource module.
|
||||
* @param {String} dataSourceId The module ID for the data source.
|
||||
*/
|
||||
bindDataSource: function(dataSourceId) {
|
||||
require([dataSourceId], (dataSource) => {
|
||||
this.dataSource = dataSource;
|
||||
if (this.query) {
|
||||
this.runThrottledQuery();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Called to trigger finding matches from the dataSource. Set on a timeout
|
||||
* to avoid multiple fast keystrokes from triggering too many rapid queries.
|
||||
*/
|
||||
runThrottledQuery: function() {
|
||||
if (!this.query || !this.dataSource) {
|
||||
this.list.innerHTML = '';
|
||||
this.positionAndHide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.timedQueryId) {
|
||||
// The choice of delay tries to balance sending too many queries to the
|
||||
// contacts API vs giving a feeling of responsiveness for the reaction
|
||||
// of the autocomplete. This balance may be affected by the total number
|
||||
// of contacts.
|
||||
this.timedQueryId = setTimeout(this.runQuery, 350);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The owner of this autocomplete instance should set this value if they
|
||||
* have extra spacing around the input that should be maintained for better
|
||||
* visual display, and to avoid small scroll janks on initial positioning
|
||||
* of the input element if it needs to be scrolled to the top of the view.
|
||||
*/
|
||||
verticalSpace: 0,
|
||||
|
||||
/**
|
||||
* This method can be overriden by the owner of the autocomplete to return
|
||||
* existing entries already attached to the email. The result should be an
|
||||
* array of objects that have "name" and "address" properties.
|
||||
* @return {Array} Array of existing entries.
|
||||
*/
|
||||
getExistingEntries: function() {
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs the query to find matches from the dataSource.
|
||||
*/
|
||||
runQuery: function() {
|
||||
this.timedQueryId = 0;
|
||||
|
||||
this.dataSource(this.query).then((result) => {
|
||||
// Protect against old or out of date queries
|
||||
if (result.query !== this.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingEntries = this.getExistingEntries();
|
||||
|
||||
var contacts = result.contacts;
|
||||
|
||||
this.list.innerHTML = '';
|
||||
|
||||
var queryRegExp = new RegExp(regExpEscape(this.query), 'i');
|
||||
|
||||
contacts.forEach((contact) => {
|
||||
// Only care about contacts with an email address(es).
|
||||
var emails = contact.email;
|
||||
if (!emails || !emails.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Contact name could be an array, just use the first one.
|
||||
var name = contact.name;
|
||||
if (Array.isArray(name)) {
|
||||
name = name[0];
|
||||
}
|
||||
|
||||
// Emails are an array, each one should get its own entry in the
|
||||
// autocomplete.
|
||||
emails.forEach((email) => {
|
||||
var address = email.value;
|
||||
|
||||
// Do not add it if the email address is already an entry.
|
||||
var hasExisting = existingEntries.some(function(entry) {
|
||||
return entry.address === address;
|
||||
});
|
||||
if (hasExisting) {
|
||||
return;
|
||||
}
|
||||
|
||||
var node = itemTemplate.cloneNode(true),
|
||||
detailsNode = node
|
||||
.querySelector('.cmp-autocomplete-item-details'),
|
||||
nameNode = node.querySelector('.cmp-autocomplete-name'),
|
||||
emailNode = node.querySelector('.cmp-autocomplete-email');
|
||||
|
||||
// Set the ariaLabel for the match, for screen reader use only.
|
||||
mozL10n.setAttributes(detailsNode, 'compose-autocomplete-match',
|
||||
{ name: name, address: address });
|
||||
|
||||
formatMatchString(queryRegExp,
|
||||
name || address,
|
||||
nameNode);
|
||||
formatMatchString(queryRegExp, address|| '', emailNode);
|
||||
|
||||
// Store the match data on the node for ease of access when items
|
||||
// are tapped.
|
||||
var match = {
|
||||
name: name,
|
||||
address: address
|
||||
};
|
||||
node.match = match;
|
||||
|
||||
this.list.appendChild(node);
|
||||
});
|
||||
});
|
||||
|
||||
// Always reshow on a query, since each keystroke has the possibility of
|
||||
// breaking the input field to a new line.
|
||||
if (this.list.children.length) {
|
||||
this.show();
|
||||
} else {
|
||||
this.positionAndHide();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Positions the input area to the top of the view and sizes the list to
|
||||
* take up as much view space as possible.
|
||||
*/
|
||||
positionElements: function() {
|
||||
// Always calculate the originTop since the whole card that contains the
|
||||
// origin could be scrolled for some reason, even by an accident.
|
||||
var originTop = this.originNode.getBoundingClientRect().top;
|
||||
|
||||
// Want to use the input element, and not a parent of it, for placement of
|
||||
// the autocomplete, since the input element can move to different lines
|
||||
// as typing occurs, where the parent's origin would not change, and would
|
||||
// be a bad reference point to use.
|
||||
var inputRect = this.inputNode.getBoundingClientRect();
|
||||
|
||||
// Scroll the area so that the input item is now at the top of the
|
||||
// address area.
|
||||
var translateY = inputRect.top - originTop +
|
||||
this.lastTranslateY -
|
||||
this.verticalSpace;
|
||||
|
||||
if (translateY !== this.lastTranslateY) {
|
||||
this.lastTranslateY = translateY;
|
||||
|
||||
// Create a new promise to track the end of the inputArea translation.
|
||||
this.inputAreaTranslated = new Promise((resolve) => {
|
||||
// No need to track reject, only resolve/ok is followed in the
|
||||
// transitionEnd machinery.
|
||||
this._inputAreaResolve = resolve;
|
||||
});
|
||||
|
||||
this.inputArea.style.transform = 'translateY(-' + translateY + 'px)';
|
||||
}
|
||||
|
||||
return this.inputAreaTranslated.then(() => {
|
||||
this.listTop = inputRect.height + (2 * this.verticalSpace);
|
||||
this.list.style.top = this.listTop + 'px';
|
||||
this.list.style.height = (window.innerHeight - this.listTop -
|
||||
originTop) + 'px';
|
||||
|
||||
// Set the list scrollTop to the top because previous listing could have
|
||||
// had it scrolled.
|
||||
this.list.scrollTop = 0;
|
||||
});
|
||||
},
|
||||
|
||||
_translateYTransitionEnd: function(event) {
|
||||
if (this._inputAreaResolve) {
|
||||
this._inputAreaResolve();
|
||||
this._inputAreaResolve = null;
|
||||
}
|
||||
},
|
||||
|
||||
show: function() {
|
||||
// The originNode should be at the top of its scroll area, and set a
|
||||
// CSS class on it that indicates the origin should not show scroll
|
||||
// bars or allow scrolling, to avoid overlapping, confusing double
|
||||
// scrollbars.
|
||||
this.originNode.scrollTop = 0;
|
||||
this.originNode.classList.add('cmp-autocomplete-noscroll');
|
||||
|
||||
this.positionElements().then(() => {
|
||||
this.list.classList.remove('collapsed');
|
||||
});
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
this.hideList();
|
||||
|
||||
if (this.inputLabelNode) {
|
||||
this.inputLabelNode.classList.remove('cmp-autocomplete-active');
|
||||
}
|
||||
|
||||
this.inputArea.style.transform = 'translateY(0px)';
|
||||
this.lastTranslateY = 0;
|
||||
|
||||
this.originNode.classList.remove('cmp-autocomplete-noscroll');
|
||||
},
|
||||
|
||||
hideList: function() {
|
||||
this.list.classList.add('collapsed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes sure the input element is at the top of the view, but do not show
|
||||
* the autocomplete list because there are no matches to show.
|
||||
*/
|
||||
positionAndHide: function() {
|
||||
this.positionElements().then(() => {
|
||||
this.hideList();
|
||||
});
|
||||
},
|
||||
|
||||
get isHidden() {
|
||||
return this.list.classList.contains('collapsed');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
});
|
||||
11
gaia/apps/email/js/cards/cmp/autocomplete_item.html
Executable file
11
gaia/apps/email/js/cards/cmp/autocomplete_item.html
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
<li class="cmp-autocomplete-item">
|
||||
<!-- Using an a tag so that screen reader properly activates a selection
|
||||
when tapped. Using a div meant the ul element got the click. To get proper full bleed on the active/selected color, need to use another div inside
|
||||
the a tag to get that while keeping the margins on text and border. -->
|
||||
<a class="cmp-autocomplete-item-link" href="#">
|
||||
<div class="cmp-autocomplete-item-details">
|
||||
<span class="cmp-autocomplete-name" dir="auto" aria-hidden="true"></span>
|
||||
<span class="cmp-autocomplete-email" dir="auto" aria-hidden="true"></span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
53
gaia/apps/email/js/cards/cmp/autocomplete_source.js
Executable file
53
gaia/apps/email/js/cards/cmp/autocomplete_source.js
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var contacts = navigator.mozContacts;
|
||||
|
||||
function makeResult(query, contacts) {
|
||||
return {
|
||||
query: query,
|
||||
contacts: contacts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds matches for the given query in the name and email for known people.
|
||||
* Returns a promise that resolves to an object that has the query and a
|
||||
* "match" array of mozContact entries. The match array will be a zero length
|
||||
* array for no matches. See this bug about expanding the data source beyond
|
||||
* contacts matches:
|
||||
* https://bugzilla.mozilla.org/show_bug.cgi?id=1188710
|
||||
* @param {String} query the name/email query to use.
|
||||
* @return {Promise}
|
||||
*/
|
||||
return function match(query) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
if (!contacts || !query) {
|
||||
return resolve(makeResult(query, []));
|
||||
}
|
||||
|
||||
var request = contacts.find({
|
||||
filterBy: ['email', 'name', 'givenName', 'familyName'],
|
||||
filterOp: 'startsWith',
|
||||
// Do not overwhelm the user with choice, limit the results. It is likely
|
||||
// easier for the user to keep typing to narrow the matches than scroll
|
||||
// through a big list, and a small bound on the results will result in
|
||||
// better performance, even when generating the UI for the matches.
|
||||
filterLimit: 30,
|
||||
filterValue: query
|
||||
});
|
||||
|
||||
request.onsuccess = function () {
|
||||
resolve(makeResult(query, this.result));
|
||||
};
|
||||
|
||||
// It is OK to just eat errors, no need to show them in the UI, but let
|
||||
// the log know for debugging purposes.
|
||||
request.onerror = function (err) {
|
||||
console.error('autocomplete_source contacts.find error: ' + err);
|
||||
resolve(makeResult(query, []));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
});
|
||||
13
gaia/apps/email/js/cards/cmp/contact_menu.html
Executable file
13
gaia/apps/email/js/cards/cmp/contact_menu.html
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
<form class="cmp-contact-menu" role="dialog" data-type="action">
|
||||
<header></header>
|
||||
<menu>
|
||||
<button class="cmp-contact-menu-edit" data-l10n-id="message-edit-menu-edit">
|
||||
</button>
|
||||
<button class="cmp-contact-menu-delete" data-l10n-id="message-edit-menu-delete">
|
||||
</button>
|
||||
<span class="last-button-container">
|
||||
<button class="cmp-contact-menu-cancel" data-l10n-id="message-multiedit-cancel">
|
||||
</button>
|
||||
</span>
|
||||
</menu>
|
||||
</form>
|
||||
9
gaia/apps/email/js/cards/cmp/draft_menu.html
Executable file
9
gaia/apps/email/js/cards/cmp/draft_menu.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<form role="dialog" class="cmp-draft-menu" data-type="action">
|
||||
<menu>
|
||||
<button id="cmp-draft-save" data-l10n-id="compose-draft-save"></button>
|
||||
<button id="cmp-draft-discard" class="danger" data-l10n-id="compose-delete-confirm"></button>
|
||||
<span class="last-button-container">
|
||||
<button id="cmp-draft-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
</span>
|
||||
</menu>
|
||||
</form>
|
||||
11
gaia/apps/email/js/cards/cmp/invalid_addresses.html
Executable file
11
gaia/apps/email/js/cards/cmp/invalid_addresses.html
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
<form role="dialog" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="compose-invalid-addresses-title"></h1>
|
||||
<p data-l10n-id="compose-invalid-addresses-description"></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="cmp-confirm-invalid-addresses"
|
||||
class="confirm-dialog-ok full recommend"
|
||||
data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
4
gaia/apps/email/js/cards/cmp/peep_bubble.html
Executable file
4
gaia/apps/email/js/cards/cmp/peep_bubble.html
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
<div class="cmp-peep-bubble peep-bubble" dir="auto" role="button">
|
||||
<span class="cmp-peep-name"></span>
|
||||
<span class="cmp-peep-address collapsed"></span>
|
||||
</div>
|
||||
119
gaia/apps/email/js/cards/compose.html
Executable file
119
gaia/apps/email/js/cards/compose.html
Executable file
|
|
@ -0,0 +1,119 @@
|
|||
<section class="cmp-compose-header" role="region" data-statuscolor="default">
|
||||
<header data-prop="headerNode">
|
||||
<a role="button" data-event="click:onBack" href="#" class="cmp-back-btn"
|
||||
data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<menu type="toolbar">
|
||||
<a role="button" href="#" data-event="click:onAttachmentAdd"
|
||||
class="cmp-attachment-btn" data-l10n-id="compose-attachment-button">
|
||||
<span class="icon icon-attachment"></span>
|
||||
</a>
|
||||
<a role="button" data-prop="sendButton" data-event="click:onSend"
|
||||
href="#" class="cmp-send-btn" data-l10n-id="compose-send-button">
|
||||
<span class="icon icon-send"></span>
|
||||
</a>
|
||||
</menu>
|
||||
<h1 class="cmp-compose-header-label"
|
||||
data-l10n-id="compose-new-header"></h1>
|
||||
</header>
|
||||
</section>
|
||||
<div data-prop="scrollContainer"
|
||||
class="scrollregion-below-header cmp-autocomplete-origin">
|
||||
<div data-prop="addrBar">
|
||||
<div class="cmp-autocomplete-input-container">
|
||||
<div class="cmp-autocomplete-input-list">
|
||||
<div data-prop="firstEnvelopeLine"
|
||||
data-event="click:onContainerClick"
|
||||
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
|
||||
role="group" data-l10n-id="to-group">
|
||||
<span class="cmp-to-label cmp-addr-label"
|
||||
data-l10n-id="compose-to" aria-hidden="true"></span>
|
||||
<div class="cmp-to-container cmp-addr-container">
|
||||
<div class="cmp-bubble-container">
|
||||
<input data-prop="toNode"
|
||||
data-event="keydown:onAddressKeydown,input:onAddressInput"
|
||||
dir="auto"
|
||||
class="cmp-to-text cmp-addr-text" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-event="click:onContactAdd"
|
||||
class="cmp-to-add cmp-contact-add"
|
||||
role="button"
|
||||
data-l10n-id="compose-contact-add-recipient"></div>
|
||||
</div>
|
||||
<!-- XXX: spec calls for showing cc/bcc merged until selected,
|
||||
but there is also the case where replying itself might need
|
||||
to expand, so we are deferring that feature -->
|
||||
<div data-event="click:onContainerClick"
|
||||
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
|
||||
role="group" data-l10n-id="cc-group">
|
||||
<span class="cmp-cc-label cmp-addr-label"
|
||||
data-l10n-id="compose-cc" aria-hidden="true"></span>
|
||||
<div class="cmp-cc-container cmp-addr-container">
|
||||
<div class="cmp-bubble-container">
|
||||
<input data-prop="ccNode"
|
||||
data-event="keydown:onAddressKeydown,input:onAddressInput"
|
||||
dir="auto"
|
||||
class="cmp-cc-text cmp-addr-text" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-event="click:onContactAdd"
|
||||
class="cmp-cc-add cmp-contact-add"
|
||||
role="button"
|
||||
data-l10n-id="compose-contact-add-recipient"></div>
|
||||
</div>
|
||||
<div data-event="click:onContainerClick"
|
||||
class="cmp-envelope-line cmp-combo cmp-autocomplete-input-label"
|
||||
role="group" data-l10n-id="bcc-group">
|
||||
<span class="cmp-bcc-label cmp-addr-label"
|
||||
data-l10n-id="compose-bcc" aria-hidden="true"></span>
|
||||
<div class="cmp-bcc-container cmp-addr-container">
|
||||
<div class="cmp-bubble-container">
|
||||
<input data-prop="bccNode"
|
||||
data-event="keydown:onAddressKeydown,input:onAddressInput"
|
||||
dir="auto"
|
||||
class="cmp-bcc-text cmp-addr-text" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-event="click:onContactAdd"
|
||||
class="cmp-bcc-add cmp-contact-add"
|
||||
role="button"
|
||||
data-l10n-id="compose-contact-add-recipient"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cmp-envelope-line cmp-subject">
|
||||
<span class="cmp-subject-label"
|
||||
data-l10n-id="compose-subject" aria-hidden="true"></span>
|
||||
<input data-prop="subjectNode"
|
||||
dir="auto"
|
||||
class="cmp-subject-text" type="text" data-l10n-id="subject" />
|
||||
</div>
|
||||
<div data-prop="errorMessage" class="cmp-error-message collapsed"></div>
|
||||
<div data-prop="attachmentTotal"
|
||||
class="cmp-envelope-line cmp-attachment-total collapsed"
|
||||
aria-hidden="true">
|
||||
<span data-prop="attachmentLabel"
|
||||
class="cmp-attachment-label cmp-addr-label"
|
||||
data-l10n-id="compose-attachments[zero]"></span>
|
||||
<span data-prop="attachmentsSize" class="cmp-attachment-size"></span>
|
||||
</div>
|
||||
<ul data-prop="attachmentsContainer" class="cmp-attachment-container">
|
||||
</ul>
|
||||
</div>
|
||||
<div data-prop="textBodyNode" class="cmp-body-text" contenteditable="true" role="textbox" aria-multiline="true"
|
||||
data-l10n-id="compose-text"></div>
|
||||
<div data-prop="htmlBodyContainer"
|
||||
data-event="click:_focusEditorWithCursorAtEnd"
|
||||
dir="auto"
|
||||
class="cmp-body-html"
|
||||
data-l10n-id="message-body-container">
|
||||
</div>
|
||||
<!-- Autocomplete at the bottom since it should have the highest z order
|
||||
placement, so that the autocomplete matches are visible. -->
|
||||
<cmp-autocomplete data-prop="autocomplete"
|
||||
data-event="autocompleteSelected"
|
||||
data-source="cards/cmp/autocomplete_source">
|
||||
</cmp-autocomplete>
|
||||
</div>
|
||||
1133
gaia/apps/email/js/cards/compose.js
Executable file
1133
gaia/apps/email/js/cards/compose.js
Executable file
File diff suppressed because it is too large
Load diff
13
gaia/apps/email/js/cards/confirm_dialog.html
Executable file
13
gaia/apps/email/js/cards/confirm_dialog.html
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
<div class="card-confirm-dialog card">
|
||||
<form data-statuscolor="background"
|
||||
role="dialog" data-type="confirm" class="collapsed confirm-dialog-form">
|
||||
<section>
|
||||
<h1 data-l10n-id="confirm-dialog-title"></h1>
|
||||
<p class="confirm-dialog-message"></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button class="confirm-dialog-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
<button class="confirm-dialog-ok recommend" data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
</div>
|
||||
67
gaia/apps/email/js/cards/confirm_dialog.js
Executable file
67
gaia/apps/email/js/cards/confirm_dialog.js
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./confirm_dialog.html')),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
var dialogBodyNode = args.dialogBodyNode,
|
||||
confirm = args.confirm,
|
||||
cancel = args.cancel,
|
||||
callback = args.callback;
|
||||
|
||||
if (dialogBodyNode) {
|
||||
this.appendChild(dialogBodyNode);
|
||||
} else {
|
||||
// If no dialogBodyNode passed in, use the default form display, and
|
||||
// configure the confirm/cancel hand, for the simple way of handling
|
||||
// confirm dialogs.
|
||||
dialogBodyNode = this.querySelector('.confirm-dialog-form');
|
||||
|
||||
dialogBodyNode.querySelector('.confirm-dialog-message')
|
||||
.textContent = args.message;
|
||||
|
||||
dialogBodyNode.classList.remove('collapsed');
|
||||
|
||||
confirm = {
|
||||
handler: function() {
|
||||
callback(true);
|
||||
}
|
||||
};
|
||||
cancel = {
|
||||
handler: function() {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wire up the event handling
|
||||
dialogBodyNode.addEventListener('submit', function(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
this.hide();
|
||||
|
||||
var target = evt.explicitOriginalTarget,
|
||||
targetId = target.id,
|
||||
isOk = target.classList.contains('confirm-dialog-ok'),
|
||||
isCancel = target.classList.contains('confirm-dialog-cancel');
|
||||
|
||||
if ((isOk || targetId === confirm.id) && confirm.handler) {
|
||||
confirm.handler();
|
||||
} else if ((isCancel || targetId === cancel.id) && cancel.handler) {
|
||||
cancel.handler();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
cards.removeCardAndSuccessors(this, 'immediate', 1, null, true);
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
75
gaia/apps/email/js/cards/editor_mixins.js
Executable file
75
gaia/apps/email/js/cards/editor_mixins.js
Executable file
|
|
@ -0,0 +1,75 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require) {
|
||||
|
||||
return {
|
||||
|
||||
_bindEditor: function(textNode) {
|
||||
this._editorNode = textNode;
|
||||
|
||||
// Prevent pastes of rich HTML, since it has encoding, security and
|
||||
// sanitation issues.
|
||||
textNode.addEventListener('paste', function(event) {
|
||||
event.preventDefault();
|
||||
var text = event.clipboardData.getData('text/plain');
|
||||
|
||||
// Only insert if text. If no text, the execCommand fails with an
|
||||
// error.
|
||||
if (text) {
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Inserts an email into the contenteditable element
|
||||
*/
|
||||
populateEditor: function(value) {
|
||||
var lines = value.split('\n');
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = 0, len = lines.length; i < len; i++) {
|
||||
if (i) {
|
||||
frag.appendChild(document.createElement('br'));
|
||||
}
|
||||
|
||||
if (lines[i]) {
|
||||
frag.appendChild(document.createTextNode(lines[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least one text node for tapping and keyboard display to work.
|
||||
if (!frag.childNodes.length) {
|
||||
frag.appendChild(document.createTextNode(''));
|
||||
}
|
||||
|
||||
this._editorNode.appendChild(frag);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the raw value from a contenteditable div
|
||||
*/
|
||||
fromEditor: function(value) {
|
||||
var content = '';
|
||||
var len = this._editorNode.childNodes.length;
|
||||
for (var i = 0; i < len; i++) {
|
||||
var node = this._editorNode.childNodes[i];
|
||||
if (node.nodeName === 'BR' &&
|
||||
// Gecko's contenteditable implementation likes to create a
|
||||
// synthetic trailing BR with type="_moz". We do not like/need
|
||||
// this synthetic BR, so we filter it out. Check out
|
||||
// nsTextEditRules::CreateTrailingBRIfNeeded to find out where it
|
||||
// comes from.
|
||||
node.getAttribute('type') !== '_moz') {
|
||||
content += '\n';
|
||||
} else {
|
||||
content += node.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
});
|
||||
4
gaia/apps/email/js/cards/fld/account_item.html
Executable file
4
gaia/apps/email/js/cards/fld/account_item.html
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
<a class="fld-account-item">
|
||||
<span class="selected-indicator"></span>
|
||||
<span class="fld-account-name"></span>
|
||||
</a>
|
||||
5
gaia/apps/email/js/cards/fld/folder_item.html
Executable file
5
gaia/apps/email/js/cards/fld/folder_item.html
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
<a class="fld-folder-item">
|
||||
<span class="selected-indicator"></span>
|
||||
<span dir="auto" class="fld-folder-name"></span>
|
||||
<span class="fld-folder-unread"></span>
|
||||
</a>
|
||||
71
gaia/apps/email/js/cards/folder_picker.html
Executable file
71
gaia/apps/email/js/cards/folder_picker.html
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
<!-- Our card does not have a header of its own; it reuses the message_list's
|
||||
header. We want to steal clicks on its menu button too, hence this node
|
||||
and fld-header-back which is a transparent button that overlays the menu
|
||||
button. -->
|
||||
<div class="fld-header-placeholder" data-statuscolor="default">
|
||||
<!-- Unlike a generic back button that navigates to a different screen, folder
|
||||
picker header button triggers the folders and settings overlay closure.
|
||||
Thus the screen reader user requires more context as to what activating
|
||||
the button would do. -->
|
||||
<button aria-expanded="true" aria-controls="cards-folder-picker"
|
||||
data-l10n-id="message-list-menu"
|
||||
data-event="click:_closeCard"
|
||||
class="fld-header-back"></button>
|
||||
</div>
|
||||
<!-- Backing semi-opaque layer for everything below the header so that
|
||||
anything that the folder drawer does not cover looks inactive-ish.
|
||||
Clicking on this makes the drawer go away. -->
|
||||
<div data-event="click:_closeCard" class="fld-shield"></div>
|
||||
<!-- This exists to clip fld-content so that when it animates in/out using
|
||||
translateY it doesn't paint over-top of the header. -->
|
||||
<div class="fld-content-container">
|
||||
<!-- This is the actual drawer thing that slides up/down using translateY.
|
||||
-->
|
||||
<div class="fld-content">
|
||||
<!-- Scrollable container holds everything but the non-scrolling settings
|
||||
button. -->
|
||||
<div class="fld-acct-scrollouter">
|
||||
<!-- Combo-box style widget that shows the current account name when
|
||||
collapsed and the fld-acct-header-account-header label when
|
||||
expanded. The actual items live in fld-accountlist-container
|
||||
for animation reasons; see below. -->
|
||||
<a data-prop="accountHeader"
|
||||
data-event="click:toggleAccounts"
|
||||
class="fld-acct-header closed" role="region">
|
||||
<!--
|
||||
The is on purpose, so that initial height measurement of the
|
||||
element be correct even before this element has real content.
|
||||
-->
|
||||
<span class="fld-acct-header-account-label"> </span>
|
||||
<span class="fld-acct-header-account-header"
|
||||
data-l10n-id="drawer-accounts-header"></span>
|
||||
<span class="fld-account-switch-arrow"></span>
|
||||
</a>
|
||||
<!-- Exists to clip fld-acct-container which uses translateY to hide the
|
||||
list of accounts in negative-space until the account list is
|
||||
opened. This has an explicit height style set based on whether the
|
||||
list of accounts is displayed or not because fld-acct-scrollinner
|
||||
will always have the same height for layout purposes because the
|
||||
translateY hiding does not affect layout. -->
|
||||
<div data-prop="fldAcctScrollInner" class="fld-acct-scrollinner">
|
||||
<!-- The list of all accounts and the folders for the current account.
|
||||
As noted above, translateY is used to hide the list of accounts
|
||||
when we don't want to be displaying it. -->
|
||||
<div data-prop="fldAcctContainer" class="fld-acct-container">
|
||||
<!-- The list of accounts -->
|
||||
<div data-prop="accountListContainer"
|
||||
class="fld-accountlist-container">
|
||||
</div>
|
||||
<!-- The list of folders for the current account. -->
|
||||
<div data-prop="foldersContainer"
|
||||
class="fld-folders-container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- settings button; always present; does not scroll -->
|
||||
<a data-event="click:onShowSettings" class="fld-nav-toolbar bottom-toolbar">
|
||||
<span class="fld-settings-link" data-l10n-id="drawer-settings-link"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
364
gaia/apps/email/js/cards/folder_picker.js
Executable file
364
gaia/apps/email/js/cards/folder_picker.js
Executable file
|
|
@ -0,0 +1,364 @@
|
|||
/*global define */
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var containerListen = require('container_listen'),
|
||||
fldFolderItemNode = require('tmpl!./fld/folder_item.html'),
|
||||
fldAccountItemNode = require('tmpl!./fld/account_item.html'),
|
||||
FOLDER_DEPTH_CLASSES = require('folder_depth_classes'),
|
||||
cards = require('cards'),
|
||||
evt = require('evt'),
|
||||
transitionEnd = require('transition_end');
|
||||
|
||||
require('css!style/folder_cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./folder_picker.html')),
|
||||
{
|
||||
createdCallback: function() {
|
||||
containerListen(this.foldersContainer, 'click',
|
||||
this.onClickFolder.bind(this));
|
||||
|
||||
containerListen(this.accountListContainer, 'click',
|
||||
this.onClickAccount.bind(this));
|
||||
|
||||
transitionEnd(this, this.onTransitionEnd.bind(this));
|
||||
|
||||
this.updateAccount = this.updateAccount.bind(this);
|
||||
},
|
||||
|
||||
onArgs: function(args) {
|
||||
var model = this.model = args.model;
|
||||
|
||||
model.latest('account', this.updateAccount);
|
||||
|
||||
// If more than one account, need to show the account dropdown
|
||||
var accountCount = model.getAccountCount();
|
||||
if (accountCount > 1) {
|
||||
this.classList.remove('one-account');
|
||||
// Set up size needed to handle translation animation for showing
|
||||
// accounts later.
|
||||
this.currentAccountContainerHeight = this.accountHeader
|
||||
.getBoundingClientRect().height *
|
||||
accountCount;
|
||||
this.hideAccounts();
|
||||
}
|
||||
|
||||
this.acctsSlice = model.api.viewAccounts(false);
|
||||
this.acctsSlice.onsplice = this.onAccountsSplice.bind(this);
|
||||
this.acctsSlice.onchange = this.onAccountsChange.bind(this);
|
||||
},
|
||||
|
||||
extraClasses: ['anim-vertical', 'anim-overlay', 'one-account'],
|
||||
|
||||
onShowSettings: function(event) {
|
||||
cards.pushCard('settings_main', 'animate');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clicking a different account changes the list of folders displayed. We
|
||||
* then trigger a select of the inbox for that account because otherwise
|
||||
* things get permutationally complex.
|
||||
*/
|
||||
updateAccount: function(account) {
|
||||
var oldAccount = this.curAccount;
|
||||
|
||||
this.mostRecentSyncTimestamp = 0;
|
||||
|
||||
if (oldAccount !== account) {
|
||||
this.foldersContainer.innerHTML = '';
|
||||
|
||||
this.model.latestOnce('folder', function(folder) {
|
||||
this.curAccount = account;
|
||||
|
||||
// - DOM!
|
||||
// update header
|
||||
this.querySelector('.fld-acct-header-account-label')
|
||||
.textContent = account.name;
|
||||
|
||||
// If no current folder, means this is the first startup, do some
|
||||
// work to populate the
|
||||
if (!this.curFolder) {
|
||||
this.curFolder = folder;
|
||||
}
|
||||
|
||||
// Clean up any old bindings.
|
||||
if (this.foldersSlice) {
|
||||
this.foldersSlice.onsplice = null;
|
||||
this.foldersSlice.onchange = null;
|
||||
}
|
||||
|
||||
this.foldersSlice = this.model.foldersSlice;
|
||||
|
||||
// since the slice is already populated, generate a fake notification
|
||||
this.onFoldersSplice(0, 0, this.foldersSlice.items, true, false);
|
||||
|
||||
// Listen for changes in the foldersSlice.
|
||||
// TODO: perhaps slices should implement an event listener
|
||||
// interface vs. only allowing one handler. This is slightly
|
||||
// dangerous in that other cards may access model.foldersSlice
|
||||
// and could decide to set these handlers, wiping these ones
|
||||
// out. However, so far folder_picker is the only one that cares
|
||||
// about these dynamic updates.
|
||||
this.foldersSlice.onsplice = this.onFoldersSplice.bind(this);
|
||||
}.bind(this));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Tapping a different account will jump to the inbox for that
|
||||
* account, but only do the jump if a new account selection,
|
||||
* and only after hiding the folder_picker.
|
||||
*/
|
||||
onClickAccount: function(accountNode, event) {
|
||||
var oldAccountId = this.curAccount.id,
|
||||
accountId = accountNode.account.id;
|
||||
|
||||
this.curAccount = accountNode.account;
|
||||
|
||||
if (oldAccountId !== accountId) {
|
||||
// Store the ID and wait for the closing animation to finish
|
||||
// for the card before switching accounts, so that the
|
||||
// animations are smoother and have fewer jumps.
|
||||
this._waitingAccountId = accountId;
|
||||
this._closeCard();
|
||||
}
|
||||
},
|
||||
|
||||
toggleAccounts: function() {
|
||||
// During initial setup, to get the sizes right for animation later,
|
||||
// the translateY was modified. During that time, do not want animation,
|
||||
// but now for toggling the display/hiding based on user action, enable
|
||||
// it.
|
||||
var hadAnimated = this.fldAcctContainer.classList.contains('animated');
|
||||
if (!hadAnimated) {
|
||||
this.fldAcctContainer.classList.add('animated');
|
||||
// Trigger acknowledgement of the transition by causing a reflow
|
||||
// for account container element.
|
||||
this.fldAcctContainer.clientWidth;
|
||||
}
|
||||
|
||||
if (this.accountHeader.classList.contains('closed')) {
|
||||
this.showAccounts();
|
||||
} else {
|
||||
this.hideAccounts();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Use a translateY transition to show accounts. But to do that correctly,
|
||||
* need to use the height of the account listing. The scroll inner needs
|
||||
* to be updated too, so that it does not cut off some of the folders.
|
||||
*/
|
||||
showAccounts: function() {
|
||||
var height = this.currentAccountContainerHeight;
|
||||
this.fldAcctScrollInner.style.height = (height +
|
||||
this.foldersContainer.getBoundingClientRect().height) + 'px';
|
||||
this.fldAcctContainer.style.transform = 'translateY(0)';
|
||||
|
||||
this.accountHeader.classList.remove('closed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Use a translateY transition to hide accounts. But to do that correctly,
|
||||
* need to use the height of the account listing. The scroll inner needs to
|
||||
* be updated too, so that it form-fits over the folder list.
|
||||
*/
|
||||
hideAccounts: function() {
|
||||
var foldersHeight = this.foldersContainer.getBoundingClientRect().height;
|
||||
if (foldersHeight) {
|
||||
this.fldAcctScrollInner.style.height = foldersHeight + 'px';
|
||||
}
|
||||
this.fldAcctContainer.style.transform = 'translateY(-' +
|
||||
this.currentAccountContainerHeight +
|
||||
'px)';
|
||||
|
||||
this.accountHeader.classList.add('closed');
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to populate the account list.
|
||||
*/
|
||||
onAccountsSplice: function(index, howMany, addedItems,
|
||||
requested, moreExpected) {
|
||||
var accountListContainer = this.accountListContainer;
|
||||
|
||||
// Note! We get called before the splice() is run on this.acctsSlice.items
|
||||
var postSliceCount = this.acctsSlice.items.length +
|
||||
addedItems.length - howMany;
|
||||
|
||||
this.classList.toggle('one-account', postSliceCount <= 1);
|
||||
|
||||
// Clear out accounts that have been removed
|
||||
var account;
|
||||
if (howMany) {
|
||||
for (var i = index + howMany - 1; i >= index; i--) {
|
||||
account = this.acctsSlice.items[i];
|
||||
if (account.element) {
|
||||
accountListContainer.removeChild(account.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var insertBuddy = (index >= accountListContainer.childElementCount) ?
|
||||
null : accountListContainer.children[index];
|
||||
|
||||
// Add DOM for each account
|
||||
addedItems.forEach(function(account) {
|
||||
var accountNode = account.element =
|
||||
fldAccountItemNode.cloneNode(true);
|
||||
accountNode.account = account;
|
||||
this.updateAccountDom(account, true);
|
||||
accountListContainer.insertBefore(accountNode, insertBuddy);
|
||||
}.bind(this));
|
||||
|
||||
// Use the accountHeader as a unit of height and multiple
|
||||
// by the number of children, to get total height needed for
|
||||
// all accounts. Doing this instead of measuring the height
|
||||
// for accountListContainer, since to get a good measurement
|
||||
// needs to not be display none, which could introduce a flash
|
||||
// of seeing the element.
|
||||
this.currentAccountContainerHeight = this.accountHeader
|
||||
.getBoundingClientRect().height *
|
||||
accountListContainer.children.length;
|
||||
// Recalculate heights needed to properly hide the accounts.
|
||||
this.hideAccounts();
|
||||
},
|
||||
|
||||
onAccountsChange: function(account) {
|
||||
this.updateAccountDom(account, false);
|
||||
},
|
||||
|
||||
updateAccountDom: function(account, firstTime) {
|
||||
var accountNode = account.element;
|
||||
|
||||
if (firstTime) {
|
||||
accountNode.querySelector('.fld-account-name')
|
||||
.textContent = account.name;
|
||||
|
||||
// Highlight the account currently in use
|
||||
if (this.curAccount && this.curAccount.id === account.id) {
|
||||
accountNode.classList.add('fld-account-selected');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onFoldersSplice: function(index, howMany, addedItems,
|
||||
requested, moreExpected) {
|
||||
var foldersContainer = this.foldersContainer;
|
||||
|
||||
var folder;
|
||||
if (howMany) {
|
||||
for (var i = index + howMany - 1; i >= index; i--) {
|
||||
folder = this.foldersSlice.items[i];
|
||||
foldersContainer.removeChild(folder.element);
|
||||
}
|
||||
}
|
||||
|
||||
var insertBuddy = (index >= foldersContainer.childElementCount) ?
|
||||
null : foldersContainer.children[index],
|
||||
self = this;
|
||||
addedItems.forEach(function(folder) {
|
||||
var folderNode = folder.element = fldFolderItemNode.cloneNode(true);
|
||||
folderNode.folder = folder;
|
||||
self.updateFolderDom(folder, true);
|
||||
foldersContainer.insertBefore(folderNode, insertBuddy);
|
||||
});
|
||||
},
|
||||
|
||||
updateFolderDom: function(folder, firstTime) {
|
||||
var folderNode = folder.element;
|
||||
|
||||
if (firstTime) {
|
||||
if (!folder.selectable) {
|
||||
folderNode.classList.add('fld-folder-unselectable');
|
||||
}
|
||||
|
||||
var depthIdx = Math.min(FOLDER_DEPTH_CLASSES.length - 1, folder.depth);
|
||||
folderNode.classList.add(FOLDER_DEPTH_CLASSES[depthIdx]);
|
||||
if (depthIdx > 0) {
|
||||
folderNode.classList.add('fld-folder-depthnonzero');
|
||||
}
|
||||
|
||||
folderNode.querySelector('.fld-folder-name')
|
||||
.textContent = folder.name;
|
||||
folderNode.dataset.type = folder.type;
|
||||
}
|
||||
|
||||
if (folder === this.curFolder) {
|
||||
folderNode.classList.add('fld-folder-selected');
|
||||
} else {
|
||||
folderNode.classList.remove('fld-folder-selected');
|
||||
}
|
||||
|
||||
// XXX do the unread count stuff once we have that info
|
||||
},
|
||||
|
||||
onClickFolder: function(folderNode, event) {
|
||||
var folder = folderNode.folder;
|
||||
if (!folder.selectable) {
|
||||
return;
|
||||
}
|
||||
|
||||
var oldFolder = this.curFolder;
|
||||
this.curFolder = folder;
|
||||
this.updateFolderDom(oldFolder);
|
||||
this.updateFolderDom(folder);
|
||||
|
||||
this._showFolder(folder);
|
||||
this._closeCard();
|
||||
},
|
||||
|
||||
onTransitionEnd: function(event) {
|
||||
// If this is an animation for the content closing, then
|
||||
// it means the card should be removed now.
|
||||
if (!this.classList.contains('opened') &&
|
||||
event.target.classList.contains('fld-content')) {
|
||||
cards.removeCardAndSuccessors(this, 'none');
|
||||
|
||||
// After card is removed, then switch the account, to provide
|
||||
// smooth animation on closing of drawer.
|
||||
if (this._waitingAccountId) {
|
||||
var model = this.model;
|
||||
model.changeAccountFromId(this._waitingAccountId, function() {
|
||||
model.selectInbox();
|
||||
});
|
||||
this._waitingAccountId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Closes the card. Relies on onTransitionEnd to do the
|
||||
// final close, this just sets up the closing transition.
|
||||
_closeCard: function() {
|
||||
evt.emit('folderPickerClosing');
|
||||
this.classList.remove('opened');
|
||||
},
|
||||
|
||||
/**
|
||||
* Tell the message-list to show this folder; exists for single code path.
|
||||
*/
|
||||
_showFolder: function(folder) {
|
||||
this.model.changeFolder(folder);
|
||||
},
|
||||
|
||||
/**
|
||||
* When the card is visible, start the animations to show the content
|
||||
* and fade in the tap shield.
|
||||
*/
|
||||
onCardVisible: function() {
|
||||
this.classList.add('opened');
|
||||
},
|
||||
|
||||
/**
|
||||
* Our card is going away; perform all cleanup except destroying our DOM.
|
||||
* This will enable the UI to animate away from our card without weird
|
||||
* graphical glitches.
|
||||
*/
|
||||
die: function() {
|
||||
this.acctsSlice.die();
|
||||
this.model.removeListener('account', this.updateAccount);
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
33
gaia/apps/email/js/cards/lst/default_vscroll_data.js
Executable file
33
gaia/apps/email/js/cards/lst/default_vscroll_data.js
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
// Default data used for the VScroll component, when data is not loaded yet for
|
||||
// display in the virtual scroll listing. The u2583 character is a solid bar
|
||||
// character, so a string of them gives a "redacted" look to a line. Just a
|
||||
// temporary placeholder until the real data comes in.
|
||||
define({
|
||||
'isPlaceholderData': true,
|
||||
'id': 'INVALID',
|
||||
'author': {
|
||||
'name': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583',
|
||||
'address': '',
|
||||
'contactId': null
|
||||
},
|
||||
'to': [
|
||||
{
|
||||
'name': ' ',
|
||||
'address': ' ',
|
||||
'contactId': null
|
||||
}
|
||||
],
|
||||
'cc': null,
|
||||
'bcc': null,
|
||||
'date': '0',
|
||||
'hasAttachments': false,
|
||||
'snippet': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
|
||||
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
|
||||
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583',
|
||||
'isRead': true,
|
||||
'isStarred': false,
|
||||
'sendStatus': {},
|
||||
'subject': '\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
|
||||
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583' +
|
||||
'\u2583\u2583\u2583\u2583\u2583\u2583\u2583\u2583'
|
||||
});
|
||||
190
gaia/apps/email/js/cards/lst/edit_controller.js
Executable file
190
gaia/apps/email/js/cards/lst/edit_controller.js
Executable file
|
|
@ -0,0 +1,190 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
var cards = require('cards'),
|
||||
ConfirmDialog = require('confirm_dialog'),
|
||||
deleteConfirmMsgNode = require('tmpl!../msg/delete_confirm.html'),
|
||||
mozL10n = require('l10n!'),
|
||||
toaster = require('toaster');
|
||||
|
||||
return {
|
||||
createdCallback: function() {
|
||||
this.editMode = false;
|
||||
this.selectedMessages = null;
|
||||
this.editModeEnabled = false;
|
||||
},
|
||||
|
||||
setEditMode: function(editMode) {
|
||||
// Do not bother if edit mode is not enabled yet.
|
||||
if (!this.editModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setEditMode(editMode);
|
||||
},
|
||||
|
||||
// This function is called from setEditMode() after ensuring that
|
||||
// the backend is in a state where we can safely use edit mode.
|
||||
_setEditMode: function(editMode) {
|
||||
var i;
|
||||
|
||||
this.editMode = editMode;
|
||||
|
||||
if (editMode) {
|
||||
this.classList.add('show-edit');
|
||||
|
||||
this.selectedMessages = [];
|
||||
this.updateDomEditControls();
|
||||
}
|
||||
else {
|
||||
this.classList.remove('show-edit');
|
||||
|
||||
this.selectedMessages = null;
|
||||
}
|
||||
|
||||
// Reset checked mode for all message items.
|
||||
var msgNodes = this.msgVScroll.querySelectorAll('.msg-header-item');
|
||||
for (i = 0; i < msgNodes.length; i++) {
|
||||
this.updateDomMessageChecked(msgNodes[i], false);
|
||||
}
|
||||
|
||||
if (this.editModeChanged) {
|
||||
this.editModeChanged(editMode);
|
||||
}
|
||||
},
|
||||
|
||||
// Event handler wired up in HTML template
|
||||
setEditModeStart: function() {
|
||||
this.setEditMode(true);
|
||||
},
|
||||
|
||||
// Event handler wired up in HTML template
|
||||
editHeaderClose: function() {
|
||||
this.setEditMode(false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggles the selection state of the node and therefore the stored
|
||||
* `selectedMessages` state based on the previous state of the selection.
|
||||
* The change in the total number of selections is then reflected in DOM
|
||||
* updates.
|
||||
* @param {Element} msgNode
|
||||
*/
|
||||
toggleSelection: function(msgNode) {
|
||||
var header = msgNode.message;
|
||||
var idx = this.selectedMessages.indexOf(header);
|
||||
if (idx !== -1) {
|
||||
this.selectedMessages.splice(idx, 1);
|
||||
} else {
|
||||
this.selectedMessages.push(header);
|
||||
}
|
||||
this.updateDomMessageChecked(msgNode, idx === -1);
|
||||
this.updateDomEditControls();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set or unset the select state on a node based on the edit mode.
|
||||
*/
|
||||
updateDomSelectState: function(msgNode, message) {
|
||||
if (this.editMode) {
|
||||
this.updateDomMessageChecked(msgNode,
|
||||
this.selectedMessages.indexOf(message) !== -1);
|
||||
} else {
|
||||
msgNode.removeAttribute('aria-selected');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the checked state for the message item in the list. It sets both
|
||||
* checkbox checked and aria-selected states.
|
||||
*/
|
||||
updateDomMessageChecked: function(msgNode, checked) {
|
||||
var checkbox = msgNode.querySelector('input[type=checkbox]');
|
||||
checkbox.checked = checked;
|
||||
msgNode.setAttribute('aria-selected', checked);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the edit mode UI bits sensitive to a change in the set of selected
|
||||
* messages. This means the label that says how many messages are selected,
|
||||
* whether the buttons are enabled, which of the toggle-pairs are visible.
|
||||
*/
|
||||
updateDomEditControls: function() {
|
||||
this.editHeader.updateDomHeaderCount(this.selectedMessages.length);
|
||||
this.editToolbar.updateDomEditButtons(this.selectedMessages.length > 0);
|
||||
|
||||
// Enabling/disabling rules (not UX-signed-off): Our bias is that people
|
||||
// want to star messages and mark messages unread (since it they naturally
|
||||
// end up unread), so unless messages are all in this state, we assume
|
||||
// that is the desired action.
|
||||
var numStarred = 0, numRead = 0;
|
||||
for (var i = 0; i < this.selectedMessages.length; i++) {
|
||||
var msg = this.selectedMessages[i];
|
||||
if (msg.isStarred) {
|
||||
numStarred++;
|
||||
}
|
||||
if (msg.isRead) {
|
||||
numRead++;
|
||||
}
|
||||
}
|
||||
|
||||
// Unstar if everything is starred, otherwise star
|
||||
this.setAsStarred = !(numStarred && numStarred ===
|
||||
this.selectedMessages.length);
|
||||
this.editToolbar.updateDomStartButton(this.setAsStarred);
|
||||
|
||||
// Mark read if everything is unread, otherwise unread
|
||||
this.setAsRead = (!!this.selectedMessages.length && numRead === 0);
|
||||
this.editToolbar.updateDomReadButton(numRead > 0);
|
||||
},
|
||||
|
||||
onDeleteMessages: function() {
|
||||
if (this.selectedMessages.length === 0) {
|
||||
return this.setEditMode(false);
|
||||
}
|
||||
|
||||
var dialog = deleteConfirmMsgNode.cloneNode(true);
|
||||
var content = dialog.getElementsByTagName('p')[0];
|
||||
mozL10n.setAttributes(content, 'message-multiedit-delete-confirm',
|
||||
{ n: this.selectedMessages.length });
|
||||
ConfirmDialog.show(dialog,
|
||||
{ // Confirm
|
||||
id: 'msg-delete-ok',
|
||||
handler: function() {
|
||||
var op = this.model.api.deleteMessages(this.selectedMessages);
|
||||
toaster.toastOperation(op);
|
||||
this.setEditMode(false);
|
||||
}.bind(this)
|
||||
},
|
||||
{ // Cancel
|
||||
id: 'msg-delete-cancel',
|
||||
handler: null
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
onStarMessages: function() {
|
||||
var op = this.model.api.markMessagesStarred(this.selectedMessages,
|
||||
this.setAsStarred);
|
||||
this.setEditMode(false);
|
||||
toaster.toastOperation(op);
|
||||
},
|
||||
|
||||
onMarkMessagesRead: function() {
|
||||
var op = this.model.api.markMessagesRead(this.selectedMessages,
|
||||
this.setAsRead);
|
||||
this.setEditMode(false);
|
||||
toaster.toastOperation(op);
|
||||
},
|
||||
|
||||
onMoveMessages: function() {
|
||||
cards.folderSelector(this.model, function(folder) {
|
||||
var op = this.model.api.moveMessages(this.selectedMessages, folder);
|
||||
toaster.toastOperation(op);
|
||||
this.setEditMode(false);
|
||||
}.bind(this), function(folder) {
|
||||
return folder.isValidMoveTarget;
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
12
gaia/apps/email/js/cards/lst/edit_header.html
Executable file
12
gaia/apps/email/js/cards/lst/edit_header.html
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
<section role="region">
|
||||
<header>
|
||||
<a href="#"
|
||||
data-event="click:domEvt"
|
||||
data-domevt-name="editHeaderClose"
|
||||
role="button"
|
||||
data-l10n-id="close-button">
|
||||
<span class="icon icon-close"></span>
|
||||
</a>
|
||||
<h1 data-prop="headerNode"></h1>
|
||||
</header>
|
||||
</section>
|
||||
16
gaia/apps/email/js/cards/lst/edit_header.js
Executable file
16
gaia/apps/email/js/cards/lst/edit_header.js
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
var mozL10n = require('l10n!');
|
||||
|
||||
return [
|
||||
require('../base')(require('template!./edit_header.html')),
|
||||
require('../mixins/dom_evt'),
|
||||
{
|
||||
updateDomHeaderCount: function(count) {
|
||||
mozL10n.setAttributes(this.headerNode, 'message-multiedit-header',
|
||||
{ n: count });
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
31
gaia/apps/email/js/cards/lst/edit_toolbar.html
Executable file
31
gaia/apps/email/js/cards/lst/edit_toolbar.html
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
<!-- Toolbar for multi-edit state -->
|
||||
<ul class="bb-tablist" role="toolbar">
|
||||
<li role="presentation">
|
||||
<button data-prop="deleteBtn"
|
||||
data-event="click:domEvt"
|
||||
data-domevt-name="onDeleteMessages"
|
||||
class="icon msg-delete-btn"
|
||||
data-l10n-id="message-delete-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="starBtn"
|
||||
data-event="click:domEvt"
|
||||
data-domevt-name="onStarMessages"
|
||||
class="icon msg-star-btn"
|
||||
data-l10n-id="message-star-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="readBtn"
|
||||
data-event="click:domEvt"
|
||||
data-domevt-name="onMarkMessagesRead"
|
||||
class="icon msg-mark-read-btn"
|
||||
data-l10n-id="message-mark-read-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="moveBtn"
|
||||
data-event="click:domEvt"
|
||||
data-domevt-name="onMoveMessages"
|
||||
class="icon msg-move-btn"
|
||||
data-l10n-id="message-move-button"></button>
|
||||
</li>
|
||||
</ul>
|
||||
47
gaia/apps/email/js/cards/lst/edit_toolbar.js
Executable file
47
gaia/apps/email/js/cards/lst/edit_toolbar.js
Executable file
|
|
@ -0,0 +1,47 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
var mozL10n = require('l10n!'),
|
||||
toolbarEditButtonNames = ['starBtn', 'readBtn', 'deleteBtn', 'moveBtn'];
|
||||
|
||||
return [
|
||||
require('../base')(require('template!./edit_toolbar.html')),
|
||||
require('../mixins/dom_evt'),
|
||||
{
|
||||
updateDomFolderType: function(folderType) {
|
||||
// You can't move messages in localdrafts or the outbox.
|
||||
this.moveBtn.classList.toggle('collapsed',
|
||||
folderType === 'localdrafts' ||
|
||||
folderType === 'outbox');
|
||||
// You can't flag or change the read status of messages in the outbox.
|
||||
this.starBtn.classList.toggle('collapsed',
|
||||
folderType === 'outbox');
|
||||
this.readBtn.classList.toggle('collapsed',
|
||||
folderType === 'outbox');
|
||||
},
|
||||
|
||||
// If true, show the star button, otherwise show unstar button.
|
||||
updateDomStartButton: function(isStarred) {
|
||||
mozL10n.setAttributes(this.starBtn,
|
||||
isStarred ? 'message-star-button' : 'message-unstar-button');
|
||||
},
|
||||
|
||||
// If true, some messages read, so show readBtn as marking unread.
|
||||
updateDomReadButton: function(hasUnread) {
|
||||
// Update mark read/unread button to show what action will be taken.
|
||||
this.readBtn.classList.toggle('unread', hasUnread);
|
||||
|
||||
mozL10n.setAttributes(this.readBtn, hasUnread ?
|
||||
'message-mark-unread-button' : 'message-mark-read-button');
|
||||
},
|
||||
|
||||
// Set how many messages to edit. If greater than zero, enable buttons.
|
||||
updateDomEditButtons: function(hasMessages) {
|
||||
// Update disabled state based on if there are selected messages
|
||||
toolbarEditButtonNames.forEach(function(key) {
|
||||
this[key].disabled = !hasMessages;
|
||||
}.bind(this));
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
158
gaia/apps/email/js/cards/lst/msg_click.js
Executable file
158
gaia/apps/email/js/cards/lst/msg_click.js
Executable file
|
|
@ -0,0 +1,158 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
|
||||
var cards = require('cards'),
|
||||
ConfirmDialog = require('confirm_dialog'),
|
||||
largeMsgConfirmMsgNode = require('tmpl!../msg/large_message_confirm.html');
|
||||
|
||||
/**
|
||||
* Show a warning that the given message is large.
|
||||
* Callback is called with cb(true|false) to continue.
|
||||
*/
|
||||
function showLargeMessageWarning(size, cb) {
|
||||
var dialog = largeMsgConfirmMsgNode.cloneNode(true);
|
||||
// TODO: If UX designers want the size included in the warning
|
||||
// message, add it here.
|
||||
ConfirmDialog.show(dialog,
|
||||
{ // Confirm
|
||||
id: 'msg-large-message-ok',
|
||||
handler: function() { cb(true); }
|
||||
},
|
||||
{ // Cancel
|
||||
id: 'msg-large-message-cancel',
|
||||
handler: function() { cb(false); }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A mixin for lst cards that want tapping on a message to go to a message
|
||||
* reader. Check all the `this` references below to see implicit dependencies
|
||||
* on instance state.
|
||||
* A longer range TODO is to remove or make explicit the dependencies.
|
||||
*/
|
||||
return {
|
||||
onClickMessage: function(event) {
|
||||
var messageNode = event.detail;
|
||||
|
||||
// You cannot open a message if this is the outbox and it is syncing.
|
||||
if (this.curFolder &&
|
||||
this.curFolder.type === 'outbox' && this.outboxSyncInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
var header = messageNode.message;
|
||||
|
||||
// Skip nodes that are default/placeholder ones.
|
||||
if (header && header.isPlaceholderData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If in edit mode, the clicks on message nodes are about changing the
|
||||
// selection for bulk edit actions.
|
||||
if (this.editMode) {
|
||||
this.toggleSelection(messageNode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.curFolder && this.curFolder.type === 'localdrafts') {
|
||||
var composer = header.editAsDraft(() => {
|
||||
cards.pushCard('compose', 'animate', {
|
||||
model: this.model,
|
||||
composer
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// When tapping a message in the outbox, don't open the message;
|
||||
// instead, move it to localdrafts and edit the message as a
|
||||
// draft.
|
||||
if (this.curFolder && this.curFolder.type === 'outbox') {
|
||||
// If the message is currently being sent, abort.
|
||||
if (header.sendStatus.state === 'sending') {
|
||||
return;
|
||||
}
|
||||
var draftsFolder =
|
||||
this.model.foldersSlice.getFirstFolderWithType('localdrafts');
|
||||
|
||||
console.log('outbox: Moving message to localdrafts.');
|
||||
this.model.api.moveMessages([header], draftsFolder, (moveMap) => {
|
||||
header.id = moveMap[header.id];
|
||||
console.log('outbox: Editing message in localdrafts.');
|
||||
var composer = header.editAsDraft(() => {
|
||||
cards.pushCard('compose', 'animate', {
|
||||
model: this.model,
|
||||
composer
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var model = this.model,
|
||||
headerCursor = this.headerCursor;
|
||||
|
||||
function pushMessageCard() {
|
||||
cards.pushCard(
|
||||
'message_reader', 'animate',
|
||||
{
|
||||
model,
|
||||
headerCursor,
|
||||
|
||||
// The header here may be undefined here, since the click
|
||||
// could be on a cached HTML node before the back end has
|
||||
// started up. It is OK if header is not available as the
|
||||
// message_reader knows how to wait for the back end to
|
||||
// start up to get the header value later.
|
||||
header,
|
||||
// Use the property on the HTML, since the click could be
|
||||
// from a cached HTML node and the real data object may not
|
||||
// be available yet.
|
||||
messageSuid: messageNode.dataset.id
|
||||
});
|
||||
}
|
||||
|
||||
if (header) {
|
||||
this.headerCursor.setCurrentMessage(header);
|
||||
} else if (messageNode.dataset.id) {
|
||||
// a case where header was not set yet, like clicking on a
|
||||
// html cached node, or virtual scroll item that is no
|
||||
// longer backed by a header.
|
||||
this.headerCursor.setCurrentMessageBySuid(messageNode.dataset.id);
|
||||
} else {
|
||||
// Not an interesting click, bail
|
||||
return;
|
||||
}
|
||||
|
||||
// If the message is really big, warn them before they open it.
|
||||
// Ideally we'd only warn if you're on a cell connection
|
||||
// (metered), but as of now `navigator.connection.metered` isn't
|
||||
// implemented.
|
||||
|
||||
// This number is somewhat arbitrary, based on a guess that most
|
||||
// plain-text/HTML messages will be smaller than this. If this
|
||||
// value is too small, users get warned unnecessarily. Too large
|
||||
// and they download a lot of data without knowing. Since we
|
||||
// currently assume that all network connections are metered,
|
||||
// they'll always see this if they get a large message...
|
||||
var LARGE_MESSAGE_SIZE = 1 * 1024 * 1024;
|
||||
|
||||
// watch out, header might be undefined here (that's okay, see above)
|
||||
if (header && header.bytesToDownloadForBodyDisplay > LARGE_MESSAGE_SIZE) {
|
||||
showLargeMessageWarning(
|
||||
header.bytesToDownloadForBodyDisplay, function(result) {
|
||||
if (result) {
|
||||
pushMessageCard();
|
||||
} else {
|
||||
// abort
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pushMessageCard();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
22
gaia/apps/email/js/cards/lst/msg_vscroll.html
Executable file
22
gaia/apps/email/js/cards/lst/msg_vscroll.html
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
<div data-prop="vScrollContainer" class="msg-vscroll-container"></div>
|
||||
<!-- maintain vertical space for the syncing/sync more div's
|
||||
regardless of their displayed status so we don't scroll them
|
||||
out of the way -->
|
||||
<div class="msg-messages-sync-container">
|
||||
<p data-prop="syncingNode" class="msg-messages-syncing collapsed"
|
||||
role="progressbar" data-l10n-id="messages-syncing-progressbar">
|
||||
<span data-l10n-id="messages-syncing"></span>
|
||||
</p>
|
||||
<p data-prop="syncMoreNode"
|
||||
data-event="click:onGetMoreMessages"
|
||||
class="msg-messages-sync-more collapsed"
|
||||
role="button">
|
||||
<span data-l10n-id="messages-load-more"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div data-prop="messageEmptyContainer"
|
||||
class="msg-list-empty-container collapsed">
|
||||
<p data-prop="messageEmptyText"
|
||||
class="msg-list-empty-message-text"
|
||||
data-l10n-id="messages-folder-empty"></p>
|
||||
</div>
|
||||
511
gaia/apps/email/js/cards/lst/msg_vscroll.js
Executable file
511
gaia/apps/email/js/cards/lst/msg_vscroll.js
Executable file
|
|
@ -0,0 +1,511 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
|
||||
var cards = require('cards'),
|
||||
containerListen = require('container_listen'),
|
||||
mozL10n = require('l10n!'),
|
||||
msgHeaderItemNode = require('tmpl!../msg/header_item.html'),
|
||||
toaster = require('toaster'),
|
||||
VScroll = require('vscroll');
|
||||
|
||||
var sliceEvents = ['splice', 'change', 'status', 'complete'];
|
||||
|
||||
/**
|
||||
* Minimum number of items there must be in the message slice
|
||||
* for us to attempt to limit the selection of snippets to fetch.
|
||||
*/
|
||||
var MINIMUM_ITEMS_FOR_SCROLL_CALC = 10;
|
||||
|
||||
/**
|
||||
* Maximum amount of time between issuing snippet requests.
|
||||
*/
|
||||
var MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST = 6000;
|
||||
|
||||
/**
|
||||
* Fetch up to 4kb while scrolling
|
||||
*/
|
||||
var MAXIMUM_BYTES_PER_MESSAGE_DURING_SCROLL = 4 * 1024;
|
||||
|
||||
|
||||
/**
|
||||
* Component that shows a message-based vscroll. Assumes the following are set
|
||||
* on the this component before vscroll is active:
|
||||
* - headerCursor
|
||||
*/
|
||||
return [
|
||||
require('../base')(require('template!./msg_vscroll.html')),
|
||||
{
|
||||
createdCallback: function() {
|
||||
this.setAttribute('role', 'listbox');
|
||||
this.setAttribute('aria-multiselectable', 'true');
|
||||
|
||||
mozL10n.setAttributes(this.messageEmptyText, this.dataset.emptyL10nId);
|
||||
|
||||
containerListen(this.vScrollContainer, 'click',
|
||||
this.onClickMessage.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Call this from the createdCallback of the module that wants to display
|
||||
* a message-based vscroll.
|
||||
*/
|
||||
init: function(scrollContainer, bindData, defaultVScrollData) {
|
||||
this.scrollContainer = scrollContainer;
|
||||
|
||||
// Set up the list data source for VScroll
|
||||
var listFunc = (function(index) {
|
||||
return this.headerCursor.messagesSlice.items[index];
|
||||
}.bind(this));
|
||||
|
||||
listFunc.size = (function() {
|
||||
// This method could get called during VScroll updates triggered
|
||||
// by messages_splice. However at that point, the headerCount may
|
||||
// not be correct, like when fetching more messages from the
|
||||
// server. So approximate by using the size of slice.items.
|
||||
var slice = this.headerCursor.messagesSlice;
|
||||
// coerce headerCount to 0 if it was undefined to avoid a NaN
|
||||
return Math.max(slice.headerCount || 0, slice.items.length);
|
||||
}.bind(this));
|
||||
this.listFunc = listFunc;
|
||||
|
||||
// We need to wait for the slice to complete before we can issue any
|
||||
// sensible growth requests.
|
||||
this.waitingOnChunk = true;
|
||||
this.desiredHighAbsoluteIndex = 0;
|
||||
this._needVScrollData = false;
|
||||
this.vScroll = new VScroll(
|
||||
this.vScrollContainer,
|
||||
this.scrollContainer,
|
||||
msgHeaderItemNode,
|
||||
defaultVScrollData
|
||||
);
|
||||
|
||||
// Called by VScroll wants to bind some data to a node it wants to
|
||||
// display in the DOM.
|
||||
this.vScroll.bindData = bindData;
|
||||
|
||||
// Called by VScroll when it detects it will need more data in the near
|
||||
// future. VScroll does not know if it already asked for this
|
||||
// information, so this function needs to be sure it actually needs to
|
||||
// ask for more from the back end.
|
||||
this.vScroll.prepareData = (function(highAbsoluteIndex) {
|
||||
var items = this.headerCursor.messagesSlice &&
|
||||
this.headerCursor.messagesSlice.items,
|
||||
headerCount = this.headerCursor.messagesSlice.headerCount;
|
||||
|
||||
if (!items || !headerCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure total amount stays within possible range.
|
||||
if (highAbsoluteIndex > headerCount - 1) {
|
||||
highAbsoluteIndex = headerCount - 1;
|
||||
}
|
||||
|
||||
// We're already prepared if the slice is already that big.
|
||||
if (highAbsoluteIndex < items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadNextChunk(highAbsoluteIndex);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
setHeaderCursor: function(headerCursor) {
|
||||
if (this.headerCursor) {
|
||||
throw new Error('headerCursor already set');
|
||||
}
|
||||
|
||||
this.headerCursor = headerCursor;
|
||||
|
||||
sliceEvents.forEach(function(type) {
|
||||
var name = 'messages_' + type;
|
||||
// Bind to local, but replace property name here so that removing the
|
||||
// listeners are easier in die().
|
||||
this[name] = this[name].bind(this);
|
||||
headerCursor.on(name, this[name]);
|
||||
}.bind(this));
|
||||
|
||||
this.onCurrentMessage = this.onCurrentMessage.bind(this);
|
||||
headerCursor.on('currentMessage', this.onCurrentMessage);
|
||||
|
||||
// Find the containing element that is a card, for use when asking if that
|
||||
// card is visible and extra work should be done.
|
||||
var parent = this;
|
||||
while((parent = parent.parentNode)) {
|
||||
if (parent.classList.contains('card')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.cardParent = parent;
|
||||
|
||||
this._onVScrollStopped = this._onVScrollStopped.bind(this);
|
||||
this.vScroll.on('scrollStopped', this._onVScrollStopped);
|
||||
},
|
||||
|
||||
/**
|
||||
* Used to remove the cached HTML from a cache restore of HTML. Do not use
|
||||
* this for clearing the display of messages when the list of messages
|
||||
* change, instead use methods on the vScroll object.
|
||||
*/
|
||||
removeMessagesHtml: function() {
|
||||
this.vScrollContainer.innerHTML = '';
|
||||
},
|
||||
|
||||
onClickMessage: function(node, event) {
|
||||
this.emitDomEvent('messageClick', node);
|
||||
},
|
||||
|
||||
/**
|
||||
* Waits for scrolling to stop before fetching snippets.
|
||||
*/
|
||||
_onVScrollStopped: function() {
|
||||
// Give any pending requests in the slice priority.
|
||||
if (!this.headerCursor.messagesSlice ||
|
||||
this.headerCursor.messagesSlice.pendingRequestCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not bother fetching snippets if this card is not in view.
|
||||
// The card could still have a scroll event triggered though
|
||||
// by the next/previous work done in message_reader.
|
||||
if (cards.isVisible(this.cardParent) && !this._hasSnippetRequest()) {
|
||||
this._requestSnippets();
|
||||
}
|
||||
},
|
||||
|
||||
onGetMoreMessages: function() {
|
||||
if (!this.headerCursor.messagesSlice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For accessibility purposes, focus on the first newly loaded item in the
|
||||
// messages list. This will ensure that screen reader's cursor position
|
||||
// will get updated to the right place.
|
||||
this.vScroll.once('recalculated', function(calledFromTop, refIndex) {
|
||||
// refIndex is the index of the first new message item.
|
||||
this.vScrollContainer.querySelector(
|
||||
'[data-index="' + refIndex + '"]').focus();
|
||||
}.bind(this));
|
||||
|
||||
this.headerCursor.messagesSlice.requestGrowth(1, true);
|
||||
},
|
||||
|
||||
isEmpty: function() {
|
||||
return this.headerCursor.messagesSlice.items.length === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide buttons that are not appropriate if we have no messages and display
|
||||
* the appropriate l10n string in the message list proper.
|
||||
*/
|
||||
showEmptyLayout: function() {
|
||||
this.messageEmptyContainer.classList.remove('collapsed');
|
||||
this.emit('emptyLayoutShown');
|
||||
},
|
||||
/**
|
||||
* Show buttons we hid in `showEmptyLayout` and hide the "empty folder"
|
||||
* message.
|
||||
*/
|
||||
hideEmptyLayout: function() {
|
||||
this.messageEmptyContainer.classList.add('collapsed');
|
||||
this.emit('emptyLayoutHidden');
|
||||
},
|
||||
|
||||
// The funny name because it is auto-bound as a listener for
|
||||
// messagesSlice events in headerCursor using a naming convention.
|
||||
messages_status: function(newStatus) {
|
||||
var syncInProgress = true;
|
||||
if (newStatus === 'synchronizing' ||
|
||||
newStatus === 'syncblocked') {
|
||||
this.syncingNode.classList.remove('collapsed');
|
||||
this.syncMoreNode.classList.add('collapsed');
|
||||
this.hideEmptyLayout();
|
||||
} else if (newStatus === 'syncfailed' ||
|
||||
newStatus === 'synced') {
|
||||
syncInProgress = false;
|
||||
if (newStatus === 'syncfailed') {
|
||||
// If there was a problem talking to the server, notify the user and
|
||||
// provide a means to attempt to talk to the server again. We have
|
||||
// made onRefresh pretty clever, so it can do all the legwork on
|
||||
// accomplishing this goal.
|
||||
toaster.toast({
|
||||
text: mozL10n.get('toaster-retryable-syncfailed')
|
||||
});
|
||||
}
|
||||
this.syncingNode.classList.add('collapsed');
|
||||
}
|
||||
this.emit('syncInProgress', syncInProgress);
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number=} newEmailCount Optional number of new messages.
|
||||
* The funny name because it is auto-bound as a listener for
|
||||
* messagesSlice events in headerCursor using a naming convention.
|
||||
*/
|
||||
messages_complete: function(newEmailCount) {
|
||||
var headerCursor = this.headerCursor;
|
||||
|
||||
console.log('message_list complete:',
|
||||
headerCursor.messagesSlice.items.length, 'items of',
|
||||
headerCursor.messagesSlice.headerCount,
|
||||
'alleged known headers. canGrow:',
|
||||
headerCursor.messagesSlice.userCanGrowDownwards);
|
||||
|
||||
// Show "load more", but only if the slice can grow and if there is a
|
||||
// non-zero headerCount. If zero headerCount, it likely means the folder
|
||||
// has never been synchronized, and this display was an offline display,
|
||||
// so it is hard to know if messages can be synchronized. In this case,
|
||||
// canGrow is not enough of an indicator, because as far as the back end is
|
||||
// concerned, it could grow, it just has no way to check for sure yet. So
|
||||
// hide the "load more", the user can use the refresh icon once online to
|
||||
// load messages.
|
||||
if (headerCursor.messagesSlice.userCanGrowDownwards &&
|
||||
headerCursor.messagesSlice.headerCount) {
|
||||
this.syncMoreNode.classList.remove('collapsed');
|
||||
} else {
|
||||
this.syncMoreNode.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Show empty layout, unless this is a slice with fake data that
|
||||
// will get changed soon.
|
||||
if (headerCursor.messagesSlice.items.length === 0) {
|
||||
this.showEmptyLayout();
|
||||
}
|
||||
|
||||
this.waitingOnChunk = false;
|
||||
// Load next chunk if one is pending
|
||||
if (this.desiredHighAbsoluteIndex) {
|
||||
this.loadNextChunk(this.desiredHighAbsoluteIndex);
|
||||
this.desiredHighAbsoluteIndex = 0;
|
||||
}
|
||||
|
||||
// It's possible for a synchronization to result in a change to
|
||||
// headerCount without resulting in a splice. This is very likely
|
||||
// to happen with a search filter when it was lying about another
|
||||
// messages existing, but it's also possible to happen in
|
||||
// synchronizations.
|
||||
//
|
||||
// XXX Our total correctness currently depends on headerCount only
|
||||
// changing as a result of a synchronization triggered by this slice.
|
||||
// This does not hold up when confronted with periodic background sync; we
|
||||
// need to finish cleanup up the headerCount change notification stuff.
|
||||
//
|
||||
// (However, this is acceptable glitchage right now. We just need to make
|
||||
// sure it doesn't happen for searches since it's so blatant.)
|
||||
//
|
||||
// So, anyways, use updateDataBind() to cause VScroll to double-check that
|
||||
// our list size didn't change from what it thought it was. (It renders
|
||||
// coordinate-space predictively based on our headerCount, but we
|
||||
// currently only provide strong correctness guarantees for actually
|
||||
// reported `items`, so we must do this.) If our list size is the same,
|
||||
// this call is effectively a no-op.
|
||||
this.vScroll.updateDataBind(0, [], 0);
|
||||
|
||||
this.emit('messagesComplete', newEmailCount);
|
||||
},
|
||||
|
||||
// The funny name because it is auto-bound as a listener for
|
||||
// messagesSlice events in headerCursor using a naming convention.
|
||||
messages_splice: function(index, howMany, addedItems,
|
||||
requested, moreExpected) {
|
||||
var headerCursor = this.headerCursor;
|
||||
|
||||
// If no work to do, just skip it.
|
||||
if (index === 0 && howMany === 0 && !addedItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('messagesSpliceStart', index, howMany, addedItems,
|
||||
requested, moreExpected);
|
||||
|
||||
if (this._needVScrollData) {
|
||||
this.vScroll.setData(this.listFunc);
|
||||
this._needVScrollData = false;
|
||||
}
|
||||
|
||||
this.vScroll.updateDataBind(index, addedItems, howMany);
|
||||
|
||||
// Remove the no message text while new messages added:
|
||||
if (addedItems.length > 0) {
|
||||
this.hideEmptyLayout();
|
||||
}
|
||||
|
||||
// If the end result is no more messages, then show empty layout.
|
||||
// This is needed mostly because local drafts do not trigger
|
||||
// a messages_complete callback when removing the last draft
|
||||
// from the compose triggered in that view. The scrollStopped
|
||||
// is used to avoid a flash where the old message is briefly visible
|
||||
// before cleared, and having the empty layout overlay it.
|
||||
// Using the slice's headerCount because it is updated before splice
|
||||
// listeners are notified, so should be accurate.
|
||||
if (!headerCursor.messagesSlice.headerCount) {
|
||||
this.vScroll.once('scrollStopped', function() {
|
||||
// Confirm there are still no messages. Since this callback happens
|
||||
// async, some items could have appeared since first issuing the
|
||||
// request to show empty.
|
||||
if (!headerCursor.messagesSlice.headerCount) {
|
||||
this.showEmptyLayout();
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
this.emit('messagesSpliceEnd', index, howMany, addedItems,
|
||||
requested, moreExpected);
|
||||
},
|
||||
|
||||
// The funny name because it is auto-bound as a listener for
|
||||
// messagesSlice events in headerCursor using a naming convention.
|
||||
messages_change: function(message, index) {
|
||||
this.emit('messagesChange', message, index);
|
||||
},
|
||||
|
||||
/**
|
||||
* Request data through desiredHighAbsoluteIndex if we don't have it
|
||||
* already and we think it exists. If we already have an outstanding
|
||||
* request we will save off this most recent request to process once
|
||||
* the current request completes. Any previously queued request will
|
||||
* be forgotten regardless of how it compares to the newly queued
|
||||
* request.
|
||||
*
|
||||
* @param {Number} desiredHighAbsoluteIndex
|
||||
*/
|
||||
loadNextChunk: function(desiredHighAbsoluteIndex) {
|
||||
// The recalculate logic will trigger a call to prepareData, so
|
||||
// it's okay for us to bail. It's advisable for us to bail
|
||||
// because any calls to prepareData will be based on outdated
|
||||
// index information.
|
||||
if (this.vScroll.waitingForRecalculate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.waitingOnChunk) {
|
||||
this.desiredHighAbsoluteIndex = desiredHighAbsoluteIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not bother asking for more than exists
|
||||
var headerCursor = this.headerCursor;
|
||||
if (desiredHighAbsoluteIndex >= headerCursor.messagesSlice.headerCount) {
|
||||
desiredHighAbsoluteIndex = headerCursor.messagesSlice.headerCount - 1;
|
||||
}
|
||||
|
||||
// Do not bother asking for more than what is already
|
||||
// fetched
|
||||
var items = headerCursor.messagesSlice.items;
|
||||
var curHighAbsoluteIndex = items.length - 1;
|
||||
var amount = desiredHighAbsoluteIndex - curHighAbsoluteIndex;
|
||||
if (amount > 0) {
|
||||
// IMPORTANT NOTE!
|
||||
// 1 is unfortunately a special value right now for historical reasons
|
||||
// that the other side interprets as a request to grow downward with the
|
||||
// default growth size. XXX change backend and its tests...
|
||||
console.log('message_list loadNextChunk growing', amount,
|
||||
(amount === 1 ? '(will get boosted to 15!) to' : 'to'),
|
||||
(desiredHighAbsoluteIndex + 1), 'items out of',
|
||||
headerCursor.messagesSlice.headerCount, 'alleged known');
|
||||
headerCursor.messagesSlice.requestGrowth(
|
||||
amount,
|
||||
// the user is not requesting us to go synchronize new messages
|
||||
false);
|
||||
this.waitingOnChunk = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Scroll to make sure that the current message is in our visible window.
|
||||
*
|
||||
* @param {header_cursor.CurrentMessage} currentMessage representation of
|
||||
* the email we're currently reading.
|
||||
* @param {Number} index the index of the message in the messagesSlice
|
||||
*/
|
||||
onCurrentMessage: function(currentMessage, index) {
|
||||
if (!currentMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
var visibleIndices = this.vScroll.getVisibleIndexRange();
|
||||
if (visibleIndices &&
|
||||
(index < visibleIndices[0] || index > visibleIndices[1])) {
|
||||
this.vScroll.jumpToIndex(index);
|
||||
}
|
||||
},
|
||||
|
||||
_hasSnippetRequest: function() {
|
||||
var max = MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST;
|
||||
var now = Date.now();
|
||||
|
||||
// if we before the maximum time to wait between requests...
|
||||
var beforeTimeout =
|
||||
(this._lastSnippetRequest + max) > now;
|
||||
|
||||
// there is an important case where the backend may be slow OR have some
|
||||
// fatal error which would prevent us from ever requesting an new set of
|
||||
// snippets because we wait until the last batch finishes. To prevent that
|
||||
// from ever happening we maintain the request start time and if more then
|
||||
// MAXIMUM_MS_BETWEEN_SNIPPET_REQUEST passes we issue a new request.
|
||||
if (this._snippetRequestPending && beforeTimeout) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
_pendingSnippetRequest: function() {
|
||||
this._snippetRequestPending = true;
|
||||
this._lastSnippetRequest = Date.now();
|
||||
},
|
||||
|
||||
_clearSnippetRequest: function() {
|
||||
this._snippetRequestPending = false;
|
||||
},
|
||||
|
||||
_requestSnippets: function() {
|
||||
var headerCursor = this.headerCursor;
|
||||
var items = headerCursor.messagesSlice.items;
|
||||
var len = items.length;
|
||||
|
||||
if (!len) {
|
||||
return;
|
||||
}
|
||||
|
||||
var clearSnippets = this._clearSnippetRequest.bind(this);
|
||||
var options = {
|
||||
// this is per message
|
||||
maximumBytesToFetch: MAXIMUM_BYTES_PER_MESSAGE_DURING_SCROLL
|
||||
};
|
||||
|
||||
if (len < MINIMUM_ITEMS_FOR_SCROLL_CALC) {
|
||||
this._pendingSnippetRequest();
|
||||
headerCursor.messagesSlice.maybeRequestBodies(0,
|
||||
MINIMUM_ITEMS_FOR_SCROLL_CALC - 1, options, clearSnippets);
|
||||
return;
|
||||
}
|
||||
|
||||
var visibleIndices = this.vScroll.getVisibleIndexRange();
|
||||
|
||||
if (visibleIndices) {
|
||||
this._pendingSnippetRequest();
|
||||
headerCursor.messagesSlice.maybeRequestBodies(
|
||||
visibleIndices[0],
|
||||
visibleIndices[1],
|
||||
options,
|
||||
clearSnippets
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
die: function() {
|
||||
sliceEvents.forEach(function(type) {
|
||||
var name = 'messages_' + type;
|
||||
this.headerCursor.removeListener(name, this[name]);
|
||||
}.bind(this));
|
||||
|
||||
this.headerCursor.removeListener('currentMessage', this.onCurrentMessage);
|
||||
this.vScroll.destroy();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
});
|
||||
8
gaia/apps/email/js/cards/lst/peep_dom.js
Executable file
8
gaia/apps/email/js/cards/lst/peep_dom.js
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
'use strict';
|
||||
define(function() {
|
||||
return {
|
||||
update: function(peep) {
|
||||
peep.element.textContent = peep.name || peep.address;
|
||||
}
|
||||
};
|
||||
});
|
||||
89
gaia/apps/email/js/cards/message_list.html
Executable file
89
gaia/apps/email/js/cards/message_list.html
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
<!-- Non-search header -->
|
||||
<section data-prop="normalHeader"
|
||||
class="msg-list-header msg-nonsearch-only"
|
||||
data-statuscolor="default"
|
||||
role="region">
|
||||
<header>
|
||||
<!-- Unlike a generic back button that navigates to a different screen,
|
||||
folder list header button triggers the folders and settings overlay. Thus
|
||||
the screen reader user requires more context as to what activating the
|
||||
button would do. -->
|
||||
<a href="#" class="msg-folder-list-btn" data-event="click:onShowFolders"
|
||||
aria-expanded="false" aria-controls="cards-folder-picker"
|
||||
role="button" data-l10n-id="message-list-menu">
|
||||
<span class="icon icon-menu"></span>
|
||||
</a>
|
||||
<menu data-prop="headerMenuNode" type="toolbar" class="anim-opacity">
|
||||
<a href="#" class="msg-compose-btn" data-event="click:onCompose"
|
||||
data-l10n-id="message-list-compose">
|
||||
<span class="icon icon-compose"></span>
|
||||
</a>
|
||||
</menu>
|
||||
<h1 data-prop="folderLabel"
|
||||
class="msg-list-header-folder-label header-label">
|
||||
<span data-prop="folderNameNode"
|
||||
dir="auto"
|
||||
class="msg-list-header-folder-name"></span>
|
||||
<span data-prop="folderUnread"
|
||||
class="msg-list-header-folder-unread collapsed"></span>
|
||||
</h1>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<!-- Multi-edit state header -->
|
||||
<lst-edit-header data-prop="editHeader"
|
||||
data-event="editHeaderClose"></lst-edit-header>
|
||||
<!-- Scroll region -->
|
||||
<div data-prop="scrollContainer" class="msg-list-scrollouter">
|
||||
<!-- exists so we can force a minimum height -->
|
||||
<div class="msg-list-scrollinner">
|
||||
<!-- The search textbox hides under the lip of the messages.
|
||||
As soon as any typing happens in it, we push the search
|
||||
controls card. -->
|
||||
<form role="search" data-prop="searchBar"
|
||||
class="msg-search-tease-bar msg-nonsearch-only">
|
||||
<p>
|
||||
<input data-event="focus:onSearchButton"
|
||||
data-prop="searchTextTease"
|
||||
class="msg-search-text-tease" type="text"
|
||||
dir="auto"
|
||||
data-l10n-id="message-search-input" />
|
||||
</p>
|
||||
</form>
|
||||
<lst-msg-vscroll data-prop="msgVScroll"
|
||||
data-event="messageClick:onClickMessage"
|
||||
data-empty-l10n-id="messages-folder-empty">
|
||||
</lst-msg-vscroll>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New email notification bar -->
|
||||
<div class="message-list-topbar"></div>
|
||||
|
||||
<!-- Toolbar for non-multi-edit state -->
|
||||
<ul data-prop="normalToolbar" class="bb-tablist msg-list-action-toolbar"
|
||||
role="toolbar">
|
||||
<li role="presentation" class="msg-nonsearch-only">
|
||||
<button data-prop="refreshBtn" data-event="click:onRefresh"
|
||||
class="icon msg-refresh-btn" data-state="synchronized"
|
||||
data-l10n-id="messages-refresh-button">
|
||||
</button>
|
||||
</li>
|
||||
<li role="status" class="msg-nonsearch-only msg-last-sync">
|
||||
<span data-prop="lastSyncedLabel"
|
||||
class="msg-last-synced-label"
|
||||
data-l10n-id="folder-last-synced-label"></span>
|
||||
<span data-prop="lastSyncedAtNode"
|
||||
class="msg-last-synced-value"></span>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="editBtn" data-event="click:setEditModeStart"
|
||||
class="icon msg-edit-btn" data-l10n-id="edit-button"></button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<lst-edit-toolbar data-prop="editToolbar"
|
||||
data-event="onDeleteMessages,onStarMessages,
|
||||
onMarkMessagesRead,onMoveMessages">
|
||||
</lst-edit-toolbar>
|
||||
|
||||
974
gaia/apps/email/js/cards/message_list.js
Executable file
974
gaia/apps/email/js/cards/message_list.js
Executable file
|
|
@ -0,0 +1,974 @@
|
|||
/*global FontSizeUtils, requestAnimationFrame */
|
||||
'use strict';
|
||||
|
||||
define(function(require, exports, module) {
|
||||
|
||||
var cards = require('cards'),
|
||||
date = require('date'),
|
||||
defaultVScrollData = require('./lst/default_vscroll_data'),
|
||||
evt = require('evt'),
|
||||
toaster = require('toaster'),
|
||||
HeaderCursor = require('header_cursor'),
|
||||
htmlCache = require('html_cache'),
|
||||
mozL10n = require('l10n!'),
|
||||
MessageListTopBar = require('message_list_topbar'),
|
||||
messageDisplay = require('message_display'),
|
||||
updatePeepDom = require('./lst/peep_dom').update,
|
||||
VScroll = require('vscroll');
|
||||
|
||||
/**
|
||||
* List messages for listing the contents of folders. Multi-editing is just a
|
||||
* state of the card.
|
||||
*
|
||||
* == Cache behavior ==
|
||||
*
|
||||
* This is a card that can be instantiated using the cached HTML stored by the
|
||||
* html_cache. As such, it is constructed to allow clicks on message list items
|
||||
* before the back end has loaded up, and to know how to refresh the cached
|
||||
* state by looking at the use the usingCachedNode property. It also prevents
|
||||
* clicks from button actions that need back end data to complete if the click
|
||||
* would result in a card that cannot also handle delayed back end startup.
|
||||
* It tracks if the back end has started up by checking curFolder, which is
|
||||
* set to a data object sent from the back end.
|
||||
*
|
||||
* == Less-than-infinite scrolling ==
|
||||
*
|
||||
* A dream UI would be to let the user smoothly scroll through all of the
|
||||
* messages in a folder, syncing them from the server as-needed. The limits
|
||||
* on this are 1) bandwidth cost, and 2) storage limitations.
|
||||
*
|
||||
* Our sync costs are A) initial sync of a time range, and B) update sync of a
|
||||
* time range. #A is sufficiently expensive that it makes sense to prompt the
|
||||
* user when we are going to sync further into a time range. #B is cheap
|
||||
* enough and having already synced the time range suggests sufficient user
|
||||
* interest.
|
||||
*
|
||||
* So the way our UI works is that we do an infinite-scroll-type thing for
|
||||
* messages that we already know about. If we are on metered bandwidth, then
|
||||
* we require the user to click a button in the display list to sync more
|
||||
* messages. If we are on unmetered bandwidth, we will eventually forego that.
|
||||
* (For testing purposes right now, we want to pretend everything is metered.)
|
||||
* We might still want to display a button at some storage threshold level,
|
||||
* like if the folder is already using a lot of space.
|
||||
*
|
||||
* See `onScroll` for more details.
|
||||
*
|
||||
* XXX this class wants to be cleaned up, badly. A lot of this may want to
|
||||
* happen via pushing more of the hiding/showing logic out onto CSS, taking
|
||||
* care to use efficient selectors.
|
||||
*
|
||||
*/
|
||||
return [
|
||||
require('./base_card')(require('template!./message_list.html')),
|
||||
require('./lst/edit_controller'),
|
||||
require('./lst/msg_click'),
|
||||
|
||||
{
|
||||
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);
|
||||
},
|
||||
|
||||
onArgs: function(args) {
|
||||
var model = this.model = args.model;
|
||||
var headerCursor = this.headerCursor = args.headerCursor ||
|
||||
new HeaderCursor(model);
|
||||
this.msgVScroll.setHeaderCursor(headerCursor);
|
||||
|
||||
model.latest('folder', this._folderChanged);
|
||||
model.on('newInboxMessages', this.onNewMail);
|
||||
model.on('backgroundSendStatus', this.onBackgroundSendStatus.bind(this));
|
||||
|
||||
model.on('foldersSliceOnChange', this.onFoldersSliceChange);
|
||||
|
||||
// If this card is created after header_cursor is set up
|
||||
// with a messagesSlice, then need to bootstrap this card
|
||||
// to catch up, since the normal events will not fire.
|
||||
// Common scenarios for this case are: going back to the
|
||||
// message list after reading a message from a notification,
|
||||
// or from a compose triggered from an activity. However,
|
||||
// only do this if there is a current folder. A case
|
||||
// where there is not a folder: after deleting an account,
|
||||
// and the UI is bootstrapping back to existing account.
|
||||
// Also, search pushes a new message_list card, but search
|
||||
// needs a special slice, created only when the search
|
||||
// actually starts. So do not bootstrap in that case.
|
||||
if (this.curFolder) {
|
||||
var items = headerCursor.messagesSlice &&
|
||||
headerCursor.messagesSlice.items;
|
||||
if (items && items.length) {
|
||||
this.msgVScroll.messages_splice(0, 0, items);
|
||||
this.msgVScroll.messages_complete(0);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Inform Cards to not emit startup content events, this card will trigger
|
||||
* them once data from back end has been received and the DOM is up to date
|
||||
* with that data.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
skipEmitContentEvents: true,
|
||||
|
||||
postInsert: function() {
|
||||
this._hideSearchBoxByScrolling();
|
||||
|
||||
// Now that _hideSearchBoxByScrolling has activated the display
|
||||
// of the search box, get the height of the search box and tell
|
||||
// vScroll about it, but only do this once the DOM is displayed
|
||||
// so the ClientRect gives an actual height.
|
||||
this.msgVScroll.vScroll.visibleOffset =
|
||||
this.searchBar.getBoundingClientRect().height;
|
||||
|
||||
// Also tell the MessageListTopBar
|
||||
this._topBar.visibleOffset = this.msgVScroll.vScroll.visibleOffset;
|
||||
|
||||
// For search we want to make sure that we capture the screen size prior
|
||||
// to focusing the input since the FxOS keyboard will resize our window to
|
||||
// be smaller which messes up our logic a bit. We trigger metric
|
||||
// gathering in non-search cases too for consistency.
|
||||
this.msgVScroll.vScroll.captureScreenMetrics();
|
||||
},
|
||||
|
||||
onSearchButton: function() {
|
||||
// Do not bother if there is no current folder.
|
||||
if (!this.curFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
cards.pushCard(
|
||||
'message_list_search', 'animate',
|
||||
{
|
||||
model: this.model,
|
||||
folder: this.curFolder
|
||||
});
|
||||
},
|
||||
|
||||
_hideSearchBoxByScrolling: function() {
|
||||
// scroll the search bit out of the way
|
||||
var searchBar = this.searchBar,
|
||||
scrollContainer = this.scrollContainer;
|
||||
|
||||
// Search bar could have been collapsed with a cache load,
|
||||
// make sure it is visible, but if so, adjust the scroll
|
||||
// position in case the user has scrolled before this code
|
||||
// runs.
|
||||
if (searchBar.classList.contains('collapsed')) {
|
||||
searchBar.classList.remove('collapsed');
|
||||
scrollContainer.scrollTop += searchBar.offsetHeight;
|
||||
}
|
||||
|
||||
// Adjust scroll position now that there is something new in
|
||||
// the scroll region, but only if at the top. Otherwise, the
|
||||
// user's purpose scroll positioning may be disrupted.
|
||||
//
|
||||
// Note that when we call vScroll.clearDisplay() we
|
||||
// inherently scroll back up to the top, so this check is still
|
||||
// okay even when switching folders. (We do not want to start
|
||||
// index 50 in our new folder because we were at index 50 in our
|
||||
// old folder.)
|
||||
if (scrollContainer.scrollTop === 0) {
|
||||
scrollContainer.scrollTop = searchBar.offsetHeight;
|
||||
}
|
||||
},
|
||||
|
||||
onShowFolders: function() {
|
||||
cards.pushCard('folder_picker', 'immediate', {
|
||||
model: this.model,
|
||||
onPushed: function() {
|
||||
this.headerMenuNode.classList.add('transparent');
|
||||
}.bind(this)
|
||||
});
|
||||
},
|
||||
|
||||
onCompose: function() {
|
||||
cards.pushCard('compose', 'animate', {
|
||||
model: this.model
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* If the last synchronised label is more than half the length
|
||||
* of its display area, set a "long" style on it that allows
|
||||
* different styling. But only do this once per card instance,
|
||||
* the label should not change otherwise.
|
||||
* TODO though, once locale changing in app is supported, this
|
||||
* should be revisited.
|
||||
*/
|
||||
sizeLastSync: function() {
|
||||
if (this._needsSizeLastSync && this.lastSyncedLabel.scrollWidth) {
|
||||
var label = this.lastSyncedLabel;
|
||||
var overHalf = label.scrollWidth > label.parentNode.clientWidth / 2;
|
||||
label.parentNode.classList[(overHalf ? 'add' : 'remove')]('long');
|
||||
this._needsSizeLastSync = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateLastSynced: function(value) {
|
||||
var method = value ? 'remove' : 'add';
|
||||
this.lastSyncedLabel.classList[method]('collapsed');
|
||||
date.setPrettyNodeDate(this.lastSyncedAtNode, value);
|
||||
this.sizeLastSync();
|
||||
},
|
||||
|
||||
updateUnread: function(num) {
|
||||
var content = '';
|
||||
if (num > 0) {
|
||||
content = num > 999 ? mozL10n.get('messages-folder-unread-max') : num;
|
||||
}
|
||||
|
||||
this.folderUnread.textContent = content;
|
||||
this.folderUnread.classList.toggle('collapsed', !content);
|
||||
this.callHeaderFontSize();
|
||||
},
|
||||
|
||||
onFoldersSliceChange: function(folder) {
|
||||
if (folder === this.curFolder) {
|
||||
this.updateUnread(folder.unread);
|
||||
this.updateLastSynced(folder.lastSyncedAt);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A workaround for font_size_utils not recognizing child node content
|
||||
* changing, and if it did, it would be noisy/extra work if done
|
||||
* generically. Using a rAF call to not slow down the rest of card updates,
|
||||
* it is something that can happen lazily on another turn.
|
||||
*/
|
||||
callHeaderFontSize: function(node) {
|
||||
requestAnimationFrame(function() {
|
||||
FontSizeUtils._reformatHeaderText(this.folderLabel);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a folder, returning true if we actually changed folders or false if
|
||||
* we did nothing because we were already in the folder.
|
||||
*/
|
||||
showFolder: function(folder, forceNewSlice) {
|
||||
if (folder === this.curFolder && !forceNewSlice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If using a cache, do not clear the HTML as it will
|
||||
// be cleared once real data has been fetched.
|
||||
if (!this.usingCachedNode) {
|
||||
// This inherently scrolls us back up to the top of the list.
|
||||
this.msgVScroll.vScroll.clearDisplay();
|
||||
}
|
||||
this.msgVScroll._needVScrollData = true;
|
||||
|
||||
this.curFolder = folder;
|
||||
|
||||
// Now that a folder is available, enable edit mode toggling.
|
||||
this.editModeEnabled = true;
|
||||
|
||||
switch (folder.type) {
|
||||
case 'drafts':
|
||||
case 'localdrafts':
|
||||
case 'outbox':
|
||||
case 'sent':
|
||||
this.isIncomingFolder = false;
|
||||
break;
|
||||
default:
|
||||
this.isIncomingFolder = true;
|
||||
break;
|
||||
}
|
||||
|
||||
this.folderNameNode.textContent = folder.name;
|
||||
this.updateUnread(folder.unread);
|
||||
this.msgVScroll.setAttribute('aria-label', folder.name);
|
||||
this.msgVScroll.hideEmptyLayout();
|
||||
|
||||
// You can't refresh messages in the localdrafts folder.
|
||||
this.refreshBtn.classList.toggle('collapsed',
|
||||
folder.type === 'localdrafts');
|
||||
|
||||
this.editToolbar.updateDomFolderType(folder.type);
|
||||
|
||||
this.updateLastSynced(folder.lastSyncedAt);
|
||||
|
||||
if (forceNewSlice) {
|
||||
// We are creating a new slice, so any pending snippet requests are
|
||||
// moot.
|
||||
this.msgVScroll._snippetRequestPending = false;
|
||||
this.headerCursor.freshMessagesSlice();
|
||||
}
|
||||
|
||||
this.onFolderShown();
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* This is an override of setEditMode in lst/edit_controller because the
|
||||
* outbox needs special treatment.
|
||||
*/
|
||||
setEditMode: function(editMode) {
|
||||
// Do not bother if edit mode is not enabled yet.
|
||||
if (!this.editModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.curFolder.type === 'outbox') {
|
||||
// You cannot edit the outbox messages if the outbox is syncing.
|
||||
if (editMode && this.outboxSyncInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Outbox Sync and Edit Mode are mutually exclusive. Disable
|
||||
// outbox syncing before allowing us to enter edit mode, and
|
||||
// vice versa. The callback shouldn't take long, but we wait to
|
||||
// trigger edit mode until outbox sync has been fully disabled,
|
||||
// to prevent ugly theoretical race conditions.
|
||||
var model = this.model;
|
||||
model.api.setOutboxSyncEnabled(model.account, !editMode, function() {
|
||||
this._setEditMode(editMode);
|
||||
}.bind(this));
|
||||
} else {
|
||||
this._setEditMode(editMode);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the refresh button state based on the new message status.
|
||||
*/
|
||||
setRefreshState: function(syncing) {
|
||||
if (syncing) {
|
||||
this.refreshBtn.dataset.state = 'synchronizing';
|
||||
this.refreshBtn.setAttribute('role', 'progressbar');
|
||||
mozL10n.setAttributes(this.refreshBtn, 'messages-refresh-progress');
|
||||
} else {
|
||||
this.refreshBtn.dataset.state = 'synchronized';
|
||||
this.refreshBtn.removeAttribute('role');
|
||||
mozL10n.setAttributes(this.refreshBtn, 'messages-refresh-button');
|
||||
}
|
||||
},
|
||||
|
||||
onNewMail: function(newEmailCount) {
|
||||
var inboxFolder = this.model.foldersSlice.getFirstFolderWithType('inbox');
|
||||
|
||||
if (inboxFolder.id === this.curFolder.id &&
|
||||
newEmailCount && newEmailCount > 0) {
|
||||
if (!cards.isVisible(this)) {
|
||||
this._whenVisible = this.onNewMail.bind(this, newEmailCount);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user manually synced, then want to jump to show the new
|
||||
// messages. Otherwise, show the top bar.
|
||||
if (this._manuallyTriggeredSync) {
|
||||
this.msgVScroll.vScroll.jumpToIndex(0);
|
||||
} else {
|
||||
// Update the existing status bar.
|
||||
this._topBar.showNewEmailCount(newEmailCount);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// When an email is being sent from the app (and not from an outbox
|
||||
// refresh), we'll receive notification here. Play a sound and
|
||||
// raise a toast, if appropriate.
|
||||
onBackgroundSendStatus: function(data) {
|
||||
if (this.curFolder.type === 'outbox') {
|
||||
if (data.state === 'sending') {
|
||||
// If the message is now sending, make sure we're showing the
|
||||
// outbox as "currently being synchronized".
|
||||
this.toggleOutboxSyncingDisplay(true);
|
||||
} else if (data.state === 'syncDone') {
|
||||
this.toggleOutboxSyncingDisplay(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.emitNotifications) {
|
||||
toaster.toast({
|
||||
text: data.localizedDescription
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* How many items in the message list to keep for the _cacheDom call.
|
||||
* @type {Number}
|
||||
*/
|
||||
_cacheListLimit: 7,
|
||||
|
||||
/**
|
||||
* Tracks if a DOM cache save is scheduled for later.
|
||||
* @type {Number}
|
||||
*/
|
||||
_cacheDomTimeoutId: 0,
|
||||
|
||||
/**
|
||||
* Confirms card state is in a visual state suitable for caching.
|
||||
*/
|
||||
_isCacheableCardState: function() {
|
||||
return this.cacheableFolderId === this.curFolder.id && !this.editMode;
|
||||
},
|
||||
|
||||
/**
|
||||
* Caches the DOM for this card, but trims it down a bit first.
|
||||
*/
|
||||
_cacheDom: function() {
|
||||
this._cacheDomTimeoutId = 0;
|
||||
if (!this._isCacheableCardState()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safely clone the node so we can mutate the tree to cut out the parts
|
||||
// we do not want/need.
|
||||
var cacheNode =
|
||||
htmlCache.cloneAsInertNodeAvoidingCustomElementHorrors(this);
|
||||
|
||||
// Make sure toolbar is visible, could be hidden by drawer
|
||||
cacheNode.querySelector('menu[type="toolbar"]')
|
||||
.classList.remove('transparent');
|
||||
|
||||
// Hide search field as it will not operate and gets scrolled out
|
||||
// of view after real load.
|
||||
var removableCacheNode = cacheNode.querySelector('.msg-search-tease-bar');
|
||||
if (removableCacheNode) {
|
||||
removableCacheNode.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Hide "new mail" topbar too
|
||||
removableCacheNode = cacheNode.querySelector('.message-list-topbar');
|
||||
if (removableCacheNode) {
|
||||
this._topBar.resetNodeForCache(removableCacheNode);
|
||||
}
|
||||
|
||||
// Hide the last sync number
|
||||
var tempNode = cacheNode.querySelector('.msg-last-synced-label');
|
||||
if (tempNode) {
|
||||
tempNode.classList.add('collapsed');
|
||||
}
|
||||
tempNode = cacheNode.querySelector('.msg-last-synced-value');
|
||||
if (tempNode) {
|
||||
tempNode.innerHTML = '';
|
||||
}
|
||||
|
||||
// Trim vScroll containers that are not in play
|
||||
VScroll.trimMessagesForCache(
|
||||
cacheNode.querySelector('.msg-vscroll-container'),
|
||||
this._cacheListLimit
|
||||
);
|
||||
|
||||
htmlCache.saveFromNode(module.id, cacheNode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Considers a DOM cache, but only if it meets the criteria for what
|
||||
* should be saved in the cache, and if a save is not already scheduled.
|
||||
* @param {Number} index the index of the message that triggered
|
||||
* this call.
|
||||
*/
|
||||
_considerCacheDom: function(index) {
|
||||
// Only bother if not already waiting to update cache and
|
||||
if (!this._cacheDomTimeoutId &&
|
||||
// card visible state is appropriate
|
||||
this._isCacheableCardState() &&
|
||||
// if the scroll area is at the top (otherwise the
|
||||
// virtual scroll may be showing non-top messages)
|
||||
this.msgVScroll.vScroll.firstRenderedIndex === 0 &&
|
||||
// if actually got a numeric index and
|
||||
(index || index === 0) &&
|
||||
// if it affects the data we cache
|
||||
index < this._cacheListLimit) {
|
||||
this._cacheDomTimeoutId = setTimeout(this._cacheDom.bind(this), 600);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears out the messages HTML in messageContainer from using the cached
|
||||
* nodes that were picked up when the HTML cache of this list was used
|
||||
* (which is indicated by usingCachedNode being true). The cached HTML
|
||||
* needs to be purged when the real data is finally available and will
|
||||
* replace the cached state. A more sophisticated approach would be to
|
||||
* compare the cached HTML to what would be inserted in its place, and
|
||||
* if no changes, skip this step, but that comparison operation could get
|
||||
* tricky, and it is cleaner just to wipe it and start fresh. Once the
|
||||
* cached HTML has been cleared, then usingCachedNode is set to false
|
||||
* to indicate that the main piece of content in the card, the message
|
||||
* list, is no longer from a cached node.
|
||||
*/
|
||||
_clearCachedMessages: function() {
|
||||
if (this.usingCachedNode) {
|
||||
this.msgVScroll.removeMessagesHtml();
|
||||
this.usingCachedNode = false;
|
||||
}
|
||||
},
|
||||
|
||||
onMessagesChange: function(message, index) {
|
||||
this.updateMessageDom(message);
|
||||
|
||||
// Since the DOM change, cache may need to change.
|
||||
this._considerCacheDom(index);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the state of the given DOM node. Note that DOM nodes are reused
|
||||
* so you must ensure that this method cleans up any dirty state resulting
|
||||
* from any possible prior operation of this method.
|
||||
*/
|
||||
updateMessageDom: function(message) {
|
||||
var msgNode = message.element;
|
||||
|
||||
if (!msgNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the placeholder data, indicate that in case VScroll
|
||||
// wants to go back and fix later.
|
||||
var classAction = message.isPlaceholderData ? 'add' : 'remove';
|
||||
var defaultDataClass = this.msgVScroll.vScroll.itemDefaultDataClass;
|
||||
msgNode.classList[classAction](defaultDataClass);
|
||||
|
||||
// ID is stored as a data- attribute so that it can survive
|
||||
// serialization to HTML for storing in the HTML cache, and
|
||||
// be usable before the actual data from the backend has
|
||||
// loaded, as clicks to the message list are allowed before
|
||||
// the back end is available. For this reason, click
|
||||
// handlers should use dataset.id when wanting the ID.
|
||||
msgNode.dataset.id = message.id;
|
||||
|
||||
// some things only need to be done once
|
||||
var dateNode = msgNode.querySelector('.msg-header-date');
|
||||
var subjectNode = msgNode.querySelector('.msg-header-subject');
|
||||
var snippetNode = msgNode.querySelector('.msg-header-snippet');
|
||||
|
||||
var listPerson;
|
||||
if (this.isIncomingFolder) {
|
||||
listPerson = message.author;
|
||||
// XXX This is not to UX spec, but this is a stop-gap and that would
|
||||
// require adding strings which we cannot justify as a slipstream fix.
|
||||
} else if (message.to && message.to.length) {
|
||||
listPerson = message.to[0];
|
||||
} else if (message.cc && message.cc.length) {
|
||||
listPerson = message.cc[0];
|
||||
} else if (message.bcc && message.bcc.length) {
|
||||
listPerson = message.bcc[0];
|
||||
} else {
|
||||
listPerson = message.author;
|
||||
}
|
||||
|
||||
// author
|
||||
listPerson.element =
|
||||
msgNode.querySelector('.msg-header-author');
|
||||
listPerson.onchange = updatePeepDom;
|
||||
listPerson.onchange(listPerson);
|
||||
// date
|
||||
var dateTime = dateNode.dataset.time = message.date.valueOf();
|
||||
date.relativeDateElement(dateNode, dateTime);
|
||||
|
||||
// subject
|
||||
messageDisplay.subject(msgNode.querySelector('.msg-header-subject'),
|
||||
message);
|
||||
|
||||
// attachments (can't change within a message but can change between
|
||||
// messages, and since we reuse DOM nodes...)
|
||||
var attachmentsNode = msgNode.querySelector('.msg-header-attachments');
|
||||
attachmentsNode.classList.toggle('msg-header-attachments-yes',
|
||||
message.hasAttachments);
|
||||
// snippet needs to be shorter if icon is shown
|
||||
snippetNode.classList.toggle('icon-short', message.hasAttachments);
|
||||
|
||||
// snippet
|
||||
snippetNode.textContent = message.snippet;
|
||||
|
||||
// update styles throughout the node for read vs unread
|
||||
msgNode.classList.toggle('unread', !message.isRead);
|
||||
|
||||
// star
|
||||
var starNode = msgNode.querySelector('.msg-header-star');
|
||||
|
||||
starNode.classList.toggle('msg-header-star-starred', message.isStarred);
|
||||
// subject needs to give space for star if it is visible
|
||||
subjectNode.classList.toggle('icon-short', message.isStarred);
|
||||
|
||||
// sync status
|
||||
var syncNode =
|
||||
msgNode.querySelector('.msg-header-syncing-section');
|
||||
|
||||
// sendState is only intended for outbox messages, so not all
|
||||
// messages will have sendStatus defined.
|
||||
var sendState = message.sendStatus && message.sendStatus.state;
|
||||
|
||||
syncNode.classList.toggle('msg-header-syncing-section-syncing',
|
||||
sendState === 'sending');
|
||||
syncNode.classList.toggle('msg-header-syncing-section-error',
|
||||
sendState === 'error');
|
||||
|
||||
// Set the accessible label for the syncNode.
|
||||
if (sendState) {
|
||||
mozL10n.setAttributes(syncNode, 'message-header-state-' + sendState);
|
||||
} else {
|
||||
syncNode.removeAttribute('data-l10n-id');
|
||||
}
|
||||
|
||||
// edit mode select state, defined in lst/edit_controller
|
||||
this.updateDomSelectState(msgNode, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when the folder picker is animating to close. Need to
|
||||
* listen for it so this card can animate fading in the header menu.
|
||||
*/
|
||||
onFolderPickerClosing: function() {
|
||||
this.headerMenuNode.classList.remove('transparent');
|
||||
},
|
||||
|
||||
/**
|
||||
* Listener called when a folder is shown. The listener emits an
|
||||
* 'inboxShown' for the current account, if the inbox is really being shown
|
||||
* and the app is visible. Useful if periodic sync is involved, and
|
||||
* notifications need to be closed if the inbox is visible to the user.
|
||||
*/
|
||||
onFolderShown: function() {
|
||||
var model = this.model,
|
||||
account = model.account,
|
||||
foldersSlice = model.foldersSlice;
|
||||
|
||||
// The extra checks here are to allow for lazy startup when we might have
|
||||
// a card instance but not a full model available. Once the model is
|
||||
// available though, this method will get called again, so the event
|
||||
// emitting is still correctly done in the lazy startup case.
|
||||
if (!document.hidden && account && foldersSlice && this.curFolder) {
|
||||
var inboxFolder = foldersSlice.getFirstFolderWithType('inbox');
|
||||
if (inboxFolder === this.curFolder) {
|
||||
evt.emit('inboxShown', account.id);
|
||||
}
|
||||
|
||||
// If user tapped in search box on message_list before the JS for the
|
||||
// card is attached, then treat that as the signal to go to search. Only
|
||||
// do this when first starting up though.
|
||||
if (document.activeElement === this.searchTextTease) {
|
||||
this.onSearchButton();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* An API method for the cards infrastructure, that Cards will call when the
|
||||
* page visibility changes and this card is the currently displayed card.
|
||||
*/
|
||||
onCurrentCardDocumentVisibilityChange: function() {
|
||||
this.onFolderShown();
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by Cards when the instance of this card type is the
|
||||
* visible card.
|
||||
*/
|
||||
onCardVisible: function() {
|
||||
if (this._whenVisible) {
|
||||
var fn = this._whenVisible;
|
||||
this._whenVisible = null;
|
||||
fn();
|
||||
}
|
||||
|
||||
// In case the vScroll was initialized when the card was not visible, like
|
||||
// in an activity/notification flow when this card is created in the
|
||||
// background behind the compose/reader card, let it know it is visible
|
||||
// now in case it needs to finish initializing and initial display.
|
||||
this.msgVScroll.vScroll.nowVisible();
|
||||
|
||||
// On first construction, or if done in background,
|
||||
// this card would not be visible to do the last sync
|
||||
// sizing so be sure to check it now.
|
||||
this.sizeLastSync();
|
||||
},
|
||||
|
||||
/**
|
||||
* The outbox has a special role in the message_list, compared to
|
||||
* other folders. We don't expect to synchronize the outbox with the
|
||||
* server, but we do allow the user to use the refresh button to
|
||||
* trigger all of the outbox messages to send.
|
||||
*
|
||||
* While they're sending, we need to display several spinny refresh
|
||||
* icons: One next to each message while it's queued for sending,
|
||||
* and also the main refresh button.
|
||||
*
|
||||
* However, the outbox send operation doesn't happen all in one go;
|
||||
* the backend only fires one 'sendOutboxMessages' at a time,
|
||||
* iterating through the pending messages. Fortunately, it notifies
|
||||
* the frontend (via `onBackgroundSendStatus`) whenever the state of
|
||||
* any message changes, and it provides a flag to let us know
|
||||
* whether or not the outbox sync is fully complete.
|
||||
*
|
||||
* So the workflow for outbox's refresh UI display is as follows:
|
||||
*
|
||||
* 1. The user taps the "refresh" button. In response:
|
||||
*
|
||||
* 1a. Immediately make all visible refresh icons start spinning.
|
||||
*
|
||||
* 1b. Immediately kick off a 'sendOutboxMessages' job.
|
||||
*
|
||||
* 2. We will start to see send status notifications, in this
|
||||
* class's onBackgroundSendStatus notification. We listen to
|
||||
* these events as they come in, and wait until we see a
|
||||
* notification with state === 'syncDone'. We'll keep the main
|
||||
* refresh icon spinning throughout this process.
|
||||
*
|
||||
* 3. As messages send or error out, we will receive slice
|
||||
* notifications for each message (handled here in `messages_change`).
|
||||
* Since each message holds its own status as `header.sendStatus`,
|
||||
* we don't need to do anything special; the normal rendering logic
|
||||
* will reset each message's status icon to the appropriate state.
|
||||
*
|
||||
* But don't take my word for it; see `jobs/outbox.js` and
|
||||
* `jobmixins.js` in GELAM for backend-centric descriptions of how
|
||||
* the outbox sending process works.
|
||||
*/
|
||||
toggleOutboxSyncingDisplay: function(syncing) {
|
||||
// Use an internal guard so that we only trigger changes to the UI
|
||||
// when necessary, rather than every time, which could break animations.
|
||||
if (syncing === this._outboxSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._outboxSyncing = syncing;
|
||||
|
||||
var i;
|
||||
var items = this.msgVScroll.getElementsByClassName(
|
||||
'msg-header-syncing-section');
|
||||
|
||||
if (syncing) {
|
||||
// For maximum perceived responsiveness, show the spinning icons
|
||||
// next to each message immediately, rather than waiting for the
|
||||
// backend to actually start sending each message. When the
|
||||
// backend reports back with message results, it'll update the
|
||||
// icon to reflect the proper result.
|
||||
for (i = 0; i < items.length; i++) {
|
||||
items[i].classList.add('msg-header-syncing-section-syncing');
|
||||
items[i].classList.remove('msg-header-syncing-section-error');
|
||||
}
|
||||
|
||||
this.editBtn.disabled = true;
|
||||
} else {
|
||||
// After sync, the edit button should remain disabled only if
|
||||
// the list is empty.
|
||||
this.editBtn.disabled = this.msgVScroll.isEmpty();
|
||||
|
||||
// Similarly, we must stop the refresh icons for each message
|
||||
// from rotating further. For instance, if we are offline, we
|
||||
// won't actually attempt to send any of those messages, so
|
||||
// they'll still have a spinny icon until we forcibly remove it.
|
||||
for (i = 0; i < items.length; i++) {
|
||||
items[i].classList.remove('msg-header-syncing-section-syncing');
|
||||
}
|
||||
}
|
||||
this.setRefreshState(syncing);
|
||||
},
|
||||
|
||||
onRefresh: function() {
|
||||
var headerCursor = this.headerCursor;
|
||||
|
||||
if (!headerCursor.messagesSlice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is the outbox, refresh has a different meaning.
|
||||
if (this.curFolder.type === 'outbox') {
|
||||
// Rather than refreshing the folder, we'll send the pending
|
||||
// outbox messages, and spin the refresh icon while doing so.
|
||||
this.toggleOutboxSyncingDisplay(true);
|
||||
}
|
||||
// If this is a normal folder...
|
||||
else {
|
||||
switch (headerCursor.messagesSlice.status) {
|
||||
// If we're still synchronizing, then the user is not well served by
|
||||
// queueing a refresh yet, let's just squash this.
|
||||
case 'new':
|
||||
case 'synchronizing':
|
||||
break;
|
||||
// If we fully synchronized, then yes, let us refresh.
|
||||
case 'synced':
|
||||
this._manuallyTriggeredSync = true;
|
||||
headerCursor.messagesSlice.refresh();
|
||||
break;
|
||||
// If we failed to talk to the server, then let's only do a refresh if
|
||||
// we know about any messages. Otherwise let's just create a new slice
|
||||
// by forcing reentry into the folder.
|
||||
case 'syncfailed':
|
||||
if (headerCursor.messagesSlice.items.length) {
|
||||
headerCursor.messagesSlice.refresh();
|
||||
} else {
|
||||
this.showFolder(this.curFolder, /* force new slice */ true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Even if we're not actually viewing the outbox right now, we
|
||||
// should still attempt to sync any pending messages. It's fairly
|
||||
// harmless to kick off this job here, but it could also make
|
||||
// sense to do this at the backend level. There are a number of
|
||||
// cases where we might also want to sendOutboxMessages() if
|
||||
// we follow up with a more comprehensive sync setting -- e.g. on
|
||||
// network change, on app startup, etc., so it's worth revisiting
|
||||
// this and how coupled we want incoming vs outgoing sync to be.
|
||||
this.model.api.sendOutboxMessages(this.model.account);
|
||||
},
|
||||
|
||||
_folderChanged: function(folder) {
|
||||
// It is possible that the notification of latest folder is fired
|
||||
// but in the meantime the foldersSlice could be cleared due to
|
||||
// a change in the current account, before this listener is called.
|
||||
// So skip this work if no foldersSlice, this method will be called
|
||||
// again soon.
|
||||
if (!this.model.foldersSlice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Folder could have changed because account changed. Make sure
|
||||
// the cacheableFolderId is still set correctly.
|
||||
var model = this.model;
|
||||
var inboxFolder = model.foldersSlice.getFirstFolderWithType('inbox');
|
||||
this.cacheableFolderId =
|
||||
model.account === model.acctsSlice.defaultAccount ?
|
||||
inboxFolder.id : null;
|
||||
|
||||
if (this.showFolder(folder)) {
|
||||
this._hideSearchBoxByScrolling();
|
||||
}
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this.msgVScroll.die();
|
||||
|
||||
evt.removeListener('folderPickerClosing', this.onFolderPickerClosing);
|
||||
|
||||
if (this.headerCursor) {
|
||||
this.headerCursor.die();
|
||||
}
|
||||
|
||||
var model = this.model;
|
||||
model.removeListener('folder', this._folderChanged);
|
||||
model.removeListener('newInboxMessages', this.onNewMail);
|
||||
model.removeListener('foldersSliceOnChange', this.onFoldersSliceChange);
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
81
gaia/apps/email/js/cards/message_list_search.html
Executable file
81
gaia/apps/email/js/cards/message_list_search.html
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
<!-- Search header -->
|
||||
<section data-prop="searchHeader"
|
||||
class="msg-search-header"
|
||||
role="region">
|
||||
<form role="search" data-event="submit:onSearchSubmit">
|
||||
<button data-event="click:onCancelSearch"
|
||||
class="msg-search-cancel" type="submit"
|
||||
data-l10n-id="message-search-cancel"></button>
|
||||
<p>
|
||||
<input data-prop="searchInput" data-event="input:onSearchTextChange"
|
||||
type="text" required="required" class="msg-search-text"
|
||||
autocorrect="off"
|
||||
inputmode="verbatim"
|
||||
x-inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-l10n-id="message-search-input" />
|
||||
<button type="reset"
|
||||
data-l10n-id="form-clear-input"
|
||||
data-event="click:onClearSearch"></button>
|
||||
</p>
|
||||
</form>
|
||||
<!-- Search filter switcher -->
|
||||
<header class="msg-search-controls-bar">
|
||||
<ul role="tablist" class="bb-tablist filter" data-type="filter">
|
||||
<li role="presentation" class="msg-search-from msg-search-filter"
|
||||
data-filter="author">
|
||||
<a data-l10n-id="message-search-from" role="tab"
|
||||
aria-selected="false"></a></li>
|
||||
<li role="presentation" class="msg-search-to msg-search-filter"
|
||||
data-filter="recipients">
|
||||
<a data-l10n-id="message-search-to" role="tab"
|
||||
aria-selected="false"></a></li>
|
||||
<li role="presentation" class="msg-search-subject msg-search-filter"
|
||||
data-filter="subject">
|
||||
<a data-l10n-id="message-search-subject" role="tab"
|
||||
aria-selected="false"></a>
|
||||
</li>
|
||||
<li role="presentation" class="msg-search-body msg-search-filter"
|
||||
data-filter="body">
|
||||
<a data-l10n-id="message-search-body" role="tab"
|
||||
aria-selected="false"></a></li>
|
||||
<li role="presentation" class="msg-search-body msg-search-filter"
|
||||
data-filter="all">
|
||||
<a data-l10n-id="message-search-all" role="tab"
|
||||
aria-selected="true"></a></li>
|
||||
</ul>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<!-- Multi-edit state header -->
|
||||
<lst-edit-header data-prop="editHeader"
|
||||
data-event="editHeaderClose"></lst-edit-header>
|
||||
|
||||
<!-- Scroll region -->
|
||||
<div data-prop="scrollContainer" class="msg-list-scrollouter">
|
||||
<!-- exists so we can force a minimum height -->
|
||||
<div class="msg-list-scrollinner">
|
||||
<lst-msg-vscroll data-prop="msgVScroll"
|
||||
data-event="messageClick:onClickMessage"
|
||||
data-empty-l10n-id="messages-search-empty">
|
||||
</lst-msg-vscroll>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New email notification bar -->
|
||||
<div class="message-list-topbar"></div>
|
||||
|
||||
<!-- Toolbar for non-multi-edit state -->
|
||||
<ul data-prop="normalToolbar" class="bb-tablist msg-list-action-toolbar"
|
||||
role="toolbar">
|
||||
<li role="presentation">
|
||||
<button data-prop="editBtn" data-event="click:setEditModeStart"
|
||||
class="icon msg-edit-btn" data-l10n-id="edit-button"></button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<lst-edit-toolbar data-prop="editToolbar"
|
||||
data-event="onDeleteMessages,onStarMessages,
|
||||
onMarkMessagesRead,onMoveMessages">
|
||||
</lst-edit-toolbar>
|
||||
|
||||
323
gaia/apps/email/js/cards/message_list_search.js
Executable file
323
gaia/apps/email/js/cards/message_list_search.js
Executable file
|
|
@ -0,0 +1,323 @@
|
|||
'use strict';
|
||||
|
||||
define(function(require, exports) {
|
||||
|
||||
var accessibilityHelper = require('shared/js/accessibility_helper'),
|
||||
cards = require('cards'),
|
||||
containerListen = require('container_listen'),
|
||||
date = require('date'),
|
||||
HeaderCursor = require('header_cursor'),
|
||||
messageDisplay = require('message_display'),
|
||||
MessageListTopBar = require('message_list_topbar'),
|
||||
updatePeepDom = require('./lst/peep_dom').update;
|
||||
|
||||
// We will display this loading data for any messages we are
|
||||
// pretending exist so that the UI has a reason to poke the search
|
||||
// slice to do more work.
|
||||
var defaultSearchVScrollData = {
|
||||
header: require('./lst/default_vscroll_data'),
|
||||
matches: []
|
||||
};
|
||||
|
||||
var MATCHED_TEXT_CLASS = 'highlight';
|
||||
|
||||
function appendMatchItemTo(matchItem, node) {
|
||||
var text = matchItem.text;
|
||||
var idx = 0;
|
||||
for (var iRun = 0; iRun <= matchItem.matchRuns.length; iRun++) {
|
||||
var run;
|
||||
if (iRun === matchItem.matchRuns.length) {
|
||||
run = { start: text.length, length: 0 };
|
||||
} else {
|
||||
run = matchItem.matchRuns[iRun];
|
||||
}
|
||||
|
||||
// generate the un-highlighted span
|
||||
if (run.start > idx) {
|
||||
var tnode = document.createTextNode(text.substring(idx, run.start));
|
||||
node.appendChild(tnode);
|
||||
}
|
||||
|
||||
if (!run.length) {
|
||||
continue;
|
||||
}
|
||||
var hspan = document.createElement('span');
|
||||
hspan.classList.add(MATCHED_TEXT_CLASS);
|
||||
hspan.textContent = text.substr(run.start, run.length);
|
||||
node.appendChild(hspan);
|
||||
idx = run.start + run.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./message_list_search.html')),
|
||||
require('./lst/edit_controller'),
|
||||
require('./lst/msg_click'),
|
||||
|
||||
{
|
||||
|
||||
createdCallback: function() {
|
||||
containerListen(this.querySelector('.filter'), 'click',
|
||||
this.onSearchFilterClick.bind(this));
|
||||
this.searchFilterTabs = this.querySelectorAll('.filter [role="tab"]');
|
||||
|
||||
this.isFirstTimeVisible = true;
|
||||
|
||||
// Binding "this" to some functions as they are used for event listeners.
|
||||
this._folderChanged = this._folderChanged.bind(this);
|
||||
|
||||
this.msgVScroll.on('emptyLayoutShown', function() {
|
||||
this.editBtn.disabled = true;
|
||||
}.bind(this));
|
||||
|
||||
this.msgVScroll.on('emptyLayoutHidden', function() {
|
||||
this.editBtn.disabled = false;
|
||||
}.bind(this));
|
||||
|
||||
this.msgVScroll.on('messagesChange', function(message, index) {
|
||||
this.updateMatchedMessageDom(message);
|
||||
}.bind(this));
|
||||
|
||||
this.msgVScroll.on('messagesComplete', function(newEmailCount) {
|
||||
// Search does not trigger normal conditions for a folder changed,
|
||||
// so if vScroll is missing its data, set it up now.
|
||||
if (!this.msgVScroll.vScroll.list) {
|
||||
this.msgVScroll.vScroll.setData(this.msgVScroll.listFunc);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
var vScrollBindData = (function bindSearch(model, node) {
|
||||
model.element = node;
|
||||
node.message = model.header;
|
||||
this.updateMatchedMessageDom(model);
|
||||
}).bind(this);
|
||||
this.msgVScroll.init(this.scrollContainer,
|
||||
vScrollBindData,
|
||||
defaultSearchVScrollData);
|
||||
|
||||
this._topBar = new MessageListTopBar(
|
||||
this.querySelector('.message-list-topbar')
|
||||
);
|
||||
this._topBar.bindToElements(this.scrollContainer,
|
||||
this.msgVScroll.vScroll);
|
||||
},
|
||||
|
||||
onArgs: function(args) {
|
||||
var model = this.model = args.model;
|
||||
var headerCursor = this.headerCursor = args.headerCursor ||
|
||||
new HeaderCursor(model);
|
||||
this.msgVScroll.setHeaderCursor(headerCursor);
|
||||
|
||||
model.latest('folder', this._folderChanged);
|
||||
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
// First time this card is visible, want the search field focused if this
|
||||
// is a search. Do not want to do it on every cardVisible, as the user
|
||||
// could be scrolled/have their own place in the search results, and are
|
||||
// likely going back and forth between this card and message_reader.
|
||||
if (this.isFirstTimeVisible) {
|
||||
this.searchInput.focus();
|
||||
}
|
||||
|
||||
this.msgVScroll.vScroll.nowVisible();
|
||||
|
||||
this.isFirstTimeVisible = false;
|
||||
},
|
||||
|
||||
|
||||
showSearch: function(phrase, filter) {
|
||||
console.log('sf: showSearch. phrase:', phrase, phrase.length);
|
||||
|
||||
this.curFolder = this.model.folder;
|
||||
this.editModeEnabled = true;
|
||||
this.msgVScroll.vScroll.clearDisplay();
|
||||
this.curPhrase = phrase;
|
||||
this.curFilter = filter;
|
||||
|
||||
// We are creating a new slice, so any pending snippet requests are moot.
|
||||
this.msgVScroll._snippetRequestPending = false;
|
||||
// Don't bother the new slice with requests until we hears it completion
|
||||
// event.
|
||||
this.msgVScroll.waitingOnChunk = true;
|
||||
this.headerCursor.startSearch(phrase, {
|
||||
author: filter === 'all' || filter === 'author',
|
||||
recipients: filter === 'all' || filter === 'recipients',
|
||||
subject: filter === 'all' || filter === 'subject',
|
||||
body: filter === 'all' || filter === 'body'
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
onSearchFilterClick: function(filterNode, event) {
|
||||
accessibilityHelper.setAriaSelected(filterNode.firstElementChild,
|
||||
this.searchFilterTabs);
|
||||
this.showSearch(this.searchInput.value, filterNode.dataset.filter);
|
||||
},
|
||||
|
||||
onSearchTextChange: function(event) {
|
||||
console.log('sf: typed, now:', this.searchInput.value);
|
||||
this.showSearch(this.searchInput.value, this.curFilter);
|
||||
},
|
||||
|
||||
onSearchSubmit: function(event) {
|
||||
// Not a real form to submit, so stop actual submission.
|
||||
event.preventDefault();
|
||||
|
||||
// Blur the focus away from the text input. This has the effect of hiding
|
||||
// the keyboard. This is useful for the two cases where this function is
|
||||
// currently triggered: Enter on the keyboard or Cancel on form submit.
|
||||
// Note that the Cancel button has a type="submit", which is technically
|
||||
// an incorrect use of that type. However the /shared styles depend on it
|
||||
// being marked as such for style reasons.
|
||||
this.searchInput.blur();
|
||||
},
|
||||
|
||||
onCancelSearch: function(event) {
|
||||
// Only care about real clicks on actual button, not fake ones triggered
|
||||
// by a form submit from the Enter button on the keyboard.
|
||||
// Note: the cancel button should not really be a type="submit", but it is
|
||||
// that way because the /shared styles for this search form wants to see
|
||||
// a submit. Longer term this should be changed in the /shared components.
|
||||
// This event test is used because in form submit cases, the
|
||||
// explicitOriginalTarget (the text input) is not the same as the target
|
||||
// (the button).
|
||||
if (event.explicitOriginalTarget !== event.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.headerCursor.endSearch();
|
||||
}
|
||||
catch (ex) {
|
||||
console.error('problem killing slice:', ex, '\n', ex.stack);
|
||||
}
|
||||
cards.removeCardAndSuccessors(this, 'animate');
|
||||
},
|
||||
|
||||
onClearSearch: function() {
|
||||
this.showSearch('', this.curFilter);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called from edit_controller mixin when the edit mode has changed. Used to
|
||||
* allow classes mixing in edit_controller to update UI state based on edit
|
||||
* state.
|
||||
*/
|
||||
editModeChanged: function(enabled) {
|
||||
if (enabled) {
|
||||
// Set status color to match the edit header.
|
||||
cards.setStatusColor(this.editHeader);
|
||||
} else {
|
||||
// Return the status color to match this card.
|
||||
cards.setStatusColor(this);
|
||||
}
|
||||
},
|
||||
|
||||
updateMatchedMessageDom: function(matchedHeader) {
|
||||
var msgNode = matchedHeader.element,
|
||||
matches = matchedHeader.matches,
|
||||
message = matchedHeader.header;
|
||||
|
||||
if (!msgNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the placeholder data, indicate that in case VScroll
|
||||
// wants to go back and fix later.
|
||||
var classAction = message.isPlaceholderData ? 'add' : 'remove';
|
||||
var defaultDataClass = this.msgVScroll.vScroll.itemDefaultDataClass;
|
||||
msgNode.classList[classAction](defaultDataClass);
|
||||
|
||||
// Even though updateMatchedMessageDom is only used in searches,
|
||||
// which likely will not be cached, the dataset.is is set to
|
||||
// maintain parity withe updateMessageDom and so click handlers
|
||||
// can always just use the dataset property.
|
||||
msgNode.dataset.id = matchedHeader.id;
|
||||
|
||||
// some things only need to be done once
|
||||
var dateNode = msgNode.querySelector('.msg-header-date');
|
||||
var subjectNode = msgNode.querySelector('.msg-header-subject');
|
||||
|
||||
// author
|
||||
var authorNode = msgNode.querySelector('.msg-header-author');
|
||||
if (matches.author) {
|
||||
authorNode.textContent = '';
|
||||
appendMatchItemTo(matches.author, authorNode);
|
||||
}
|
||||
else {
|
||||
// we can only update the name if it wasn't matched on.
|
||||
message.author.element = authorNode;
|
||||
message.author.onchange = updatePeepDom;
|
||||
message.author.onchange(message.author);
|
||||
}
|
||||
|
||||
// date
|
||||
var dateTime = dateNode.dataset.time = message.date.valueOf();
|
||||
date.relativeDateElement(dateNode, dateTime);
|
||||
|
||||
// subject
|
||||
if (matches.subject) {
|
||||
subjectNode.textContent = '';
|
||||
appendMatchItemTo(matches.subject[0], subjectNode);
|
||||
} else {
|
||||
messageDisplay.subject(subjectNode, message);
|
||||
}
|
||||
|
||||
// snippet
|
||||
var snippetNode = msgNode.querySelector('.msg-header-snippet');
|
||||
if (matches.body) {
|
||||
snippetNode.textContent = '';
|
||||
appendMatchItemTo(matches.body[0], snippetNode);
|
||||
} else {
|
||||
snippetNode.textContent = message.snippet;
|
||||
}
|
||||
|
||||
// attachments (can't change within a message but can change between
|
||||
// messages, and since we reuse DOM nodes...)
|
||||
var attachmentsNode =
|
||||
msgNode.querySelector('.msg-header-attachments');
|
||||
attachmentsNode.classList.toggle('msg-header-attachments-yes',
|
||||
message.hasAttachments);
|
||||
// snippet needs to be shorter if icon is shown
|
||||
snippetNode.classList.toggle('icon-short', message.hasAttachments);
|
||||
|
||||
// Set unread state.
|
||||
msgNode.classList.toggle('unread', !message.isRead);
|
||||
|
||||
// star
|
||||
var starNode = msgNode.querySelector('.msg-header-star');
|
||||
starNode.classList.toggle('msg-header-star-starred', message.isStarred);
|
||||
// subject needs to give space for star if it is visible
|
||||
subjectNode.classList.toggle('icon-short', message.isStarred);
|
||||
|
||||
// edit mode select state, defined in lst/edit_controller
|
||||
this.updateDomSelectState(msgNode, message);
|
||||
},
|
||||
|
||||
_folderChanged: function(folder) {
|
||||
// It is possible that the notification of latest folder is fired
|
||||
// but in the meantime the foldersSlice could be cleared due to
|
||||
// a change in the current account, before this listener is called.
|
||||
// So skip this work if no foldersSlice, this method will be called
|
||||
// again soon.
|
||||
if (!this.model.foldersSlice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.curFolder = folder;
|
||||
|
||||
this.showSearch('', 'all');
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this.msgVScroll.die();
|
||||
this.model.removeListener('folder', this._folderChanged);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
});
|
||||
92
gaia/apps/email/js/cards/message_reader.html
Executable file
92
gaia/apps/email/js/cards/message_reader.html
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
<section class="msg-reader-header" role="region" data-statuscolor="default">
|
||||
<header>
|
||||
<a data-event="click:onBack" href="#" class="msg-back-btn"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<menu type="toolbar">
|
||||
<button data-prop="previousBtn" class="msg-up-btn"
|
||||
data-l10n-id="message-reader-next-button">
|
||||
<span data-prop="previousIcon" class="icon icon-up"></span>
|
||||
</button>
|
||||
<button data-prop="nextBtn" class="msg-down-btn"
|
||||
data-l10n-id="message-reader-previous-button">
|
||||
<span data-prop="nextIcon" class="icon icon-down"></span>
|
||||
</button>
|
||||
</menu>
|
||||
<h1 dir="auto" class="msg-reader-header-label"></h1>
|
||||
</header>
|
||||
</section>
|
||||
<div data-prop="scrollContainer"
|
||||
class="scrollregion-below-header scrollregion-horizontal-too">
|
||||
<div data-prop="envelopeBar" class="msg-envelope-bar">
|
||||
<div class="msg-envelope-line msg-envelope-from-line"
|
||||
role="listbox" data-l10n-id="from-group">
|
||||
<span class="msg-envelope-key"
|
||||
data-l10n-id="envelope-from" aria-hidden="true"></span>
|
||||
</div>
|
||||
<!-- the details starts out collapsed, but can be toggled -->
|
||||
<div class="msg-envelope-details">
|
||||
<div class="msg-envelope-line msg-envelope-to-line"
|
||||
role="listbox" data-l10n-id="to-group">
|
||||
<span class="msg-envelope-key"
|
||||
data-l10n-id="envelope-to" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="msg-envelope-line msg-envelope-cc-line"
|
||||
role="listbox" data-l10n-id="cc-group">
|
||||
<span class="msg-envelope-key"
|
||||
data-l10n-id="envelope-cc" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="msg-envelope-line msg-envelope-bcc-line"
|
||||
role="listbox" data-l10n-id="bcc-group">
|
||||
<span class="msg-envelope-key"
|
||||
data-l10n-id="envelope-bcc" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg-envelope-subject-container">
|
||||
<h3 aria-level="2" class="msg-envelope-subject"
|
||||
data-l10n-id="subject" dir="auto"></h3>
|
||||
<span role="heading" aria-level="3" class="msg-envelope-date"
|
||||
data-l10n-id="message-reader-date-received"></span>
|
||||
</div>
|
||||
</div>
|
||||
<ul data-prop="attachmentsContainer"
|
||||
class="msg-attachments-container collapsed"
|
||||
data-l10n-id="attachments-container">
|
||||
</ul>
|
||||
<!-- Tells us about remote/not downloaded images, asks to show -->
|
||||
<div data-prop="loadBar" class="msg-reader-load-infobar collapsed"
|
||||
role="button">
|
||||
<p data-prop="loadBarText" class="msg-reader-load-infobar-text"></p>
|
||||
<button class="msg-reader-load-infobar-button"
|
||||
aria-hidden="true"></button>
|
||||
</div>
|
||||
<div data-prop="rootBodyNode"
|
||||
class="msg-body-container"
|
||||
data-l10n-id="message-body-container">
|
||||
<progress data-l10n-id="message-body-container-progress"></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="bb-tablist msg-reader-action-toolbar" role="toolbar">
|
||||
<li role="presentation">
|
||||
<button data-prop="deleteBtn" class="icon msg-delete-btn"
|
||||
data-l10n-id="message-delete-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="starBtn" class="icon msg-star-btn"
|
||||
data-l10n-id="message-star-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="readBtn" class="icon msg-mark-read-btn"
|
||||
data-l10n-id="message-mark-read-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="moveBtn" class="icon msg-move-btn"
|
||||
data-l10n-id="message-move-button"></button>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<button data-prop="replyBtn" class="icon msg-reply-btn"
|
||||
data-l10n-id="message-reply-forward-button"></button>
|
||||
</li>
|
||||
</ul>
|
||||
1198
gaia/apps/email/js/cards/message_reader.js
Executable file
1198
gaia/apps/email/js/cards/message_reader.js
Executable file
File diff suppressed because it is too large
Load diff
50
gaia/apps/email/js/cards/mixins/data-event.js
Executable file
50
gaia/apps/email/js/cards/mixins/data-event.js
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
'use strict';
|
||||
define(function () {
|
||||
var slice = Array.prototype.slice;
|
||||
|
||||
return {
|
||||
templateInsertedCallback: function () {
|
||||
slice.call(this.querySelectorAll('[data-event]'))
|
||||
.forEach(function (node) {
|
||||
var parent = node;
|
||||
// Make sure the node is not nested in another component.
|
||||
while ((parent = parent.parentNode)) {
|
||||
if (parent.nodeName.indexOf('-') !== -1) {
|
||||
if (parent !== this) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Value is of type 'name:value,name:value',
|
||||
// with the :value part optional.
|
||||
node.dataset.event.split(',').forEach(function (pair) {
|
||||
var evtName, method,
|
||||
parts = pair.split(':');
|
||||
|
||||
if (!parts[1]) {
|
||||
parts[1] = parts[0];
|
||||
}
|
||||
evtName = parts[0].trim();
|
||||
method = parts[1].trim();
|
||||
|
||||
if (typeof this[method] !== 'function') {
|
||||
throw new Error('"' + method +
|
||||
'" is not a function, cannot bind with data-event');
|
||||
}
|
||||
|
||||
node.addEventListener(evtName, function(evt) {
|
||||
// Treat these events as private to the
|
||||
// custom element.
|
||||
evt.stopPropagation();
|
||||
return this[method](evt);
|
||||
}.bind(this), false);
|
||||
}.bind(this));
|
||||
}.bind(this));
|
||||
}
|
||||
};
|
||||
});
|
||||
28
gaia/apps/email/js/cards/mixins/data-prop.js
Executable file
28
gaia/apps/email/js/cards/mixins/data-prop.js
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
'use strict';
|
||||
define(function () {
|
||||
return {
|
||||
templateInsertedCallback: function () {
|
||||
var node, parent,
|
||||
nodes = this.querySelectorAll('[data-prop]'),
|
||||
length = nodes.length;
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
// Make sure the node is not nested in another component.
|
||||
node = nodes[i];
|
||||
parent = node;
|
||||
while ((parent = parent.parentNode)) {
|
||||
if (parent.nodeName.indexOf('-') !== -1) {
|
||||
if (parent !== this) {
|
||||
node = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (node && parent) {
|
||||
this[node.dataset.prop] = nodes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
39
gaia/apps/email/js/cards/mixins/dom_evt.js
Executable file
39
gaia/apps/email/js/cards/mixins/dom_evt.js
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
define(function () {
|
||||
/**
|
||||
* domEvt can be used with data-event="" to translate internal template
|
||||
* events into custom events emitted from the custom element that contains
|
||||
* the template element that triggered the event. Use like so in the template:
|
||||
*
|
||||
* <a href="#" data-event="click:domEvt" data-domevt-name="headerClose">
|
||||
*
|
||||
* When that a tag receives a click event (from itself or its children) then
|
||||
* domEvt will convert that to a custom event "headerClose", dispatched on the
|
||||
* custom element.
|
||||
*
|
||||
* data-domevt-detail="something" allows passing a "detail" string in the
|
||||
* custom event that will have the value of "something".
|
||||
*/
|
||||
return {
|
||||
domEvt: function (event) {
|
||||
var target = event.currentTarget,
|
||||
eventName = target.dataset.domevtName,
|
||||
eventData = {},
|
||||
eventDetail = target.dataset.domevtDetail;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!eventName) {
|
||||
throw new Error('No data-domevt-name on element <' +
|
||||
target.nodeName + ' class="' + target.className + '">');
|
||||
}
|
||||
|
||||
if (eventDetail) {
|
||||
eventData.detail = eventDetail;
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent(eventName, eventData));
|
||||
}
|
||||
};
|
||||
});
|
||||
9
gaia/apps/email/js/cards/msg/attach_confirm.html
Executable file
9
gaia/apps/email/js/cards/msg/attach_confirm.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<form role="dialog" class="msg-attach-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1></h1>
|
||||
<p></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-attach-ok" class="full" data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
9
gaia/apps/email/js/cards/msg/attachment_did_not_open_alert.html
Executable file
9
gaia/apps/email/js/cards/msg/attachment_did_not_open_alert.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<form role="dialog" class="msg-attachment-did-not-open" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="message-attachment-did-not-open-label"></h1>
|
||||
<p><span data-l10n-id="message-attachment-did-not-open-body"></span></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-attachment-did-not-open-ok" data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
9
gaia/apps/email/js/cards/msg/attachment_disabled_confirm.html
Executable file
9
gaia/apps/email/js/cards/msg/attachment_disabled_confirm.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<form role="dialog" class="msg-attachment-disabled-confirm" data-type="confirm">
|
||||
<section>
|
||||
<p data-l10n-id="message-send-attachment-disabled-confirm"></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-attachment-disabled-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
<button id="msg-attachment-disabled-ok" data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
17
gaia/apps/email/js/cards/msg/attachment_item.html
Executable file
17
gaia/apps/email/js/cards/msg/attachment_item.html
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
<li class="msg-attachment-item">
|
||||
<span class="msg-attachment-icon" role="presentation"></span>
|
||||
<span class="msg-attachment-fileinfo">
|
||||
<span dir="auto" class="msg-attachment-filename"></span>
|
||||
<span class="msg-attachment-filesize"></span>
|
||||
<span data-l10n-id="message-attachment-too-large"
|
||||
class="msg-attachment-too-large"></span>
|
||||
</span>
|
||||
<button class="msg-attachment-download" data-l10n-id="download-button">
|
||||
<span class="icon icon-download"></span></button>
|
||||
<span class="msg-attachment-downloading">
|
||||
<progress class="small" data-l10n-id="downloading-progress"></progress>
|
||||
</span>
|
||||
<button class="msg-attachment-view" data-l10n-id="view-button">
|
||||
<span data-l10n-id="message-attachment-view" class="icon icon-view"></span>
|
||||
</button>
|
||||
</li>
|
||||
10
gaia/apps/email/js/cards/msg/browse_confirm.html
Executable file
10
gaia/apps/email/js/cards/msg/browse_confirm.html
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
<form role="dialog" class="msg-browse-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="confirm-dialog-title"></h1>
|
||||
<p></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-browse-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
<button id="msg-browse-ok" class="recommend" data-l10n-id="dialog-button-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
19
gaia/apps/email/js/cards/msg/contact_menu.html
Executable file
19
gaia/apps/email/js/cards/msg/contact_menu.html
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
<form class="msg-contact-menu" role="dialog" data-type="action">
|
||||
<header></header>
|
||||
<menu>
|
||||
<button class="msg-contact-menu-new collapsed" data-l10n-id="message-contact-menu-new">
|
||||
</button>
|
||||
<button class="msg-contact-menu-reply collapsed" data-l10n-id="message-contact-menu-reply">
|
||||
</button>
|
||||
<button class="msg-contact-menu-view collapsed" data-l10n-id="message-contact-menu-view">
|
||||
</button>
|
||||
<button class="msg-contact-menu-create-contact collapsed" data-l10n-id="message-contact-menu-create">
|
||||
</button>
|
||||
<button class="msg-contact-menu-add-to-existing-contact collapsed" data-l10n-id="message-contact-menu-add-existing">
|
||||
</button>
|
||||
<span class="last-button-container">
|
||||
<button class="msg-contact-menu-cancel" data-l10n-id="message-multiedit-cancel">
|
||||
</button>
|
||||
</span>
|
||||
</menu>
|
||||
</form>
|
||||
10
gaia/apps/email/js/cards/msg/delete_confirm.html
Executable file
10
gaia/apps/email/js/cards/msg/delete_confirm.html
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
<form role="dialog" class="msg-delete-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="confirm-dialog-title"></h1>
|
||||
<p></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-delete-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
<button id="msg-delete-ok" class="danger" data-l10n-id="message-edit-menu-delete"></button>
|
||||
</menu>
|
||||
</form>
|
||||
19
gaia/apps/email/js/cards/msg/header_item.html
Executable file
19
gaia/apps/email/js/cards/msg/header_item.html
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
<a class="msg-header-item" role="option" tabindex="0">
|
||||
<label class="pack-checkbox negative" aria-hidden="true">
|
||||
<input type="checkbox"><span></span>
|
||||
</label>
|
||||
<div class="msg-header-details-section">
|
||||
<span dir="auto" class="msg-header-author"></span>
|
||||
<span dir="auto" class="msg-header-subject"></span>
|
||||
<span dir="auto" class="msg-header-date"></span>
|
||||
<span dir="auto" class="msg-header-snippet"></span>
|
||||
</div>
|
||||
<div class="msg-header-syncing-section"></div>
|
||||
<div class="msg-header-unread-section"
|
||||
data-l10n-id="message-header-unread"></div>
|
||||
<div class="msg-header-icons-section">
|
||||
<span class="msg-header-star" data-l10n-id="message-header-starred"></span>
|
||||
<span class="msg-header-attachments"
|
||||
data-l10n-id="message-header-attachments"></span>
|
||||
</div><div class="msg-header-avatar-section" aria-hidden="true">
|
||||
</div></a>
|
||||
10
gaia/apps/email/js/cards/msg/large_message_confirm.html
Executable file
10
gaia/apps/email/js/cards/msg/large_message_confirm.html
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
<form role="dialog" class="msg-large-message-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="confirm-dialog-title"></h1>
|
||||
<p><span data-l10n-id="message-large-message-confirm"></span></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="msg-large-message-cancel" data-l10n-id="message-large-message-cancel"></button>
|
||||
<button id="msg-large-message-ok" data-l10n-id="message-large-message-ok"></button>
|
||||
</menu>
|
||||
</form>
|
||||
3
gaia/apps/email/js/cards/msg/peep_bubble.html
Executable file
3
gaia/apps/email/js/cards/msg/peep_bubble.html
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="msg-peep-bubble peep-bubble" dir="auto" role="option">
|
||||
<span class="msg-peep-content"></span>
|
||||
</div>
|
||||
15
gaia/apps/email/js/cards/msg/reply_menu.html
Executable file
15
gaia/apps/email/js/cards/msg/reply_menu.html
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
<form class="msg-reply-menu" role="dialog" data-type="action">
|
||||
<header></header>
|
||||
<menu>
|
||||
<button class="msg-reply-menu-reply" data-l10n-id="message-reply-menu-reply">
|
||||
</button>
|
||||
<button class="msg-reply-menu-reply-all" data-l10n-id="message-reply-menu-reply-all">
|
||||
</button>
|
||||
<button class="msg-reply-menu-forward" data-l10n-id="message-reply-menu-forward">
|
||||
</button>
|
||||
<span class="last-button-container">
|
||||
<button class="msg-reply-menu-cancel" data-l10n-id="message-reply-menu-cancel">
|
||||
</button>
|
||||
</span>
|
||||
</menu>
|
||||
</form>
|
||||
228
gaia/apps/email/js/cards/oauth2/fetch.js
Executable file
228
gaia/apps/email/js/cards/oauth2/fetch.js
Executable file
|
|
@ -0,0 +1,228 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var queryString = require('query_string');
|
||||
var services = require('services');
|
||||
var cards = require('cards');
|
||||
|
||||
// All of the oauthSecrets we know.
|
||||
var oauthSecrets = services.oauth2;
|
||||
// The secrets for the oauth we're currently doing.
|
||||
var curOauthSecrets;
|
||||
// Flag to indicate the work for redeeming the auth code is still in progress,
|
||||
// so detection of the auth window closing should not cancel the operation.
|
||||
var redeemingCode = false;
|
||||
|
||||
var TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
var redirectUri = 'http://localhost';
|
||||
|
||||
var deferred, p, oauthSettings;
|
||||
|
||||
/**
|
||||
* The postMessage listener called from redirect.html to indicate the final
|
||||
* data returned from the oauth jump.
|
||||
* @param {DOMEvent} event the event sent via redirect.html's postMessage.
|
||||
*/
|
||||
function onBrowserComplete(message) {
|
||||
var data = message.data;
|
||||
if (message.type === 'oauth2Complete') {
|
||||
if (!data.code) {
|
||||
reset('reject', new Error('no code returned'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Okay, so we've got a code. And now we need to redeem that code for
|
||||
// tokens.
|
||||
console.log('oauth redirect returned with code');
|
||||
redeemingCode = true;
|
||||
redeemCode(data.code).then(
|
||||
function(redeemed) {
|
||||
// Microsoft provides expires_on too but Google only provides
|
||||
// expires_in, so let's just use expires_in for now.
|
||||
var expiresInMS = parseInt(redeemed.expires_in, 10) * 1000;
|
||||
// Make the time absolute and let's take 30 seconds off the end to
|
||||
// allow for ridiculous latency.
|
||||
var expireTimeMS = Date.now() +
|
||||
Math.max(0, expiresInMS - TIMEOUT_MS);
|
||||
var result = {
|
||||
status: 'success',
|
||||
tokens: {
|
||||
accessToken: redeemed.access_token,
|
||||
refreshToken: redeemed.refresh_token,
|
||||
expireTimeMS: expireTimeMS
|
||||
},
|
||||
secrets: curOauthSecrets
|
||||
};
|
||||
|
||||
reset('resolve', result);
|
||||
},
|
||||
function(err) {
|
||||
reset('reject', err);
|
||||
});
|
||||
} else {
|
||||
// Treat anything else like a cancel, it failed or was stopped by the
|
||||
// user.
|
||||
reset('resolve', { status: 'cancel' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the code received from the oauth jump into an actual oauth token
|
||||
* data.
|
||||
* @param {String} code the code received from the oauth2 provider.
|
||||
* @return {Promise}
|
||||
*/
|
||||
function redeemCode(code) {
|
||||
console.log('redeeming oauth code');
|
||||
return new Promise(function(xhrResolve, xhrReject) {
|
||||
var xhr = new XMLHttpRequest({ mozSystem: true });
|
||||
xhr.open('POST', oauthSettings.tokenEndpoint, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.timeout = TIMEOUT_MS;
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
console.error('token redemption failed', xhr.status,
|
||||
xhr.responseText);
|
||||
xhrReject('status' + xhr.status);
|
||||
} else {
|
||||
try {
|
||||
// yeah, we could have just set responseType.
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
console.log('oauth code redeemed. access_token? ' +
|
||||
!!data.access_token + ', refresh_token? ' +
|
||||
!! data.refresh_token);
|
||||
xhrResolve(data);
|
||||
} catch (ex) {
|
||||
console.error('badly formed JSON response for token redemption:',
|
||||
xhr.responseText);
|
||||
xhrReject(ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function(err) {
|
||||
console.error('token redemption weird error:', err);
|
||||
xhrReject(err);
|
||||
};
|
||||
xhr.ontimeout = function() {
|
||||
console.error('token redemption timeout');
|
||||
xhrReject('timeout');
|
||||
};
|
||||
|
||||
var redemptionArgs = {
|
||||
code: code,
|
||||
client_id: curOauthSecrets.clientId,
|
||||
client_secret: curOauthSecrets.clientSecret,
|
||||
// uh, this doesn't seem right, but the docs want it? hopefully it just
|
||||
// gets forever ignored?
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code'
|
||||
};
|
||||
|
||||
xhr.send(queryString.fromObject(redemptionArgs));
|
||||
});
|
||||
}
|
||||
|
||||
function reset(actionName, value) {
|
||||
console.log('oauth2 fetch reset with action: ' + actionName);
|
||||
var action = deferred[actionName];
|
||||
|
||||
deferred = p = null;
|
||||
redeemingCode = false;
|
||||
|
||||
action(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the OAuth dialog dance.
|
||||
*
|
||||
* @param {Object} o2Settings
|
||||
* @param {String} o2Settings.authEndpoint
|
||||
* URL of the authorization endpoint.
|
||||
* @param {String} o2Settings.scope
|
||||
* The scope to request.
|
||||
* @param {String} [o2Settings.secretGroup]
|
||||
* Secret group identifier if this is an initial OAuth. If this is a reauth
|
||||
* then the clientId should be provided instead and reused.
|
||||
* @param {String} [o2Settings.clientId]
|
||||
* If this is a reauth (or don't need to/want to use our internal secret
|
||||
* table, the client_id to reuse. clientSecret should also be provided
|
||||
* (even though we don't use it ourselves) for consistency.
|
||||
* @param {Object} [extraQueryObject] extra query args to pass to the
|
||||
* provider. These are typically only known at runtime vs the o2Settings.
|
||||
* The property names in the extraQueryObject will be mapped to the provider's
|
||||
* preferred names by using o2Settings.extraArgsMap
|
||||
*
|
||||
* @return {Promise} The resolved value will be an object with these
|
||||
* properties:
|
||||
* * status: String indicating status of the request. 'succcess' and 'cancel'
|
||||
* are the possible values.
|
||||
* * tokens: If status is 'success', this will be the token data in the form
|
||||
* { accessToken, refreshToken, expireTimeMS }
|
||||
* * secrets: The clientId and clientSecret used for this oauth.
|
||||
*/
|
||||
return function oauth2Fetch(o2Settings, extraQueryObject) {
|
||||
// Only one auth session at a time.
|
||||
if (deferred) {
|
||||
reset('reject', new Error('Multiple oauth calls, starting new one'));
|
||||
}
|
||||
p = new Promise(function(res, rej) {
|
||||
deferred = {
|
||||
resolve: res,
|
||||
reject: rej
|
||||
};
|
||||
});
|
||||
|
||||
oauthSettings = o2Settings;
|
||||
|
||||
curOauthSecrets = undefined;
|
||||
if (o2Settings.clientId && o2Settings.clientSecret) {
|
||||
curOauthSecrets = {
|
||||
clientId: o2Settings.clientId,
|
||||
clientSecret: o2Settings.clientSecret
|
||||
};
|
||||
} else if (o2Settings.secretGroup) {
|
||||
curOauthSecrets = oauthSecrets[o2Settings.secretGroup];
|
||||
}
|
||||
if (!curOauthSecrets) {
|
||||
reset('reject',
|
||||
new Error('no secrets for group: ' + o2Settings.secretGroup));
|
||||
return p;
|
||||
}
|
||||
|
||||
var authEndpointQuery = {
|
||||
client_id: curOauthSecrets.clientId,
|
||||
// This must match what's in our manifest so we're not considering it
|
||||
// something that's allowed to change. Because this is currently
|
||||
// fairly gmail specific with the "installed app" flow, we (must) use
|
||||
// localhost, but we are maintaining a redirect for our theoretical
|
||||
// https://email.gaiamobile.org/ domain.
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: o2Settings.scope,
|
||||
// max_auth_age set to 0 means that google will not keep the user signed
|
||||
// in after completing the oauth jump. This is important so that if an
|
||||
// account is deleted, someone else could not then just set up another
|
||||
// account using the cached sign in cookies.
|
||||
max_auth_age: 0
|
||||
};
|
||||
|
||||
var url = o2Settings.authEndpoint + '?' +
|
||||
queryString.fromObject(authEndpointQuery);
|
||||
|
||||
if (extraQueryObject) {
|
||||
var extraArgs = queryString.fromObject(extraQueryObject);
|
||||
if (extraArgs) {
|
||||
url += '&' + extraArgs;
|
||||
}
|
||||
}
|
||||
|
||||
cards.pushCard('setup_oauth2', 'animate', {
|
||||
url: url,
|
||||
onBrowserComplete: onBrowserComplete
|
||||
});
|
||||
|
||||
return p;
|
||||
};
|
||||
});
|
||||
9
gaia/apps/email/js/cards/oauth2/redirect.html
Executable file
9
gaia/apps/email/js/cards/oauth2/redirect.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
145
gaia/apps/email/js/cards/settings_account.html
Executable file
145
gaia/apps/email/js/cards/settings_account.html
Executable file
|
|
@ -0,0 +1,145 @@
|
|||
<!-- Per-account settings menu; largely read-only for now -->
|
||||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim settings-account-common" role="region">
|
||||
<header class="tng-account-header">
|
||||
<a data-event="click:onBack"
|
||||
href="#" class="tng-back-btn back-btn header-start-btn"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 data-prop="headerLabel"
|
||||
class="tng-account-header-label header-label"></h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header class="tng-main-accounts-label">
|
||||
<h2 data-l10n-id="settings-sub-header-account">
|
||||
AccounT
|
||||
</h2>
|
||||
</header>
|
||||
<ul class="tng-list" data-l10n-id="settings-account-group">
|
||||
<li class="list-text-with-sub">
|
||||
<span data-l10n-id="settings-account-name" class="list-text">
|
||||
YouR NamE</span>
|
||||
<span data-prop="accountNameNode" dir="auto"
|
||||
class="tng-account-name list-sub-text"></span>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tng-notify-mail-label pack-switch list-pack-switch">
|
||||
<input class="tng-notify-mail" type="checkbox" />
|
||||
<span data-l10n-id="settings-account-display-notifications"
|
||||
class="list-text list-pack-switch-text">
|
||||
DisplaY NotificationS FoR NeW MessageS</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tng-sound-onsend-label pack-switch list-pack-switch">
|
||||
<input class="tng-sound-onsend" type="checkbox" />
|
||||
<span data-l10n-id="settings-account-play-sound-onsend"
|
||||
class="list-text list-pack-switch-text">
|
||||
PlaY SounD AfteR MessagE SenT</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label data-prop="defaultLabelNode"
|
||||
class="pack-checkbox tng-default-label list-pack-checkbox">
|
||||
<input data-prop="defaultInputNode"
|
||||
class="tng-default-input" type="checkbox">
|
||||
<span data-l10n-id="settings-default-account"
|
||||
class="list-text list-pack-checkbox-text">
|
||||
SeT AS DefaulT EmaiL</span>
|
||||
</label>
|
||||
</li>
|
||||
<li class="settings-signature settings-input-list">
|
||||
<label class="pack-switch tng-default-label list-pack-switch">
|
||||
<input class="tng-signature-input" type="checkbox">
|
||||
<span data-l10n-id="settings-account-signature-label"
|
||||
class="list-text list-pack-switch-text">
|
||||
SignaturE</span>
|
||||
</label>
|
||||
<button class="signature-button button icon">
|
||||
<div dir="auto" class="signature-button-content"></div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<header class="tng-main-accounts-label">
|
||||
<h2 data-l10n-id="settings-sub-header-data">
|
||||
DatA
|
||||
</h2>
|
||||
</header>
|
||||
<ul class="tng-list" data-l10n-id="settings-data-group">
|
||||
<li class="syncinterval-setting select-item">
|
||||
<label data-l10n-id="settings-account-check-mail" class="list-text">
|
||||
ChecK FoR NeW MessageS</label>
|
||||
<span class="button icon icon-dialog">
|
||||
<select class="tng-account-check-interval mail-select"
|
||||
data-l10n-id="settings-account-check-mail-select">
|
||||
<option value="0" data-l10n-id="settings-check-every-manual">
|
||||
ManuallY</option>
|
||||
<option value="300000" data-l10n-id="settings-check-every-5min">
|
||||
5 MinuteS</option>
|
||||
<option value="600000" data-l10n-id="settings-check-every-10min">
|
||||
10 MinuteS</option>
|
||||
<option value="900000" data-l10n-id="settings-check-every-15min">
|
||||
15 MinuteS</option>
|
||||
<option value="1800000" data-l10n-id="settings-check-every-30min">
|
||||
30 MinuteS</option>
|
||||
<option value="3600000" data-l10n-id="settings-check-every-60min">
|
||||
60 MinuteS</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
<li data-prop="syncSettingNode" class="synchronize-setting select-item">
|
||||
<label data-l10n-id="settings-account-synchronize-window"
|
||||
class="list-text"></label>
|
||||
<span class="button icon icon-dialog">
|
||||
<select data-prop="synchronizeNode"
|
||||
data-event="change:onChangeSynchronize"
|
||||
class="mail-select tng-account-synchronize"
|
||||
data-l10n-id="settings-account-synchronize-window-select">
|
||||
<option value="auto" data-l10n-id="settings-synchronize-auto">
|
||||
AutomatiC</option>
|
||||
<option value="1d" data-l10n-id="settings-synchronize-one-day">
|
||||
1 DaY</option>
|
||||
<option value="3d" data-l10n-id="settings-synchronize-three-days">
|
||||
3 DayS</option>
|
||||
<option value="1w" data-l10n-id="settings-synchronize-one-week">
|
||||
1 WeeK</option>
|
||||
<option value="2w" data-l10n-id="settings-synchronize-two-weeks">
|
||||
2 WeekS</option>
|
||||
<option value="1m" data-l10n-id="settings-synchronize-one-month">
|
||||
1 MontH</option>
|
||||
<option value="all" data-l10n-id="settings-synchronize-all">
|
||||
AlL MessageS</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<header class="tng-main-accounts-label">
|
||||
<h2 data-l10n-id="settings-sub-header-server">
|
||||
ServeR SettingS
|
||||
</h2>
|
||||
</header>
|
||||
<ul data-prop="serversContainer" data-l10n-id="settings-server-group"
|
||||
class="tng-list tng-account-server-container">
|
||||
<li class="item-with-children">
|
||||
<a href="#" data-prop="accountCredNode"
|
||||
data-event="click:onClickCredentials"
|
||||
class="tng-account-credentials list-text"></a>
|
||||
</li>
|
||||
<li class="list-text-pair">
|
||||
<span data-l10n-id="settings-account-type" role="presentation"
|
||||
class="list-text aside start">
|
||||
AccounT TypE</span>
|
||||
<div role="presentation">
|
||||
<span data-prop="accountTypeNode" dir="auto" role="presentation"
|
||||
class="tng-account-type"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button href="#" data-l10n-id="settings-account-delete"
|
||||
data-event="click:onDelete"
|
||||
class="tng-account-delete danger"></button>
|
||||
</section>
|
||||
</section>
|
||||
148
gaia/apps/email/js/cards/settings_account.js
Executable file
148
gaia/apps/email/js/cards/settings_account.js
Executable file
|
|
@ -0,0 +1,148 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var tngAccountSettingsServerNode =
|
||||
require('tmpl!./tng/account_settings_server.html'),
|
||||
tngAccountDeleteConfirmNode =
|
||||
require('tmpl!./tng/account_delete_confirm.html'),
|
||||
evt = require('evt'),
|
||||
mozL10n = require('l10n!'),
|
||||
cards = require('cards'),
|
||||
ConfirmDialog = require('confirm_dialog');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./settings_account.html')),
|
||||
require('./account_prefs_mixins'),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.identity = this.account.identities[0];
|
||||
|
||||
this.headerLabel.textContent = args.account.name;
|
||||
|
||||
this._bindPrefs('tng-account-check-interval',
|
||||
'tng-notify-mail',
|
||||
'tng-sound-onsend',
|
||||
'tng-signature-input',
|
||||
'signature-button');
|
||||
|
||||
this.accountNameNode.textContent =
|
||||
(this.identity && this.identity.name) || this.account.name;
|
||||
|
||||
// ActiveSync, IMAP and SMTP are protocol names, no need to be localized
|
||||
this.accountTypeNode.textContent =
|
||||
(this.account.type === 'activesync') ? 'ActiveSync' :
|
||||
(this.account.type === 'imap+smtp') ? 'IMAP+SMTP' : 'POP3+SMTP';
|
||||
|
||||
// Handle default account checkbox. If already a default, then the
|
||||
// checkbox cannot be unchecked. The default is changed by going to an
|
||||
// account that is not the default and checking that checkbox.
|
||||
if (this.account.isDefault) {
|
||||
this.defaultInputNode.disabled = true;
|
||||
this.defaultInputNode.checked = true;
|
||||
} else {
|
||||
this.defaultLabelNode.addEventListener('click',
|
||||
this.onChangeDefaultAccount.bind(this),
|
||||
false);
|
||||
}
|
||||
|
||||
if (this.account.type === 'activesync') {
|
||||
this.synchronizeNode.value = this.account.syncRange;
|
||||
} else {
|
||||
// Remove it from the DOM so that css selectors for last-child can work
|
||||
// efficiently. Also, it just makes the overall DOM smaller.
|
||||
this.syncSettingNode.parentNode.removeChild(this.syncSettingNode);
|
||||
}
|
||||
|
||||
this.account.servers.forEach(function(server, index) {
|
||||
var serverNode = tngAccountSettingsServerNode.cloneNode(true);
|
||||
var serverLabel = serverNode.querySelector('.tng-account-server-label');
|
||||
|
||||
mozL10n.setAttributes(serverLabel,
|
||||
'settings-' + server.type + '-label');
|
||||
serverLabel.addEventListener('click',
|
||||
this.onClickServers.bind(this, index), false);
|
||||
|
||||
this.serversContainer.appendChild(serverNode);
|
||||
}.bind(this));
|
||||
|
||||
var credL10nId = 'settings-account-userpass';
|
||||
if (this.account.authMechanism === 'oauth2') {
|
||||
credL10nId = 'settings-account-useroauth2';
|
||||
}
|
||||
mozL10n.setAttributes(this.accountCredNode, credL10nId);
|
||||
},
|
||||
|
||||
onBack: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
this.updateSignatureButton();
|
||||
},
|
||||
|
||||
onClickCredentials: function() {
|
||||
cards.pushCard(
|
||||
'settings_account_credentials', 'animate',
|
||||
{
|
||||
account: this.account
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onClickServers: function(index) {
|
||||
cards.pushCard(
|
||||
'settings_account_servers', 'animate',
|
||||
{
|
||||
account: this.account,
|
||||
index: index
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onChangeDefaultAccount: function(event) {
|
||||
event.stopPropagation();
|
||||
if (event.preventBubble) {
|
||||
event.preventBubble();
|
||||
}
|
||||
|
||||
if (!this.defaultInputNode.disabled) {
|
||||
this.defaultInputNode.disabled = true;
|
||||
this.defaultInputNode.checked = true;
|
||||
this.account.modifyAccount({ setAsDefault: true });
|
||||
}
|
||||
},
|
||||
|
||||
onChangeSynchronize: function(event) {
|
||||
this.account.modifyAccount({syncRange: event.target.value});
|
||||
},
|
||||
|
||||
onDelete: function() {
|
||||
var account = this.account;
|
||||
|
||||
var dialog = tngAccountDeleteConfirmNode.cloneNode(true);
|
||||
var content = dialog.getElementsByTagName('p')[0];
|
||||
mozL10n.setAttributes(content, 'settings-account-delete-prompt',
|
||||
// Replacing the '@' character with '\u200b@\u200b'
|
||||
// where \u200b is a zero-width space allows for line-breaking
|
||||
{ account: account.name.replace(/@/, '\u200b@\u200b') });
|
||||
ConfirmDialog.show(dialog,
|
||||
{ // Confirm
|
||||
id: 'account-delete-ok',
|
||||
handler: function() {
|
||||
account.deleteAccount();
|
||||
evt.emit('accountDeleted', account);
|
||||
}
|
||||
},
|
||||
{ // Cancel
|
||||
id: 'account-delete-cancel',
|
||||
handler: null
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
55
gaia/apps/email/js/cards/settings_account_credentials.html
Executable file
55
gaia/apps/email/js/cards/settings_account_credentials.html
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
<!-- Per-account credentials settings menu -->
|
||||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="tng-account-server-header">
|
||||
<a data-event="click:onBack"
|
||||
href="#" class="tng-back-btn back-btn header-start-btn"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 data-prop="headerLabel"
|
||||
class="tng-account-header-label header-label"></h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header>
|
||||
<h2 data-l10n-id="settings-account-credentials"
|
||||
class="tng-account-credentials-label"></h2>
|
||||
</header>
|
||||
<div class="tng-form">
|
||||
<li class="tng-account-server-username settings-input-list">
|
||||
<h2 data-l10n-id="settings-username"
|
||||
class="tng-server-username-title" aria-level="3">
|
||||
UsernamE</h2>
|
||||
<input disabled data-prop="usernameNodeInput"
|
||||
dir="auto"
|
||||
class="tng-server-username-input" type="text" />
|
||||
</li>
|
||||
<li class="tng-account-server-password settings-input-list">
|
||||
<h2 data-l10n-id="settings-password"
|
||||
class="tng-server-password-title" aria-level="3">
|
||||
PassworD</h2>
|
||||
<!-- When the input is focused, a resize event will be fired
|
||||
and will trigger _showCard() in mail-common.js
|
||||
the UI state will become wrong after _showCard() is called
|
||||
so disable the input first to prevent the wrong state
|
||||
-->
|
||||
<input data-prop="passwordNodeInput"
|
||||
class="tng-server-password-input" type="password"
|
||||
data-l10n-id="setup-new-password" />
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<button data-l10n-id="settings-save"
|
||||
data-prop="saveButton"
|
||||
data-event="click:onClickSave"
|
||||
class="tng-account-save settings-button recommend"></button>
|
||||
|
||||
<button data-l10n-id="settings-reauth"
|
||||
data-prop="reauthButton"
|
||||
data-event="click:onClickReauth"
|
||||
class="tng-account-reauth settings-button recommend collapsed">
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
74
gaia/apps/email/js/cards/settings_account_credentials.js
Executable file
74
gaia/apps/email/js/cards/settings_account_credentials.js
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var mozL10n = require('l10n!'),
|
||||
cards = require('cards'),
|
||||
oauthFetch = require('./oauth2/fetch');
|
||||
|
||||
return [
|
||||
require('./base_card')
|
||||
(require('template!./settings_account_credentials.html')),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.headerLabel.textContent = this.account.name;
|
||||
|
||||
// If we're not using password auth, then hide the password box and the
|
||||
// save UI for it.
|
||||
if (this.account.authMechanism !== 'password') {
|
||||
this.querySelector('.tng-account-server-password')
|
||||
.classList.add('collapsed');
|
||||
this.saveButton.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// If the account is explicitly an oauth2 type, then show the reauthorize
|
||||
// button.
|
||||
if (this.account.authMechanism === 'oauth2') {
|
||||
this.reauthButton.classList.remove('collapsed');
|
||||
}
|
||||
|
||||
this.usernameNodeInput.value = this.account.username;
|
||||
},
|
||||
|
||||
onBack: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onClickSave: function() {
|
||||
var password = this.passwordNodeInput.value;
|
||||
|
||||
if (password) {
|
||||
this.account.modifyAccount({password: password});
|
||||
this.account.clearProblems();
|
||||
} else {
|
||||
alert(mozL10n.get('settings-password-empty'));
|
||||
}
|
||||
|
||||
this.onBack();
|
||||
},
|
||||
|
||||
onClickReauth: function() {
|
||||
var oauth2 = this.account._wireRep.credentials.oauth2;
|
||||
oauthFetch(oauth2, {
|
||||
login_hint: this.account.username
|
||||
})
|
||||
.then(function(response) {
|
||||
if (response.status === 'success') {
|
||||
this.account.modifyAccount({ oauthTokens: response.tokens });
|
||||
|
||||
// The user may have reauthed because they canceled an onbadlogin
|
||||
// card but came here to try to fix the problem, so ask to clear
|
||||
// problems if possible.
|
||||
this.account.clearProblems();
|
||||
|
||||
// Successfully reauthed, nothing else to do on this card.
|
||||
this.onBack();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
43
gaia/apps/email/js/cards/settings_account_servers.html
Executable file
43
gaia/apps/email/js/cards/settings_account_servers.html
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
<!-- Per-account server settings menu -->
|
||||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="tng-account-server-header">
|
||||
<a data-event="click:onBack"
|
||||
href="#" class="tng-back-btn back-btn header-start-btn"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 data-prop="headerLabel"
|
||||
class="tng-account-header-label header-label">
|
||||
AccounT NamE</h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header>
|
||||
<h2 data-prop="serverLabel"
|
||||
class="tng-account-server-label"></h2>
|
||||
</header>
|
||||
<div class="tng-form">
|
||||
<li class="tng-account-server-hostname settings-input-list">
|
||||
<h2 data-l10n-id="settings-hostname"
|
||||
class="tng-server-hostname-title" aria-level="3">
|
||||
HostnamE</h2>
|
||||
<input data-prop="hostnameNodeInput"
|
||||
disabled class="tng-server-hostname-input"
|
||||
type="text" />
|
||||
</li>
|
||||
<li class="tng-account-server-port settings-input-list">
|
||||
<h2 data-l10n-id="settings-port"
|
||||
class="tng-server-port-title" aria-level="3"></h2>
|
||||
<input data-prop="portNodeInput" disabled class="tng-server-port-input"
|
||||
type="text" />
|
||||
</li>
|
||||
</div>
|
||||
<!-- nothing is editable, nothing is saved; collapse. -->
|
||||
<button data-l10n-id="settings-save"
|
||||
data-event="click:onBack"
|
||||
class="tng-account-save settings-button recommend collapsed"
|
||||
></button>
|
||||
</section>
|
||||
</section>
|
||||
39
gaia/apps/email/js/cards/settings_account_servers.js
Executable file
39
gaia/apps/email/js/cards/settings_account_servers.js
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Per-account server settings, it can be activesync, imap+smtp, or
|
||||
* pop3+smtp
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var mozL10n = require('l10n!'),
|
||||
cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')
|
||||
(require('template!./settings_account_servers.html')),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.server = args.account.servers[args.index];
|
||||
|
||||
this.headerLabel.textContent = this.account.name;
|
||||
|
||||
mozL10n.setAttributes(this.serverLabel,
|
||||
'settings-' + this.server.type + '-label');
|
||||
|
||||
// activesync stores its data in 'server'
|
||||
this.hostnameNodeInput.value = this.server.connInfo.hostname ||
|
||||
this.server.connInfo.server;
|
||||
// port is meaningless for activesync; display empty value
|
||||
this.portNodeInput.value = this.server.connInfo.port || '';
|
||||
},
|
||||
|
||||
onBack: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
70
gaia/apps/email/js/cards/settings_debug.html
Executable file
70
gaia/apps/email/js/cards/settings_debug.html
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
<!-- Secret developer debug page; for those in the know. This page can only be
|
||||
reached by tapping 5 times on some whitespace on the main settings page.
|
||||
|
||||
NOTE NOTE NOTE NOTE NOTE
|
||||
|
||||
This page is intentionally unlocalized. There's a few reasons for this:
|
||||
- The intended users are Firefox OS developers for which English is
|
||||
currently a pre-requisite.
|
||||
- It allows us to change these settings without being particularly
|
||||
concerned about string freeze.
|
||||
- We don't have to worry about the localized strings not capturing the
|
||||
risk of enabling this logging.
|
||||
|
||||
This raises the question: Should this be shipped in non-developer builds if
|
||||
we think it's not worth localizing? Our current rationale is that it is
|
||||
useful. We (the developers) have had bug reporters enable the logging and
|
||||
worked with us to fix bugs using the functionality. Especially given our
|
||||
limited ability to provide updated versions of the app or have users
|
||||
side-load versions of our certified app.
|
||||
-->
|
||||
<!-- This section element is needed by building blocks, would be nice if
|
||||
bb used a class instead of a section element target -->
|
||||
<section class="skin-organic settings-account-common bbshim" role="region">
|
||||
<header class="tng-account-header">
|
||||
<a data-event="click:onClose"
|
||||
href="#" class="tng-back-btn back-btn header-start-btn">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 class="header-label">Debug Magic</h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<button data-event="click:resetApp"
|
||||
href="#" class="tng-dbg-reset">Reset App</button>
|
||||
<header><h2>Logging</h2></header>
|
||||
<ul class="tng-list">
|
||||
<li class="select-item">
|
||||
<label class="list-text">Logging Mode</label>
|
||||
<span class="button icon icon-dialog">
|
||||
<select data-prop="loggingSelect"
|
||||
data-event="change:onChangeLogging"
|
||||
class="tng-dbg-logging">
|
||||
<option value="">
|
||||
No logging.
|
||||
</option>
|
||||
<option value="safe">
|
||||
Circular: Safe data: no passwords or message contents logged.
|
||||
</option>
|
||||
<option value="dangerous">
|
||||
Circular: Dangerous data: passwords and message contents logged.
|
||||
</option>
|
||||
<option value="realtime-dangerous">
|
||||
Realtime + Circular: Dangerous data: passwords and message contents logged.
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button data-event="click:dumpLog"
|
||||
href="#"
|
||||
class="tng-dbg-dump-storage"
|
||||
>Dump circular log buffer to sdcard</button>
|
||||
<header><h2>More Settings!</h2></header>
|
||||
<button data-event="click:fastSync"
|
||||
href="#"
|
||||
class="tng-dbg-fastsync">Enable faster sync options</button>
|
||||
|
||||
<button data-event="click:resetStartupCache"
|
||||
href="#">Reset startup cache</button>
|
||||
</section>
|
||||
</section>
|
||||
58
gaia/apps/email/js/cards/settings_debug.js
Executable file
58
gaia/apps/email/js/cards/settings_debug.js
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
'use strict';
|
||||
var _secretDebug;
|
||||
|
||||
/**
|
||||
* Quasi-secret card for troubleshooting/debugging support. Not part of the
|
||||
* standard UX flow, potentially not to be localized, and potentially not to
|
||||
* be shipped after initial dogfooding.
|
||||
*/
|
||||
define(function(require) {
|
||||
|
||||
var MailAPI = require('api'),
|
||||
cards = require('cards'),
|
||||
htmlCache = require('html_cache');
|
||||
|
||||
if (!_secretDebug) {
|
||||
_secretDebug = {};
|
||||
}
|
||||
|
||||
return [
|
||||
require('./base_card')
|
||||
(require('template!./settings_debug.html')),
|
||||
{
|
||||
createdCallback: function() {
|
||||
this.loggingSelect.value = MailAPI.config.debugLogging || '';
|
||||
},
|
||||
|
||||
onClose: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
resetApp: function() {
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
dumpLog: function(target) {
|
||||
MailAPI.debugSupport('dumpLog', target);
|
||||
},
|
||||
|
||||
onChangeLogging: function() {
|
||||
// coerce the falsey empty string to false.
|
||||
var value = this.loggingSelect.value || false;
|
||||
MailAPI.debugSupport('setLogging', value);
|
||||
},
|
||||
|
||||
fastSync: function() {
|
||||
_secretDebug.fastSync = [20000, 60000];
|
||||
},
|
||||
|
||||
resetStartupCache: function() {
|
||||
htmlCache.reset();
|
||||
console.log('htmlCache.reset done');
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
26
gaia/apps/email/js/cards/settings_main.html
Executable file
26
gaia/apps/email/js/cards/settings_main.html
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
<!-- Main settings menu, root of all (mail) settings -->
|
||||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="tng-main-header">
|
||||
<menu data-event="click:onClose" type="toolbar" class="tng-close-btn">
|
||||
<button data-l10n-id="settings-done"></button>
|
||||
</menu>
|
||||
<h1 class="tng-main-header-label"
|
||||
data-l10n-id="settings-main-header"></h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header class="tng-main-accounts-label">
|
||||
<h2 data-l10n-id="settings-account-section"></h2>
|
||||
</header>
|
||||
<ul data-prop="accountsContainer" class="tng-accounts-container"
|
||||
data-l10n-id="settings-account-listbox" role="listbox">
|
||||
</ul>
|
||||
<button data-event="click:onClickAddAccount"
|
||||
href="#" data-l10n-id="settings-account-add"
|
||||
class="tng-account-add"></button>
|
||||
<a data-event="click:onClickSecretButton"
|
||||
class="tng-email-lib-version list-text">v1</a>
|
||||
</section>
|
||||
</section>
|
||||
106
gaia/apps/email/js/cards/settings_main.js
Executable file
106
gaia/apps/email/js/cards/settings_main.js
Executable file
|
|
@ -0,0 +1,106 @@
|
|||
/*global define*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var tngAccountItemNode = require('tmpl!./tng/account_item.html'),
|
||||
MailAPI = require('api'),
|
||||
cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./settings_main.html')),
|
||||
{
|
||||
createdCallback: function() {
|
||||
this.acctsSlice = MailAPI.viewAccounts(false);
|
||||
this.acctsSlice.onsplice = this.onAccountsSplice.bind(this);
|
||||
|
||||
this._secretButtonClickCount = 0;
|
||||
this._secretButtonTimer = null;
|
||||
},
|
||||
|
||||
extraClasses: ['anim-fade', 'anim-overlay'],
|
||||
|
||||
onClose: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1, 1);
|
||||
},
|
||||
|
||||
onAccountsSplice: function(index, howMany, addedItems,
|
||||
requested, moreExpected) {
|
||||
var accountsContainer = this.accountsContainer;
|
||||
|
||||
var account;
|
||||
if (howMany) {
|
||||
for (var i = index + howMany - 1; i >= index; i--) {
|
||||
account = this.acctsSlice.items[i];
|
||||
accountsContainer.removeChild(account.element);
|
||||
}
|
||||
}
|
||||
|
||||
var insertBuddy = (index >= accountsContainer.childElementCount) ?
|
||||
null : accountsContainer.children[index],
|
||||
self = this;
|
||||
addedItems.forEach(function(account) {
|
||||
var accountNode = account.element =
|
||||
tngAccountItemNode.cloneNode(true);
|
||||
accountNode.account = account;
|
||||
self.updateAccountDom(account, true);
|
||||
accountsContainer.insertBefore(accountNode, insertBuddy);
|
||||
});
|
||||
},
|
||||
|
||||
updateAccountDom: function(account, firstTime) {
|
||||
var accountNode = account.element;
|
||||
|
||||
if (firstTime) {
|
||||
var accountLabel =
|
||||
accountNode.querySelector('.tng-account-item-label');
|
||||
|
||||
accountLabel.textContent = account.name;
|
||||
accountNode.setAttribute('aria-label', account.name);
|
||||
// Attaching a listener to account node with the role="option" to
|
||||
// enable activation with the screen reader.
|
||||
accountNode.addEventListener('click',
|
||||
this.onClickEnterAccount.bind(this, account), false);
|
||||
}
|
||||
},
|
||||
|
||||
onClickAddAccount: function() {
|
||||
cards.pushCard(
|
||||
'setup_account_info', 'animate',
|
||||
{
|
||||
allowBack: true
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onClickEnterAccount: function(account) {
|
||||
cards.pushCard(
|
||||
'settings_account', 'animate',
|
||||
{
|
||||
account: account
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onClickSecretButton: function() {
|
||||
if (this._secretButtonTimer === null) {
|
||||
this._secretButtonTimer = window.setTimeout(
|
||||
function() {
|
||||
this._secretButtonTimer = null;
|
||||
this._secretButtonClickCount = 0;
|
||||
}.bind(this), 2000);
|
||||
}
|
||||
|
||||
if (++this._secretButtonClickCount >= 5) {
|
||||
window.clearTimeout(this._secretButtonTimer);
|
||||
this._secretButtonTimer = null;
|
||||
this._secretButtonClickCount = 0;
|
||||
cards.pushCard('settings_debug', 'animate', {}, 'right');
|
||||
}
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this.acctsSlice.die();
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
25
gaia/apps/email/js/cards/settings_signature.html
Executable file
25
gaia/apps/email/js/cards/settings_signature.html
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="tng-signature-header">
|
||||
<a data-event="click:onBack"
|
||||
href="#"
|
||||
class="signature-back-btn tng-back-btn back-btn header-start-btn"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<menu data-event="click:onClickDone"
|
||||
class="tng-close-btn tng-signature-done" type="toolbar">
|
||||
<button data-l10n-id="settings-done"></button>
|
||||
</menu>
|
||||
<h1 class="tng-signature-header-label header-label" data-l10n-id="settings-account-signature-label"></h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<div data-prop="signatureNode"
|
||||
dir="auto"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-l10n-id="settings-account-signature-textbox"
|
||||
class="tng-signature-text" contenteditable="true"></div>
|
||||
</section>
|
||||
</section>
|
||||
107
gaia/apps/email/js/cards/settings_signature.js
Executable file
107
gaia/apps/email/js/cards/settings_signature.js
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var backFormNode = require('tmpl!./sig/save_signature.html'),
|
||||
cards = require('cards'),
|
||||
trailingRegExp = /\s+$/;
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./settings_signature.html')),
|
||||
require('./editor_mixins'),
|
||||
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.identity = this.account.identities[0];
|
||||
|
||||
this._bindEditor(this.signatureNode);
|
||||
|
||||
this.populateEditor(this.identity.signature || '');
|
||||
},
|
||||
|
||||
// Removes any trailing whitespace, since it is just noise, and it also sets
|
||||
// up the wrong expectation when the cursor is auto-focused at end of the
|
||||
// content -- it will sometimes show up on the far right of the text screen
|
||||
// because of phantom \n (that are <br>s in the HTML).
|
||||
getTextFromEditor: function() {
|
||||
var text = this.fromEditor().replace(trailingRegExp, '');
|
||||
return text;
|
||||
},
|
||||
|
||||
goBack: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onBack: function() {
|
||||
var signature = this.getTextFromEditor();
|
||||
if (signature === this.identity.signature) {
|
||||
this.goBack();
|
||||
return;
|
||||
}
|
||||
var menu = backFormNode.cloneNode(true);
|
||||
this._savePromptMenu = menu;
|
||||
cards.setStatusColor(menu);
|
||||
document.body.appendChild(menu);
|
||||
|
||||
var formSubmit = (function(evt) {
|
||||
cards.setStatusColor();
|
||||
document.body.removeChild(menu);
|
||||
this._savePromptMenu = null;
|
||||
|
||||
switch (evt.explicitOriginalTarget.id) {
|
||||
case 'sig-save':
|
||||
this.identity.modifyIdentity({ signature: signature });
|
||||
this.goBack();
|
||||
break;
|
||||
case 'sig-discard':
|
||||
this.goBack();
|
||||
break;
|
||||
case 'sig-cancel':
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}).bind(this);
|
||||
menu.addEventListener('submit', formSubmit);
|
||||
},
|
||||
|
||||
onClickDone: function() {
|
||||
var signature = this.getTextFromEditor();
|
||||
|
||||
// Only push the signature if it was changed
|
||||
if (signature !== this.identity.signature) {
|
||||
this.identity.modifyIdentity({ signature: signature });
|
||||
}
|
||||
|
||||
this.onBack();
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
// Need to wait until the card is shown and animation is done before
|
||||
// focusing input in the editable area.
|
||||
|
||||
// Clear any existing selections
|
||||
var selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
|
||||
// If there is content, set the cursor at the
|
||||
// end of it.
|
||||
var node = this.signatureNode.lastChild;
|
||||
if (node) {
|
||||
var range = document.createRange();
|
||||
|
||||
range.setStartAfter(node);
|
||||
range.setEndAfter(node);
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
// The selection sets up where the cursor will show, this ties it all
|
||||
// together and brings up the keyboard.
|
||||
this.signatureNode.focus();
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
25
gaia/apps/email/js/cards/setup_account_error_mixin.js
Executable file
25
gaia/apps/email/js/cards/setup_account_error_mixin.js
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
var SETUP_ERROR_L10N_ID_MAP = require('./setup_l10n_map'),
|
||||
mozL10n = require('l10n!');
|
||||
|
||||
return {
|
||||
// note: this method is reused by setup_account_info and
|
||||
// setup_manual_config.
|
||||
showError: function(errName, errDetails) {
|
||||
this.errorRegionNode.classList.remove('collapsed');
|
||||
|
||||
// Attempt to get a user-friendly string for the error we got. If we can't
|
||||
// find a match, just show the "unknown" error string.
|
||||
var errorStr = SETUP_ERROR_L10N_ID_MAP.hasOwnProperty(errName) ?
|
||||
SETUP_ERROR_L10N_ID_MAP[errName] :
|
||||
SETUP_ERROR_L10N_ID_MAP.unknown;
|
||||
mozL10n.setAttributes(this.errorMessageNode, errorStr, errDetails);
|
||||
|
||||
// Make sure we are scrolled to the top of the scroll region so that the
|
||||
// error message is visible.
|
||||
this.scrollBelowNode.scrollTop = 0;
|
||||
},
|
||||
|
||||
};
|
||||
});
|
||||
55
gaia/apps/email/js/cards/setup_account_info.html
Executable file
55
gaia/apps/email/js/cards/setup_account_info.html
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic form-card bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<a data-prop="backButton"
|
||||
data-event="click:onBack"
|
||||
href="#" class="sup-back-btn collapsed"
|
||||
role="button" data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 class="sup-account-header-label"
|
||||
data-l10n-id="setup-account-header3"></h1>
|
||||
</header>
|
||||
<div data-prop="scrollBelowNode" class="scrollregion-below-header">
|
||||
<div data-prop="errorRegionNode" class="sup-error-region collapsed">
|
||||
<div data-prop="errorMessageNode" class="sup-error-message"></div>
|
||||
</div>
|
||||
<form data-prop="formNode" class="sup-form sup-account-form">
|
||||
<p>
|
||||
<!-- x-inputmode is a temporary v1 B2G stop-gap -->
|
||||
<input data-prop="nameNode"
|
||||
data-event="input:onInfoInput"
|
||||
class="sup-info-name" type="text"
|
||||
data-l10n-id="setup-info-name"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input data-prop="emailNode"
|
||||
data-event="input:onInfoInput"
|
||||
class="sup-info-email" type="email"
|
||||
data-l10n-id="setup-info-email"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<button data-prop="nextButton"
|
||||
data-event="click:onNext"
|
||||
class="sup-info-next-btn recommend" disabled>
|
||||
<span data-l10n-id="setup-info-next"></span>
|
||||
</button>
|
||||
<button data-prop="manualConfig"
|
||||
data-event="click:onClickManualConfig"
|
||||
class="sup-manual-config-btn" disabled
|
||||
data-l10n-id="setup-manual-config2">
|
||||
ManuaL SetuP
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
97
gaia/apps/email/js/cards/setup_account_info.js
Executable file
97
gaia/apps/email/js/cards/setup_account_info.js
Executable file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Enter basic account info card (name, e-mail address) to try and
|
||||
* autoconfigure an account.
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require, exports, module) {
|
||||
|
||||
var evt = require('evt'),
|
||||
mozL10n = require('l10n!'),
|
||||
cards = require('cards'),
|
||||
htmlCache = require('html_cache'),
|
||||
FormNavigation = require('form_navigation');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_account_info.html')),
|
||||
require('./setup_account_error_mixin'),
|
||||
{
|
||||
createdCallback: function() {
|
||||
htmlCache.cloneAndSave(module.id, this);
|
||||
|
||||
this.formNavigation = new FormNavigation({
|
||||
formElem: this.formNode,
|
||||
onLast: this.onNext.bind(this)
|
||||
});
|
||||
|
||||
this.needsFocus = true;
|
||||
},
|
||||
|
||||
onArgs: function(args) {
|
||||
// The back button should only be enabled if there is at least one other
|
||||
// account already in existence.
|
||||
if (args.allowBack) {
|
||||
this.backButton.classList.remove('collapsed');
|
||||
}
|
||||
|
||||
if (args.launchedFromActivity) {
|
||||
this.errorRegionNode.classList.remove('collapsed');
|
||||
mozL10n.setAttributes(this.errorMessageNode,
|
||||
'setup-empty-account-message');
|
||||
}
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
// Only focus in the form fields if this is the first time the card is
|
||||
// being shown.
|
||||
if (this.needsFocus) {
|
||||
this.nameNode.focus();
|
||||
this.needsFocus = false;
|
||||
}
|
||||
},
|
||||
|
||||
onBack: function(event) {
|
||||
evt.emit('setupAccountCanceled', this);
|
||||
},
|
||||
|
||||
onNext: function(event) {
|
||||
event.preventDefault(); // Prevent FormNavigation from taking over.
|
||||
|
||||
// Clear HTML cache since the outcome of the setup will change it, and if
|
||||
// the user bails mid-setup, the app will not show the older incorrect
|
||||
// state.
|
||||
htmlCache.reset();
|
||||
|
||||
// The progress card is the dude that actually tries to create the
|
||||
// account.
|
||||
cards.pushCard(
|
||||
'setup_progress', 'animate',
|
||||
{
|
||||
displayName: this.nameNode.value,
|
||||
emailAddress: this.emailNode.value,
|
||||
callingCard: this
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onInfoInput: function(event) {
|
||||
this.nextButton.disabled = this.manualConfig.disabled =
|
||||
!this.formNode.checkValidity();
|
||||
},
|
||||
|
||||
onClickManualConfig: function(event) {
|
||||
event.preventDefault(); // Prevent FormNavigation from taking over.
|
||||
cards.pushCard(
|
||||
'setup_manual_config', 'animate',
|
||||
{
|
||||
displayName: this.nameNode.value,
|
||||
emailAddress: this.emailNode.value
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this.formNavigation = null;
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
0
gaia/apps/email/js/cards/setup_account_oauth.html
Executable file
0
gaia/apps/email/js/cards/setup_account_oauth.html
Executable file
0
gaia/apps/email/js/cards/setup_account_oauth.js
Executable file
0
gaia/apps/email/js/cards/setup_account_oauth.js
Executable file
39
gaia/apps/email/js/cards/setup_account_password.html
Executable file
39
gaia/apps/email/js/cards/setup_account_password.html
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="form-card card skin-organic bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<a href="#"
|
||||
data-event="click:onBack" class="sup-back-btn">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 class="sup-account-header-label"
|
||||
data-l10n-id="setup-account-header3"></h1>
|
||||
</header>
|
||||
<div data-prop="scrollBelowNode" class="scrollregion-below-header">
|
||||
<div data-prop="errorRegionNode" class="sup-error-region collapsed">
|
||||
<div data-prop="errorMessageNode" class="sup-error-message"></div>
|
||||
</div>
|
||||
<form data-prop="formNode" class="sup-form sup-account-form">
|
||||
<p class="list-text-with-sub" role="heading" aria-level="2">
|
||||
<span data-l10n-id="setup-account-enter-password" class="list-text">
|
||||
EnteR PassworD FoR:</span>
|
||||
<span data-prop="emailNode"
|
||||
class="sup-email-display list-sub-text"></span>
|
||||
</p>
|
||||
<p>
|
||||
<input data-prop="passwordNode"
|
||||
data-event="input:onInfoInput"
|
||||
class="sup-info-password" type="password"
|
||||
data-l10n-id="setup-info-password"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<button data-prop="nextButton"
|
||||
data-event="click:onNext"
|
||||
class="sup-info-next-btn recommend" disabled>
|
||||
<span data-l10n-id="setup-info-next"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
67
gaia/apps/email/js/cards/setup_account_password.js
Executable file
67
gaia/apps/email/js/cards/setup_account_password.js
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var mix = require('mix'),
|
||||
cards = require('cards'),
|
||||
FormNavigation = require('form_navigation');
|
||||
|
||||
// Function to avoid jshint error about "Do not use 'new' for side effects"
|
||||
function bindFormNavigation(instance) {
|
||||
return new FormNavigation({
|
||||
formElem: instance.formNode,
|
||||
onLast: instance.onNext.bind(instance)
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_account_password.html')),
|
||||
require('./setup_account_error_mixin'),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.args = args;
|
||||
this.emailAddress = args.emailAddress;
|
||||
|
||||
this.emailNode.textContent = this.emailAddress;
|
||||
this.needsFocus = true;
|
||||
|
||||
bindFormNavigation(this);
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
// Only focus in the form fields if this is the first time the card is
|
||||
// being shown.
|
||||
if (this.needsFocus) {
|
||||
this.passwordNode.focus();
|
||||
this.needsFocus = false;
|
||||
}
|
||||
},
|
||||
|
||||
onBack: function(event) {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
onNext: function(event) {
|
||||
event.preventDefault(); // Prevent FormNavigation from taking over.
|
||||
|
||||
this.args.password = this.passwordNode.value;
|
||||
|
||||
// The progress card is the dude that actually tries to create the
|
||||
// account.
|
||||
cards.pushCard(
|
||||
'setup_progress', 'animate',
|
||||
// Send a new object for sanitation, avoid state modifications
|
||||
// downstream.
|
||||
mix({
|
||||
callingCard: this
|
||||
}, this.args),
|
||||
'right');
|
||||
},
|
||||
|
||||
onInfoInput: function(event) {
|
||||
this.nextButton.disabled = !this.formNode.checkValidity();
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
65
gaia/apps/email/js/cards/setup_account_prefs.html
Executable file
65
gaia/apps/email/js/cards/setup_account_prefs.html
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim settings-account-common" role="region">
|
||||
<header class="sup-account-header">
|
||||
<menu type="toolbar">
|
||||
<button data-event="click:onNext" class="sup-info-next-btn">
|
||||
<span data-l10n-id="setup-info-next"></span>
|
||||
</button>
|
||||
</menu>
|
||||
<h1 class="sup-account-header-label"
|
||||
data-l10n-id="setup-account-header3"></h1>
|
||||
</header>
|
||||
<section class="scrollregion-below-header skin-organic" role="region">
|
||||
<ul class="tng-list">
|
||||
<li class="syncinterval-setting select-item">
|
||||
<label data-l10n-id="settings-account-check-mail" class="list-text">
|
||||
ChecK FoR NeW MessageS</label>
|
||||
<span class="button icon icon-dialog">
|
||||
<select class="tng-account-check-interval mail-select"
|
||||
data-l10n-id="settings-account-check-mail-select">
|
||||
<option value="0" data-l10n-id="settings-check-every-manual">
|
||||
ManuallY</option>
|
||||
<option value="300000" data-l10n-id="settings-check-every-5min">
|
||||
5 MinuteS</option>
|
||||
<option value="600000" data-l10n-id="settings-check-every-10min">
|
||||
10 MinuteS</option>
|
||||
<option value="900000" data-l10n-id="settings-check-every-15min">
|
||||
15 MinuteS</option>
|
||||
<option value="1800000" data-l10n-id="settings-check-every-30min">
|
||||
30 MinuteS</option>
|
||||
<option value="3600000" data-l10n-id="settings-check-every-60min">
|
||||
60 MinuteS</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tng-notify-mail-label pack-switch list-pack-switch">
|
||||
<input class="tng-notify-mail" type="checkbox" />
|
||||
<span data-l10n-id="settings-account-display-notifications"
|
||||
class="list-text list-pack-switch-text">
|
||||
DisplaY NotificationS FoR NeW MessageS</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="tng-sound-onsend-label pack-switch list-pack-switch">
|
||||
<input class="tng-sound-onsend" type="checkbox" />
|
||||
<span data-l10n-id="settings-account-play-sound-onsend"
|
||||
class="list-text list-pack-switch-text">
|
||||
PlaY SounD AfteR MessagE SenT</span>
|
||||
</label>
|
||||
</li>
|
||||
<li class="settings-signature settings-input-list">
|
||||
<label class="pack-switch tng-default-label list-pack-switch">
|
||||
<input class="tng-signature-input" type="checkbox">
|
||||
<span data-l10n-id="settings-account-signature-label"
|
||||
class="list-text list-pack-switch-text">
|
||||
SignaturE</span>
|
||||
</label>
|
||||
<button class="signature-button button icon">
|
||||
<div dir="auto" class="signature-button-content"></div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
43
gaia/apps/email/js/cards/setup_account_prefs.js
Executable file
43
gaia/apps/email/js/cards/setup_account_prefs.js
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Setup is done; add another account?
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var cards = require('cards'),
|
||||
mozL10n = require('l10n!');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_account_prefs.html')),
|
||||
require('./account_prefs_mixins'),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.identity = this.account.identities[0];
|
||||
|
||||
// Establish defaults specifically for our email app.
|
||||
this.identity.modifyIdentity({
|
||||
signatureEnabled: true,
|
||||
signature: mozL10n.get('settings-default-signature-2')
|
||||
});
|
||||
|
||||
this._bindPrefs('tng-account-check-interval',
|
||||
'tng-notify-mail',
|
||||
'tng-sound-onsend',
|
||||
'tng-signature-input',
|
||||
'signature-button');
|
||||
},
|
||||
|
||||
onNext: function(event) {
|
||||
cards.pushCard('setup_done', 'animate');
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
this.updateSignatureButton();
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
24
gaia/apps/email/js/cards/setup_done.html
Executable file
24
gaia/apps/email/js/cards/setup_done.html
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
<!-- Account all setup, add another or read your mail? -->
|
||||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<h1 class="sup-account-header-label"
|
||||
data-l10n-id="setup-completed-header"></h1>
|
||||
</header>
|
||||
<div class="sup-progress-region">
|
||||
<span class="sup-progress-label" data-l10n-id="setup-completed-label">
|
||||
AlL DonE!
|
||||
</span>
|
||||
</div>
|
||||
<button data-event="click:onAddAnother"
|
||||
class="sup-add-another-account-btn sup-form-btn"
|
||||
data-l10n-id="setup-add-another-account">
|
||||
AdD AnotheR AccounT
|
||||
</button>
|
||||
<button data-event="click:onShowMail"
|
||||
class="sup-show-mail-btn sup-form-btn recommend"
|
||||
data-l10n-id="setup-show-mail">
|
||||
ContinuE tO MaiL
|
||||
</button>
|
||||
</section>
|
||||
25
gaia/apps/email/js/cards/setup_done.js
Executable file
25
gaia/apps/email/js/cards/setup_done.js
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Setup is done; add another account?
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var evt = require('evt');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_done.html')),
|
||||
{
|
||||
onAddAnother: function() {
|
||||
evt.emit('addAccount');
|
||||
},
|
||||
onShowMail: function() {
|
||||
// Nuke this card
|
||||
evt.emit('showLatestAccount');
|
||||
},
|
||||
|
||||
die: function() {
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
});
|
||||
16
gaia/apps/email/js/cards/setup_fix_gmail.html
Executable file
16
gaia/apps/email/js/cards/setup_fix_gmail.html
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
<section class="error-card form-card card skin-organic bbshim" role="region">
|
||||
<header>
|
||||
<h1 class="sup-account-header-label header-label"></h1>
|
||||
</header>
|
||||
<div class="scrollregion-below-header">
|
||||
<h2 class="sup-enable-label sup-form-label"></h2>
|
||||
|
||||
<p data-prop="accountNode" class="sup-gmail-account"></p>
|
||||
|
||||
<div class="actions">
|
||||
<button data-event="click:onDismiss"
|
||||
class="sup-dismiss-btn recommend"></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
49
gaia/apps/email/js/cards/setup_fix_gmail.js
Executable file
49
gaia/apps/email/js/cards/setup_fix_gmail.js
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Tells the user how to enable IMAP/POP3 for Gmail
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_fix_gmail.html')),
|
||||
{
|
||||
extraClasses: ['anim-fade', 'anim-overlay'],
|
||||
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.restoreCard = args.restoreCard;
|
||||
|
||||
// The account name is not translatable; set it verbatim.
|
||||
this.accountNode.textContent = this.account.name;
|
||||
|
||||
// Localize the common elements. Since the text of these may differ
|
||||
// depending on the account type, we must translate them here rather
|
||||
// than through data-l10n-id.
|
||||
var translations = {
|
||||
'sup-account-header-label': 'setup-gmail-{ACCOUNT_TYPE}-header',
|
||||
'sup-enable-label': 'setup-gmail-{ACCOUNT_TYPE}-message',
|
||||
'sup-dismiss-btn': 'setup-gmail-{ACCOUNT_TYPE}-retry'
|
||||
};
|
||||
var accountType = (this.account.type === 'imap+smtp' ? 'imap' : 'pop3');
|
||||
for (var className in translations) {
|
||||
var l10nId = translations[className].replace('{ACCOUNT_TYPE}',
|
||||
accountType);
|
||||
this.getElementsByClassName(className)[0].setAttribute('data-l10n-id',
|
||||
l10nId);
|
||||
}
|
||||
},
|
||||
|
||||
die: function() {
|
||||
// no special cleanup required
|
||||
},
|
||||
|
||||
onDismiss: function() {
|
||||
this.account.clearProblems();
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1,
|
||||
this.restoreCard);
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
21
gaia/apps/email/js/cards/setup_fix_gmail_twofactor.html
Executable file
21
gaia/apps/email/js/cards/setup_fix_gmail_twofactor.html
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
<section class="error-card form-card card skin-organic bbshim" role="region">
|
||||
<header>
|
||||
<h1 class="sup-account-header-label header-label"
|
||||
data-l10n-id="setup-gmail-twofactor-header"></h1>
|
||||
</header>
|
||||
<div class="scrollregion-below-header">
|
||||
<h2 class="sup-bad-password-label sup-form-label"
|
||||
data-l10n-id="setup-gmail-twofactor-message"></h2>
|
||||
|
||||
<p data-prop="accountNode" class="sup-bad-password-account"></p>
|
||||
|
||||
<input data-prop="passwordNode" class="sup-info-password" type="password"
|
||||
data-l10n-id="setup-info-password" />
|
||||
|
||||
<div class="actions">
|
||||
<button data-event="click:onUsePassword"
|
||||
class="sup-use-password-btn recommend"
|
||||
data-l10n-id="setup-gmail-twofactor-fix"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
8
gaia/apps/email/js/cards/setup_fix_gmail_twofactor.js
Executable file
8
gaia/apps/email/js/cards/setup_fix_gmail_twofactor.js
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
return [
|
||||
require('./base_card')
|
||||
(require('template!./setup_fix_gmail_twofactor.html')),
|
||||
require('./setup_fix_mixin')
|
||||
];
|
||||
});
|
||||
67
gaia/apps/email/js/cards/setup_fix_mixin.js
Executable file
67
gaia/apps/email/js/cards/setup_fix_mixin.js
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Mixin for the setup_fix_* cases.
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var mozL10n = require('l10n!'),
|
||||
cards = require('cards');
|
||||
|
||||
return {
|
||||
extraClasses: ['anim-fade', 'anim-overlay'],
|
||||
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.whichSide = args.whichSide; // incoming or outgoing
|
||||
this.restoreCard = args.restoreCard;
|
||||
|
||||
var type = this.account.type;
|
||||
// In the case of IMAP/POP3, they _might_ have two different
|
||||
// passwords, one for IMAP/POP3 and one for SMTP. We need to clarify
|
||||
// which one we're asking for. (It does not need l10n because it's a
|
||||
// protocol.)
|
||||
if (type === 'imap+smtp' || type === 'pop3+smtp') {
|
||||
var l10nString = null;
|
||||
if (this.whichSide === 'incoming') {
|
||||
if (type === 'imap+smtp') {
|
||||
l10nString = 'settings-account-clarify-imap';
|
||||
} else {
|
||||
l10nString = 'settings-account-clarify-pop3';
|
||||
}
|
||||
} else {
|
||||
l10nString = 'settings-account-clarify-smtp';
|
||||
}
|
||||
mozL10n.setAttributes(this.accountNode, l10nString,
|
||||
{ 'account-name': this.account.name });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Assume we will be successful; update the password, trigger a reauth
|
||||
* attempt, then close the card.
|
||||
*/
|
||||
onUsePassword: function() {
|
||||
var password = this.passwordNode.value;
|
||||
if (password) {
|
||||
this.account.modifyAccount(this.whichSide === 'incoming' ?
|
||||
{ password: password } :
|
||||
{ outgoingPassword: password },
|
||||
this.proceed.bind(this));
|
||||
} else {
|
||||
this.proceed();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* After potentially modifying the account, continue onward.
|
||||
*/
|
||||
proceed: function() {
|
||||
this.account.clearProblems();
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1, this.restoreCard);
|
||||
},
|
||||
|
||||
die: function() {
|
||||
// no special cleanup required
|
||||
}
|
||||
};
|
||||
});
|
||||
23
gaia/apps/email/js/cards/setup_fix_oauth2.html
Executable file
23
gaia/apps/email/js/cards/setup_fix_oauth2.html
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="error-card form-card skin-organic bbshim" role="region">
|
||||
<header>
|
||||
<h1 class="sup-account-header-label header-label"
|
||||
data-l10n-id="setup-fix-oauth2-header"></h1>
|
||||
</header>
|
||||
<div class="scrollregion-below-header">
|
||||
<p class="sup-oauth-description"
|
||||
data-l10n-id="setup-fix-oauth2-description">
|
||||
EmaiL NeedS ReauthorizatioN FoR AccounT
|
||||
</p>
|
||||
|
||||
<p data-prop="oauth2Name" class="sup-oauth2-name"></p>
|
||||
|
||||
<div class="actions">
|
||||
<button data-event="click:onReauth"
|
||||
class="sup-reauth-btn recommend"
|
||||
data-l10n-id="setup-fix-oauth2-reauthorize"></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
68
gaia/apps/email/js/cards/setup_fix_oauth2.js
Executable file
68
gaia/apps/email/js/cards/setup_fix_oauth2.js
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var oauthFetch = require('./oauth2/fetch'),
|
||||
cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_fix_oauth2.html')),
|
||||
{
|
||||
extraClasses: ['anim-fade', 'anim-overlay'],
|
||||
|
||||
onArgs: function(args) {
|
||||
this.account = args.account;
|
||||
this.restoreCard = args.restoreCard;
|
||||
|
||||
// The account name is not translatable; set it verbatim.
|
||||
this.oauth2Name.textContent = this.account.name;
|
||||
},
|
||||
|
||||
die: function() {
|
||||
// no special cleanup required
|
||||
},
|
||||
|
||||
onReauth: function(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
var oauth2 = this.account._wireRep.credentials.oauth2;
|
||||
oauthFetch(oauth2, {
|
||||
login_hint: this.account.username
|
||||
})
|
||||
.then(function(response) {
|
||||
// Cancellation means hide this UI.
|
||||
if (response.status === 'cancel') {
|
||||
this.delayedClose();
|
||||
// Success means victory.
|
||||
} else if (response.status === 'success') {
|
||||
this.account.modifyAccount({ oauthTokens: response.tokens });
|
||||
this.account.clearProblems();
|
||||
this.delayedClose();
|
||||
|
||||
// Anything else means a failure and it's also time to close.
|
||||
} else {
|
||||
console.error('Unknown oauthFetch status: ' + response.status);
|
||||
this.delayedClose();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
delayedClose: function() {
|
||||
// The setTimeout is a hack. See the comment in setup_progress, in
|
||||
// onCardVisible, similar issue here, but for the close of the oauth
|
||||
// card.
|
||||
setTimeout(this.close.bind(this), 100);
|
||||
},
|
||||
|
||||
close: function(event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1,
|
||||
this.restoreCard);
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
28
gaia/apps/email/js/cards/setup_fix_password.html
Executable file
28
gaia/apps/email/js/cards/setup_fix_password.html
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
<section class="error-card form-card card skin-organic bbshim" role="region">
|
||||
<header>
|
||||
<h1 class="sup-account-header-label header-label"
|
||||
data-l10n-id="setup-bad-password-header"></h1>
|
||||
</header>
|
||||
<div class="scrollregion-below-header sup-form">
|
||||
<form>
|
||||
<h2 class="sup-bad-password-label sup-form-label"
|
||||
data-l10n-id="setup-bad-password"></h2>
|
||||
|
||||
<p data-prop="accountNode" class="sup-bad-password-account"></p>
|
||||
|
||||
<p>
|
||||
<input data-prop="passwordNode"
|
||||
class="sup-info-password" type="password"
|
||||
dir="auto"
|
||||
data-l10n-id="setup-new-password" />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button data-event="click:onUsePassword"
|
||||
class="sup-use-password-btn recommend"
|
||||
data-l10n-id="setup-fix-password"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
10
gaia/apps/email/js/cards/setup_fix_password.js
Executable file
10
gaia/apps/email/js/cards/setup_fix_password.js
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Asks the user to re-enter their password for the account
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_fix_password.html')),
|
||||
require('./setup_fix_mixin')
|
||||
];
|
||||
});
|
||||
23
gaia/apps/email/js/cards/setup_l10n_map.js
Executable file
23
gaia/apps/email/js/cards/setup_l10n_map.js
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Map error codes to their l10n string id. This exists because we have
|
||||
* revised some of the strings and so a direct transformation is no longer
|
||||
* sufficient. If an error code does not exist in this map, it gets mapped
|
||||
* to the "unknown" value's l10n string id.
|
||||
*/
|
||||
define({
|
||||
'offline': 'setup-error-offline',
|
||||
'bad-user-or-pass': 'setup-error-bad-user-or-pass2',
|
||||
'bad-address': 'setup-error-bad-address',
|
||||
'not-authorized': 'setup-error-not-authorized',
|
||||
'unknown': 'setup-error-unknown2',
|
||||
'needs-app-pass': 'setup-error-needs-app-pass',
|
||||
'imap-disabled': 'setup-error-imap-disabled',
|
||||
'pop3-disabled': 'setup-error-pop3-disabled',
|
||||
'bad-security': 'setup-error-bad-security',
|
||||
'unresponsive-server': 'setup-error-unresponsive-server',
|
||||
'pop-server-not-great': 'setup-error-pop-server-not-great',
|
||||
'server-problem': 'setup-error-server-problem',
|
||||
'no-config-info': 'setup-error-no-config-info',
|
||||
'server-maintenance': 'setup-error-server-maintenance',
|
||||
'user-account-exists': 'setup-error-account-already-exists'
|
||||
});
|
||||
210
gaia/apps/email/js/cards/setup_manual_config.html
Executable file
210
gaia/apps/email/js/cards/setup_manual_config.html
Executable file
|
|
@ -0,0 +1,210 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic form-card bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<a data-event="click:onBack" href="#" class="sup-back-btn" role="button"
|
||||
data-l10n-id="back-button">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 class="sup-account-header-label header-label"
|
||||
data-l10n-id="setup-manual-config-header"></h1>
|
||||
</header>
|
||||
<div data-prop="scrollBelowNode" class="scrollregion-below-header">
|
||||
<div data-prop="errorRegionNode" class="sup-error-region collapsed">
|
||||
<div data-prop="errorMessageNode" class="sup-error-message"></div>
|
||||
</div>
|
||||
<header class="collapsed"></header>
|
||||
<form data-prop="formNode" class="sup-manual-form">
|
||||
<div class="sup-manual-common sup-form">
|
||||
<p>
|
||||
<!-- x-inputmode is a temporary v1 B2G stop-gap -->
|
||||
<input class="sup-info-name" type="text"
|
||||
data-l10n-id="setup-info-name"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-info-email" type="email"
|
||||
data-l10n-id="setup-info-email"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p class="sup-manual-password-wrapper">
|
||||
<input class="sup-info-password" type="password"
|
||||
data-l10n-id="setup-info-password"
|
||||
dir="auto"
|
||||
required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h2 data-l10n-id="setup-manual-account-type"></h2>
|
||||
</header>
|
||||
<div class="sup-form sup-form-item">
|
||||
<!-- These don't need L10N as they are protocol names. -->
|
||||
<li class="sup-manual-account-type-container">
|
||||
<span class="button icon icon-dialog">
|
||||
<select data-prop="accountTypeNode"
|
||||
data-event="change:onChangeAccountType"
|
||||
data-l10n-id="setup-manual-account-type-select"
|
||||
class="mail-select sup-manual-account-type">
|
||||
<option value="imap+smtp">IMAP+SMTP</option>
|
||||
<option value="pop3+smtp">POP3+SMTP</option>
|
||||
<option value="activesync">ActiveSync</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<section data-prop="compositeSection"
|
||||
class="sup-manual-composite" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header>
|
||||
<h2 data-prop="manualImapTitle" class="sup-manual-imap-title"
|
||||
data-l10n-id="setup-manual-imap-header"></h2>
|
||||
<h2 data-prop="manualPop3Title" class="sup-manual-pop3-title"
|
||||
data-l10n-id="setup-manual-pop3-header"></h2>
|
||||
</header>
|
||||
<div class="sup-form">
|
||||
<p>
|
||||
<input class="sup-manual-composite-hostname" type="text"
|
||||
data-l10n-id="setup-manual-hostname"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-manual-composite-username" type="text"
|
||||
data-l10n-id="setup-manual-username"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-manual-composite-password" type="password"
|
||||
data-l10n-id="setup-info-password"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<li class="sup-socket-select-container">
|
||||
<span class="button icon icon-dialog">
|
||||
<select class="mail-select sup-manual-composite-socket sup-socket-select"
|
||||
data-l10n-id="setup-manual-socket-select">
|
||||
<option value="SSL" data-l10n-id="setup-manual-socket-ssl">
|
||||
SsL</option>
|
||||
<option value="STARTTLS" data-l10n-id="setup-manual-socket-starttls">
|
||||
StarttlS</option>
|
||||
<option value="PLAIN" data-l10n-id="setup-manual-socket-plain">
|
||||
PlaiN</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
<p>
|
||||
<input class="sup-manual-composite-port" type="text"
|
||||
data-l10n-id="setup-manual-port" value="993"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<h2 data-l10n-id="setup-manual-smtp-header"></h2>
|
||||
</header>
|
||||
<div class="sup-form">
|
||||
<p>
|
||||
<input class="sup-manual-smtp-hostname" type="text"
|
||||
data-l10n-id="setup-manual-hostname"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-manual-smtp-username" type="text"
|
||||
data-l10n-id="setup-manual-username"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-manual-smtp-password" type="password"
|
||||
data-l10n-id="setup-info-password"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<li class="sup-socket-select-container">
|
||||
<span class="button icon icon-dialog">
|
||||
<select class="mail-select sup-manual-smtp-socket sup-socket-select"
|
||||
data-l10n-id="setup-manual-socket-select">
|
||||
<option value="SSL" data-l10n-id="setup-manual-socket-ssl">
|
||||
SsL</option>
|
||||
<option value="STARTTLS" data-l10n-id="setup-manual-socket-starttls">
|
||||
StarttlS</option>
|
||||
<option value="PLAIN" data-l10n-id="setup-manual-socket-plain">
|
||||
PlaiN</option>
|
||||
</select>
|
||||
</span>
|
||||
</li>
|
||||
<p>
|
||||
<input class="sup-manual-smtp-port" type="text"
|
||||
data-l10n-id="setup-manual-port" value="465"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-prop="activeSyncSection"
|
||||
class="sup-manual-activesync collapsed" role="region">
|
||||
<header class="collapsed"></header>
|
||||
<header>
|
||||
<h2 data-l10n-id="setup-manual-activesync-header">
|
||||
ActivesynC SettingS</h2>
|
||||
</header>
|
||||
<div class="sup-form">
|
||||
<p>
|
||||
<input class="sup-manual-activesync-hostname" type="text"
|
||||
data-l10n-id="setup-manual-hostname"
|
||||
x-inputmode="verbatim"
|
||||
inputmode="verbatim"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
<p>
|
||||
<input class="sup-manual-activesync-username" type="text"
|
||||
data-l10n-id="setup-manual-activesync-username"
|
||||
dir="auto"
|
||||
data-maybe-required />
|
||||
<button type="reset"></button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="sup-form">
|
||||
<p>
|
||||
<button data-prop="nextButton"
|
||||
data-event="click:onNext"
|
||||
class="sup-manual-next-btn recommend" disabled>
|
||||
<span data-l10n-id="setup-info-next"></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
303
gaia/apps/email/js/cards/setup_manual_config.js
Executable file
303
gaia/apps/email/js/cards/setup_manual_config.js
Executable file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* Asks the user to manually configure their account.
|
||||
*/
|
||||
/*jshint nonew: false */
|
||||
/*global MozActivity */
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var cards = require('cards'),
|
||||
ConfirmDialog = require('confirm_dialog'),
|
||||
FormNavigation = require('form_navigation'),
|
||||
plainSocketWarningNode = require('tmpl!./tng/plain_socket_warning.html');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_manual_config.html')),
|
||||
require('./setup_account_error_mixin'),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.formItems = {
|
||||
common: {
|
||||
displayName: this._fromClass('sup-info-name'),
|
||||
emailAddress: this._fromClass('sup-info-email'),
|
||||
password: this._fromClass('sup-info-password'),
|
||||
passwordWrapper: this._fromClass('sup-manual-password-wrapper')
|
||||
},
|
||||
composite: {
|
||||
hostname: this._fromClass('sup-manual-composite-hostname'),
|
||||
port: this._fromClass('sup-manual-composite-port'),
|
||||
socket: this._fromClass('sup-manual-composite-socket'),
|
||||
username: this._fromClass('sup-manual-composite-username'),
|
||||
password: this._fromClass('sup-manual-composite-password')
|
||||
},
|
||||
smtp: {
|
||||
hostname: this._fromClass('sup-manual-smtp-hostname'),
|
||||
port: this._fromClass('sup-manual-smtp-port'),
|
||||
socket: this._fromClass('sup-manual-smtp-socket'),
|
||||
username: this._fromClass('sup-manual-smtp-username'),
|
||||
password: this._fromClass('sup-manual-smtp-password')
|
||||
|
||||
},
|
||||
activeSync: {
|
||||
hostname: this._fromClass('sup-manual-activesync-hostname'),
|
||||
username: this._fromClass('sup-manual-activesync-username')
|
||||
}
|
||||
};
|
||||
|
||||
var password = password || '';
|
||||
|
||||
var common = this.formItems.common;
|
||||
common.displayName.value = args.displayName;
|
||||
common.emailAddress.value = args.emailAddress;
|
||||
common.password.value = password;
|
||||
|
||||
var composite = this.formItems.composite;
|
||||
composite.username.value = args.emailAddress;
|
||||
composite.password.value = password;
|
||||
|
||||
|
||||
var smtp = this.formItems.smtp;
|
||||
smtp.username.value = args.emailAddress;
|
||||
smtp.password.value = password;
|
||||
|
||||
this.changeIfSame(common.emailAddress,
|
||||
[composite.username,
|
||||
smtp.username]);
|
||||
this.changeIfSame(composite.username,
|
||||
[smtp.username]);
|
||||
this.changeIfSame(composite.password,
|
||||
[smtp.password,
|
||||
common.password]);
|
||||
|
||||
for (var type in this.formItems) {
|
||||
for (var field in this.formItems[type]) {
|
||||
if (this.formItems[type][field].tagName === 'INPUT') {
|
||||
this.formItems[type][field].addEventListener(
|
||||
'input', this.onInfoInput.bind(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requireFields('composite', true);
|
||||
this.requireFields('smtp', true);
|
||||
this.requireFields('activeSync', false);
|
||||
|
||||
composite.socket.addEventListener('change',
|
||||
this.onChangeCompositeSocket.bind(this));
|
||||
smtp.socket.addEventListener('change',
|
||||
this.onChangeSmtpSocket.bind(this));
|
||||
|
||||
this.onChangeAccountType({ target: this.accountTypeNode });
|
||||
|
||||
this._formNavigation = new FormNavigation({
|
||||
formElem: this.formNode,
|
||||
onLast: this.onNext.bind(this)
|
||||
});
|
||||
},
|
||||
|
||||
_fromClass: function(className) {
|
||||
return this.getElementsByClassName(className)[0];
|
||||
},
|
||||
onBack: function(event) {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onNext: function(event) {
|
||||
event.preventDefault(); // Prevent FormNavigation from taking over.
|
||||
var config = { type: this.accountTypeNode.value };
|
||||
|
||||
if (config.type === 'imap+smtp' || config.type === 'pop3+smtp') {
|
||||
config.incoming = {
|
||||
hostname: this.formItems.composite.hostname.value,
|
||||
port: this.formItems.composite.port.value,
|
||||
socketType: this.formItems.composite.socket.value,
|
||||
username: this.formItems.composite.username.value,
|
||||
password: this.formItems.composite.password.value,
|
||||
authentication: 'password-cleartext'
|
||||
};
|
||||
config.outgoing = {
|
||||
hostname: this.formItems.smtp.hostname.value,
|
||||
port: this.formItems.smtp.port.value,
|
||||
socketType: this.formItems.smtp.socket.value,
|
||||
username: this.formItems.smtp.username.value,
|
||||
password: this.formItems.smtp.password.value,
|
||||
authentication: 'password-cleartext'
|
||||
};
|
||||
}
|
||||
else { // config.type === 'activesync'
|
||||
config.incoming = {
|
||||
server: 'https://' + this.formItems.activeSync.hostname.value,
|
||||
username: this.formItems.activeSync.username.value
|
||||
};
|
||||
}
|
||||
|
||||
this.pushSetupCard(config);
|
||||
},
|
||||
|
||||
pushSetupCard: function(config) {
|
||||
// For composite accounts where they've elected to have separate
|
||||
// passwords, use the composite password field. For everything
|
||||
// else, there's MasterCard. Uh, I mean, the common password.
|
||||
var password;
|
||||
if (this.accountTypeNode.value === 'activesync') {
|
||||
password = this.formItems.common.password.value;
|
||||
} else {
|
||||
password = this.formItems.composite.password.value;
|
||||
}
|
||||
// The progress card is the dude that actually tries to create the
|
||||
// account.
|
||||
cards.pushCard(
|
||||
'setup_progress', 'animate',
|
||||
{
|
||||
displayName: this.formItems.common.displayName.value,
|
||||
emailAddress: this.formItems.common.emailAddress.value,
|
||||
password: password,
|
||||
outgoingPassword: config.outgoing && config.outgoing.password,
|
||||
|
||||
configInfo: config,
|
||||
callingCard: this
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
onInfoInput: function(ignoredEvent) {
|
||||
this.nextButton.disabled = !this.formNode.checkValidity();
|
||||
},
|
||||
|
||||
/**
|
||||
* When sourceField changes, change every field in destFields to
|
||||
* match, if and only if destField previously matched sourceField.
|
||||
*/
|
||||
changeIfSame: function(sourceField, destFields) {
|
||||
sourceField._previousValue = sourceField.value;
|
||||
sourceField.addEventListener('input', function(e) {
|
||||
for (var i = 0; i < destFields.length; i++) {
|
||||
var destField = destFields[i];
|
||||
if (destField.value === e.target._previousValue) {
|
||||
destField.value = destField._previousValue = e.target.value;
|
||||
}
|
||||
}
|
||||
sourceField._previousValue = e.target.value;
|
||||
this.onInfoInput(); // run validation
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
onChangeAccountType: function(event) {
|
||||
var isComposite = (event.target.value === 'imap+smtp' ||
|
||||
event.target.value === 'pop3+smtp');
|
||||
var isImap = event.target.value === 'imap+smtp';
|
||||
|
||||
if (isComposite) {
|
||||
this.compositeSection.classList.remove('collapsed');
|
||||
this.activeSyncSection.classList.add('collapsed');
|
||||
this.manualImapTitle.classList.toggle('collapsed', !isImap);
|
||||
this.manualPop3Title.classList.toggle('collapsed', isImap);
|
||||
}
|
||||
else {
|
||||
this.compositeSection.classList.add('collapsed');
|
||||
this.activeSyncSection.classList.remove('collapsed');
|
||||
}
|
||||
|
||||
this.formItems.common.passwordWrapper.classList.toggle(
|
||||
'collapsed', isComposite);
|
||||
this.requireFields('composite', isComposite);
|
||||
this.requireFields('smtp', isComposite);
|
||||
this.requireFields('activeSync', !isComposite);
|
||||
this.onChangeCompositeSocket({target: this.formItems.composite.socket});
|
||||
},
|
||||
|
||||
// If the user selects a different socket type, autofill the most likely
|
||||
// port.
|
||||
onChangeCompositeSocket: function(event) {
|
||||
var isImap = this.accountTypeNode.value === 'imap+smtp';
|
||||
var SSL_VALUE = (isImap ? '993' : '995');
|
||||
var STARTTLS_VALUE = (isImap ? '143' : '110');
|
||||
|
||||
var socketType = event.target.value;
|
||||
if (socketType === 'PLAIN') {
|
||||
this.showPlainSocketWarning();
|
||||
}
|
||||
|
||||
var portField = this.formItems.composite.port;
|
||||
if (socketType === 'SSL') {
|
||||
portField.value = SSL_VALUE;
|
||||
} else if (socketType === 'STARTTLS' || socketType === 'PLAIN') {
|
||||
portField.value = STARTTLS_VALUE;
|
||||
}
|
||||
},
|
||||
|
||||
onChangeSmtpSocket: function(event) {
|
||||
const SSL_VALUE = '465';
|
||||
const STARTTLS_VALUE = '587';
|
||||
const PLAIN_VALUE = '25';
|
||||
var socketType = event.target.value;
|
||||
var portField = this.formItems.smtp.port;
|
||||
|
||||
if (socketType == 'PLAIN') {
|
||||
this.showPlainSocketWarning();
|
||||
}
|
||||
|
||||
// Switch portField values to match defaults for the socketType, but only
|
||||
// if the existing value for portField is one of the other defaults, and
|
||||
// not a user-supplied value.
|
||||
if (socketType === 'SSL' && (portField.value === STARTTLS_VALUE ||
|
||||
portField.value === PLAIN_VALUE)) {
|
||||
portField.value = SSL_VALUE;
|
||||
} else if (socketType == 'STARTTLS' && (portField.value == SSL_VALUE ||
|
||||
portField.value == PLAIN_VALUE)) {
|
||||
portField.value = STARTTLS_VALUE;
|
||||
} else if (socketType == 'PLAIN' && (portField.value == SSL_VALUE ||
|
||||
portField.value == STARTTLS_VALUE)) {
|
||||
portField.value = PLAIN_VALUE;
|
||||
}
|
||||
},
|
||||
|
||||
requireFields: function(type, required) {
|
||||
for (var field in this.formItems[type]) {
|
||||
var item = this.formItems[type][field];
|
||||
if (!item.hasAttribute('data-maybe-required')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (required) {
|
||||
item.setAttribute('required', '');
|
||||
} else {
|
||||
item.removeAttribute('required');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
showPlainSocketWarning: function() {
|
||||
var dialog = plainSocketWarningNode.cloneNode(true);
|
||||
|
||||
// Capture taps to Learn More to open an activity and to also prevent the
|
||||
// confirmation dialog from closing. A Learn More button is used instead
|
||||
// of a hyperlink so that more text can be visible in the dialog.
|
||||
var learnMoreNode = dialog.querySelector('.tng-plain-socket-learn-more');
|
||||
learnMoreNode.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
new MozActivity({
|
||||
name: 'view',
|
||||
data: {
|
||||
type: 'url',
|
||||
url: learnMoreNode.dataset.href
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ConfirmDialog.show(dialog, {
|
||||
// ok
|
||||
id: 'tng-plain-socket-warning-ok',
|
||||
// There is nothing to do.
|
||||
handler: function() {}
|
||||
});
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this._formNavigation = null;
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
16
gaia/apps/email/js/cards/setup_oauth2.html
Executable file
16
gaia/apps/email/js/cards/setup_oauth2.html
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<a data-event="click:onBack"
|
||||
href="#" class="sup-back-btn">
|
||||
<span class="icon icon-back"></span>
|
||||
</a>
|
||||
<h1 data-prop="headerLabel"
|
||||
dir="auto"
|
||||
class="sup-account-header-label header-label"></h1>
|
||||
</header>
|
||||
<section data-prop="scrollRegion"
|
||||
class="scrollregion-below-header skin-organic" role="region">
|
||||
</section>
|
||||
</section>
|
||||
87
gaia/apps/email/js/cards/setup_oauth2.js
Executable file
87
gaia/apps/email/js/cards/setup_oauth2.js
Executable file
|
|
@ -0,0 +1,87 @@
|
|||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var appSelf = require('app_self'),
|
||||
cards = require('cards');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_oauth2.html')),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.onBrowserComplete = args.onBrowserComplete;
|
||||
|
||||
|
||||
var browserFrame = document.createElement('iframe');
|
||||
browserFrame.classList.add('sup-oauth2-browser');
|
||||
browserFrame.setAttribute('mozbrowser', true);
|
||||
browserFrame.setAttribute('src', args.url);
|
||||
browserFrame.addEventListener('mozbrowserlocationchange',
|
||||
this.onLocationChange.bind(this));
|
||||
|
||||
this.scrollRegion.appendChild(browserFrame);
|
||||
},
|
||||
|
||||
die: function() {
|
||||
// The main reason a mozbrowser for this flow, not preserving cookies.
|
||||
appSelf.latest('self', function(app) {
|
||||
console.log('clearing browser data: ' + app);
|
||||
// Each app has two cookie jars, one for the app and one for browsing
|
||||
// contexts. This call just clears the browsing context.
|
||||
if (app) {
|
||||
app.clearBrowserData();
|
||||
}
|
||||
console.log('browser data cleared');
|
||||
});
|
||||
},
|
||||
|
||||
close: function() {
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onBack: function(event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
this.onBrowserComplete({
|
||||
type: 'oauth2Cancel'
|
||||
});
|
||||
|
||||
this.close();
|
||||
},
|
||||
|
||||
onLocationChange: function(event) {
|
||||
var url = event.detail;
|
||||
|
||||
// If the URL is not an expected app one for the redirect page
|
||||
// (set up in the manifest.webapp), then just update the title.
|
||||
if (url.indexOf('app:') !== 0 ||
|
||||
url.indexOf('cards/oauth2/redirect.html?') === -1) {
|
||||
this.headerLabel.textContent = url;
|
||||
return;
|
||||
}
|
||||
|
||||
//Start closing the dialog now to avoid the user staring too long at an
|
||||
//empty white screen.
|
||||
this.close();
|
||||
|
||||
// Should have the data we need now.
|
||||
var search = url.split('?')[1] || '';
|
||||
var result = {
|
||||
type: 'oauth2Complete',
|
||||
data: {}
|
||||
};
|
||||
|
||||
var elements = search.split('&');
|
||||
elements.forEach(function(p) {
|
||||
var values = p.split('=');
|
||||
result.data[decodeURIComponent(values[0])] =
|
||||
decodeURIComponent(values[1]);
|
||||
});
|
||||
|
||||
this.onBrowserComplete(result);
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
17
gaia/apps/email/js/cards/setup_progress.html
Executable file
17
gaia/apps/email/js/cards/setup_progress.html
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
<!-- need this section element to keep building blocks happy. Too bad it
|
||||
expects a section element -->
|
||||
<section class="skin-organic bbshim" role="region">
|
||||
<header class="sup-account-header">
|
||||
<h1 class="sup-account-header-label"
|
||||
data-l10n-id="setup-account-header3"></h1>
|
||||
</header>
|
||||
<form role="dialog" data-type="confirm" data-statuscolor="background">
|
||||
<section>
|
||||
<h1 data-l10n-id="setup-progress-wait-msg"></h1>
|
||||
<p class="sup-progress-container"><progress></progress></p>
|
||||
</section>
|
||||
<menu><button data-event="click:onBack"
|
||||
class="sup-back-btn full"
|
||||
data-l10n-id="message-multiedit-cancel"></button></menu>
|
||||
</form>
|
||||
</section>
|
||||
215
gaia/apps/email/js/cards/setup_progress.js
Executable file
215
gaia/apps/email/js/cards/setup_progress.js
Executable file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* Show a spinner in for two possible steps in the setup of an account:
|
||||
*
|
||||
* 1) During the autoconfig step, when figuring out the capabilities of the
|
||||
* server, and possibly needing an oauth jump.
|
||||
*
|
||||
* 2) During the manual config or password flow when finally connecting to the
|
||||
* service to confirm password and settings are correct.
|
||||
*
|
||||
* So the possible flows are:
|
||||
*
|
||||
* OAuth: setup_account_info -> setup_progress -> setup_account_prefs
|
||||
* autoconfig password: setup_account_info -> setup_progress ->
|
||||
* setup_password -> setup_progress -> setup_account_prefs
|
||||
* manual config: setup_account_info -> setup_manual_config ->
|
||||
* setup_progress -> setup_account_prefs
|
||||
*/
|
||||
'use strict';
|
||||
define(function(require) {
|
||||
|
||||
var MailAPI = require('api'),
|
||||
cards = require('cards'),
|
||||
oauthFetch = require('cards/oauth2/fetch');
|
||||
|
||||
return [
|
||||
require('./base_card')(require('template!./setup_progress.html')),
|
||||
{
|
||||
onArgs: function(args) {
|
||||
this.args = args;
|
||||
this.callingCard = args.callingCard;
|
||||
this.creationInProcess = true;
|
||||
this.pushedSecondaryCard = false;
|
||||
this.cardHasBeenShown = false;
|
||||
this.createCanceled = false;
|
||||
},
|
||||
|
||||
extraClasses: ['anim-fade', 'anim-overlay'],
|
||||
|
||||
cancelCreation: function() {
|
||||
if (!this.creationInProcess) {
|
||||
return;
|
||||
}
|
||||
// XXX implement cancellation
|
||||
},
|
||||
|
||||
onCardVisible: function() {
|
||||
if (this.cardHasBeenShown) {
|
||||
// If this card was made visible because of a cancel of a secondary
|
||||
// config card, just go back one more card. The setTimeout is a hack.
|
||||
// Without it, the final card is not actionable because the
|
||||
// onTransitionEnd is not fired on this second removeCardAndSuccessors
|
||||
// call while done as part of finishing up the previous card's
|
||||
// removeCardAndSuccessors. A queue approach as described in 973038 does
|
||||
// not help. It seems like the _transitionEnd for the second call does
|
||||
// not ever fire. Need some async delay, not sure why yet. Otherwise,
|
||||
// _eatingEventsUntilNextCard ends up as true, since the reset logic for
|
||||
// it in _onTransitionEnd does not fire. An immediate setTimeout is not
|
||||
// enough, bothersome that it needs a time threshold.
|
||||
// pushedSecondaryCard is needed besides just a cardHasBeenShown,
|
||||
// because this card calls the oauth code, which may show its own cards,
|
||||
// but then navigate back to this card for a moment. In that case, the
|
||||
// card needs to stay up and visible.
|
||||
if (this.pushedSecondaryCard) {
|
||||
setTimeout(this.onBack.bind(this), 100);
|
||||
}
|
||||
} else {
|
||||
// First time the card has been shown, can now sort out what card to
|
||||
// show next. This logic could be in onArgs, but it is racy, where
|
||||
// learnAbout could complete before the animation to this card completes
|
||||
// which would lead to a case where think we have pushed a secondary
|
||||
// card, but it is really the first time this card is shown, so it would
|
||||
// be hard to know if this card was being shown for first time setup
|
||||
// reasons, or because a cancel/back had occurred. Ideally, learnAbout()
|
||||
// would be called in setup_account_info, but since it could take a
|
||||
// moment to complete by waiting for network connections to complete as
|
||||
// part of autodiscovery, this card is shown to give the user feedback
|
||||
// that something is happening. Instead of using the card visible state
|
||||
// as a hack to know the cancel state, switch to a callingCard card
|
||||
// passing approach so the next card can give a specific callingCard
|
||||
// cancel signal. However, bug 973038 needs to be solved, or some way
|
||||
// to remove more than one card at a time. Passing `2` to the
|
||||
// removeCardAndSuccessors call from this card if callingCard cancel
|
||||
// signal was received would also work, if we get proper expectations
|
||||
// around the number of ontransitioned events in that case.
|
||||
this.cardHasBeenShown = true;
|
||||
if (!this.args.password) {
|
||||
this.learnAbout();
|
||||
} else {
|
||||
// The manual config pathway.
|
||||
this.tryCreate();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBack: function(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.cancelCreation();
|
||||
this.createCanceled = true;
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger the back-end's autoconfig logic based on just knowing the user's
|
||||
* email address to figure out what to do next.
|
||||
*/
|
||||
learnAbout: function() {
|
||||
MailAPI.learnAboutAccount({
|
||||
emailAddress: this.args.emailAddress
|
||||
}, function(details) {
|
||||
var args = this.args;
|
||||
args.configInfo = details.configInfo;
|
||||
var result = details.result;
|
||||
|
||||
// - We can autoconfig and it's time to use oauth!
|
||||
if (result === 'need-oauth2') {
|
||||
oauthFetch(details.configInfo.oauth2Settings, {
|
||||
login_hint: args.emailAddress
|
||||
})
|
||||
.then(function(response) {
|
||||
// Cancellation means lose the progress card and go back to entering
|
||||
// the user's email address.
|
||||
if (response.status === 'cancel') {
|
||||
this.onBack();
|
||||
// Successful oauth'ing means time to complete the account creation.
|
||||
} else if (response.status === 'success') {
|
||||
args.configInfo.oauth2Secrets = response.secrets;
|
||||
args.configInfo.oauth2Tokens = response.tokens;
|
||||
this.tryCreate();
|
||||
// Any other error means things did not work. Things not working
|
||||
// implies things will never work and so let's dump the user into
|
||||
// the manual config card.
|
||||
} else {
|
||||
console.error('Unknown oauthFetch status: ' + response.status);
|
||||
this._divertToManualConfig();
|
||||
}
|
||||
}.bind(this), this.onCreationError.bind(this));
|
||||
// We can autoconfig but we need the user's password.
|
||||
} else if (result === 'need-password') {
|
||||
// Track that a secondary card was added that could lead to a cancel
|
||||
// in that case, need to cancel this card too.
|
||||
this.pushedSecondaryCard = true;
|
||||
cards.pushCard(
|
||||
'setup_account_password', 'animate',
|
||||
{
|
||||
displayName: args.displayName,
|
||||
emailAddress: args.emailAddress
|
||||
},
|
||||
'right');
|
||||
// No configuration data available, the user's only option is manual
|
||||
// config.
|
||||
} else { // must be no-config-info and even if not, we'd want this.
|
||||
this._divertToManualConfig();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* learnAbout decided the only option for the user is to manually configure
|
||||
* their account. Sorry, user!
|
||||
*/
|
||||
_divertToManualConfig: function() {
|
||||
this.pushedSecondaryCard = true;
|
||||
cards.pushCard('setup_manual_config', 'animate', {
|
||||
displayName: this.args.displayName,
|
||||
emailAddress: this.args.emailAddress
|
||||
},
|
||||
'right');
|
||||
},
|
||||
|
||||
tryCreate: function() {
|
||||
var args = this.args;
|
||||
var options = {
|
||||
displayName: args.displayName,
|
||||
emailAddress: args.emailAddress,
|
||||
password: args.password,
|
||||
outgoingPassword: args.outgoingPassword
|
||||
};
|
||||
|
||||
MailAPI.tryToCreateAccount(
|
||||
options,
|
||||
args.configInfo || null,
|
||||
function(err, errDetails, account) {
|
||||
this.creationInProcess = false;
|
||||
if (err) {
|
||||
this.onCreationError(err, errDetails);
|
||||
} else {
|
||||
if (this.createCanceled) {
|
||||
account.deleteAccount();
|
||||
} else {
|
||||
this.onCreationSuccess(account);
|
||||
}
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
onCreationError: function(err, errDetails) {
|
||||
this.callingCard.showError(err, errDetails);
|
||||
cards.removeCardAndSuccessors(this, 'animate', 1);
|
||||
},
|
||||
|
||||
onCreationSuccess: function(account) {
|
||||
cards.pushCard('setup_account_prefs', 'animate',
|
||||
{
|
||||
account: account
|
||||
});
|
||||
},
|
||||
|
||||
die: function() {
|
||||
this.cancelCreation();
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
9
gaia/apps/email/js/cards/sig/save_signature.html
Executable file
9
gaia/apps/email/js/cards/sig/save_signature.html
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
<form role="dialog" class="cmp-draft-menu" data-type="action">
|
||||
<menu>
|
||||
<button id="sig-save" data-l10n-id="signature-save"></button>
|
||||
<button id="sig-discard" class="danger" data-l10n-id="signature-discard-confirm"></button>
|
||||
<span class="last-button-container">
|
||||
<button id="sig-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
</span>
|
||||
</menu>
|
||||
</form>
|
||||
10
gaia/apps/email/js/cards/tng/account_delete_confirm.html
Executable file
10
gaia/apps/email/js/cards/tng/account_delete_confirm.html
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
<form role="dialog" class="tng-account-delete-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="confirm-dialog-title"></h1>
|
||||
<p></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button id="account-delete-cancel" data-l10n-id="message-multiedit-cancel"></button>
|
||||
<button id="account-delete-ok" class="danger" data-l10n-id="settings-account-delete-confirm"></button>
|
||||
</menu>
|
||||
</form>
|
||||
4
gaia/apps/email/js/cards/tng/account_item.html
Executable file
4
gaia/apps/email/js/cards/tng/account_item.html
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
<li class="tng-account-item item-with-children" role="option">
|
||||
<a href="#" class="tng-account-item-label list-text"></a>
|
||||
<!-- we may want to expose some other info in this row -->
|
||||
</li>
|
||||
3
gaia/apps/email/js/cards/tng/account_settings_server.html
Executable file
3
gaia/apps/email/js/cards/tng/account_settings_server.html
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
<li class="tng-account-settings-server item-with-children">
|
||||
<a href="#" class="tng-account-server-label list-text"></a>
|
||||
</li>
|
||||
15
gaia/apps/email/js/cards/tng/plain_socket_warning.html
Executable file
15
gaia/apps/email/js/cards/tng/plain_socket_warning.html
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
<form role="dialog" class="tng-plain-socket-confirm" data-type="confirm">
|
||||
<section>
|
||||
<h1 data-l10n-id="tng-plain-socket-warning-title"></h1>
|
||||
<p data-l10n-id="tng-plain-socket-warning-message"></p>
|
||||
<p data-l10n-id="tng-plain-socket-warning-message-options"></p>
|
||||
</section>
|
||||
<menu>
|
||||
<button class="ok-btn recommend tng-plain-socket-learn-more"
|
||||
data-href="https://support.mozilla.org/kb/how-switch-secure-email-provider-firefox-os"
|
||||
data-l10n-id="tng-plain-socket-learn-more"></button>
|
||||
<button class="ok-btn confirm-dialog-ok"
|
||||
data-l10n-id="tng-plain-socket-warning-ok"></button>
|
||||
|
||||
</menu>
|
||||
</form>
|
||||
4
gaia/apps/email/js/cards/toaster.html
Executable file
4
gaia/apps/email/js/cards/toaster.html
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
<section role="status" class="toaster collapsed">
|
||||
<p class="toaster-text"></p>
|
||||
<div class="toaster-action-target"><button class="toaster-action"></button></div>
|
||||
</section>
|
||||
11
gaia/apps/email/js/cards/value_selector.html
Executable file
11
gaia/apps/email/js/cards/value_selector.html
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
<form class="email-value-selector collapsed" role="dialog" data-type="value-selector">
|
||||
<section class="scrollable">
|
||||
<h1></h1>
|
||||
<ol role="listbox">
|
||||
</ol>
|
||||
</section>
|
||||
<menu>
|
||||
<button class="full"
|
||||
data-l10n-id="message-multiedit-cancel"></button>
|
||||
</menu>
|
||||
</form>
|
||||
1
gaia/apps/email/js/cards/vsl/item.html
Executable file
1
gaia/apps/email/js/cards/vsl/item.html
Executable file
|
|
@ -0,0 +1 @@
|
|||
<li role="option"><label role="presentation"> <span></span></label></li>
|
||||
Loading…
Reference in a new issue