VulpesOS/gaia/apps/email/js/cards/compose.js
OverSu b38dea0597 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.
2026-09-30 23:13:03 +02:00

1133 lines
40 KiB
JavaScript
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Card definitions/logic for composition, contact picking, and attaching
* things. Although ideally, the picking and attaching will be handled by a
* web activity or shared code.
**/
/*global MozActivity */
'use strict';
define(function(require, exports, module) {
var cmpAttachmentItemNode = require('tmpl!./cmp/attachment_item.html'),
cmpContactMenuNode = require('tmpl!./cmp/contact_menu.html'),
cmpDraftMenuNode = require('tmpl!./cmp/draft_menu.html'),
cmpPeepBubbleNode = require('tmpl!./cmp/peep_bubble.html'),
cmpInvalidAddressesNode = require('tmpl!./cmp/invalid_addresses.html'),
msgAttachConfirmNode = require('tmpl!./msg/attach_confirm.html'),
evt = require('evt'),
htmlCache = require('html_cache'),
toaster = require('toaster'),
iframeShims = require('iframe_shims'),
Marquee = require('marquee'),
mozL10n = require('l10n!'),
cards = require('cards'),
ConfirmDialog = require('confirm_dialog'),
mimeToClass = require('mime_to_class'),
fileDisplay = require('file_display'),
addrPropNames = ['to', 'cc', 'bcc'],
dataIdCounter = 0;
/**
* Previously we limited to 5MiB because of device limitations primarily
* related to downloading attachments larger than 5MiB on Tarako devices.
* On more capable devices this is less of a concern and the dominating
* factor becomes the limits of the mail providers themselves. The value
* converged upon by most providers is 25MiB. Because of encoding overhead
* and message body concerns, for now we're sticking to 22MiB, but we should
* bump this as appropriate.
*/
var MAX_ATTACHMENT_SIZE = 22 * 1024 * 1024;
/**
* To make it easier to focus input boxes, we have clicks on their owning
* container cause a focus event to occur on the input. This method helps us
* also position the cursor based on the location of the click so the cursor
* can end up at the edges of the input box which could otherwise be very hard
* to do.
*/
function focusInputAndPositionCursorFromContainerClick(event, input) {
// Do not do anything if the event is happening on the input already or we
// will disrupt the default positioning logic! We use explicitOriginalTarget
// because under Gecko originalTarget may contain anonymous content.
if (event.explicitOriginalTarget === input) {
return;
}
// Stop bubbling to avoid our other focus-handlers!
event.stopPropagation();
// coordinates are relative to the viewport origin
var bounds = input.getBoundingClientRect();
var midX = bounds.left + bounds.width / 2;
// and that's what clientX is too!
input.focus();
var cursorPos = 0;
if (event.clientX >= midX) {
cursorPos = input.value.length;
}
input.setSelectionRange(cursorPos, cursorPos);
}
return [
require('./base_card')(require('template!./compose.html')),
require('./editor_mixins'),
{
createdCallback: function() {
// Save a cached version before anything is changed on the pristine
// template state.
htmlCache.cloneAndSave(module.id, this);
this.sending = false;
// Management of attachment work, to limit memory use
this._totalAttachmentsFinishing = 0;
this._totalAttachmentsDone = 0;
this._wantAttachment = false;
this._onAttachmentDone = this._onAttachmentDone.bind(this);
// Pass text node to editor mixins
this._bindEditor(this.textBodyNode);
// Add subject focus for larger hitbox
var subjectContainer = this.querySelector('.cmp-subject');
subjectContainer.addEventListener('click', function subjectFocus(evt) {
focusInputAndPositionCursorFromContainerClick(
evt, subjectContainer.querySelector('input'));
});
// Likewise, clicking on the empty space below our contenteditable region
// or on our immutable HTML quoting box should result in us positioning
// the cursor in our contenteditable region.
this.scrollContainer.addEventListener(
'click',
function(event) {
// Only do this if the click is BELOW the text area.
var bounds = this.textBodyNode.getBoundingClientRect();
if (event.clientY > bounds.bottom) {
this._focusEditorWithCursorAtEnd(event);
}
}.bind(this));
// Tracks if the card closed itself, in which case
// no draft saving is needed. If something else
// causes the card to die, then we want to save any
// state.
this._selfClosed = false;
// Set up unique data IDs for data-sensitive operations that could be in
// progress. These IDs are unique per kind of action, not unique per
// instance of a kind of action. However, these IDs are just used to know
// if a hard shutdown should be delayed a bit, and are unique enough for
// those purposes.
var dataId = module.id + '-' + (dataIdCounter += 1);
this._dataIdSaveDraft = dataId + '-saveDraft';
this._dataIdSendEmail = dataId + '-sendEmail';
// Set up filter for the autocomplete results to avoid dupes in addresses.
this.autocomplete.getExistingEntries =
this.getExistingEntriesForAutocomplete.bind(this);
},
onArgs: function(args) {
this.composer = args.composer;
this.composerData = args.composerData || {};
this.activity = args.activity;
this.model = args.model;
},
onCardVisible: function() {
// Once the card is visible, tell the autocomplete about some extra space
// to use when positioning the autocomplete so that there is nice space
// around the + button and there are not small scroll janks when
// positioning and scrolling the input to the top of the view.
var props = getComputedStyle(this.firstEnvelopeLine),
space = parseInt(props['padding-top'], 10) +
parseInt(props['margin-top'], 10);
this.autocomplete.verticalSpace = space;
},
/**
* 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,
/**
* Focus our contenteditable region and position the cursor at the last
* valid editing cursor position.
*
* The intent is so that if the user taps below our editing region that we
* still correctly position the cursor. We previously relied on min-height
* to do this for us, but that results in ugly problems when we have quoted
* HTML that follows and our editable region is not big enough to satisfy
* the height.
*
* Note: When we are quoting HTML, the "Bob wrote:" stuff does go in the
* contenteditable text area, so we may actually want to get smarter and
* position the cursor before that node instead.
*/
_focusEditorWithCursorAtEnd: function(event) {
if (event) {
event.stopPropagation();
}
// Selection/range manipulation is the easiest way to force the cursor
// to a specific location.
//
// Note: Once the user has pressed return once, the editor will create a
// bogus <br type="_moz"> that is always the last element. Even though
// this bogus node will be the last child, nothing tremendously bad
// happens.
//
// Note: This technique does result in our new text existing in its own,
// new text node. So don't make any assumptions about how text nodes are
// arranged.
var insertAfter = this.textBodyNode.lastChild;
var range = document.createRange();
range.setStartAfter(insertAfter);
range.setEndAfter(insertAfter);
this.textBodyNode.focus();
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
},
postInsert: function() {
// the HTML bit needs us linked into the DOM so the iframe can be
// linked in, hence this happens in postInsert.
require(['iframe_shims'], function() {
// NOTE: when the compose card changes to allow switching the From
// account then this logic will need to change, both the acquisition of
// the account pref and the folder to use for the composer. So it is
// good to group this logic together, since they both will need to
// change later.
if (this.composer) {
this._loadStateFromComposer();
} else {
var data = this.composerData,
model = this.model;
model.latestOnce('folder', function(folder) {
this.composer = model.api.beginMessageComposition(data.message,
folder,
data.options,
function() {
if (data.onComposer) {
data.onComposer(this.composer, this);
}
this._loadStateFromComposer();
}.bind(this));
}.bind(this));
}
}.bind(this));
},
_loadStateFromComposer: function() {
var self = this;
function expandAddresses(node, addresses) {
if (!addresses) {
return '';
}
addresses.forEach(function(aval) {
var name, address;
if (typeof(aval) === 'string') {
// TODO: We will apply email address parser for showing bubble
// properly. We set both name and address same as aval string
// before parser is ready.
name = address = aval;
} else {
name = aval.name;
address = aval.address;
}
self.insertBubble(node, name, address);
});
}
expandAddresses(this.toNode, this.composer.to);
expandAddresses(this.ccNode, this.composer.cc);
expandAddresses(this.bccNode, this.composer.bcc);
this.validateAddresses();
this.renderSendStatus();
// Add attachments
this.renderAttachments();
this.subjectNode.value = this.composer.subject;
// Save the initial state of the composer so that if the user immediately
// hits the back button without doing anything we can simply discard the
// draft. This is not for avoiding redundant saves or any attempt at
// efficiency.
this.origText = this.composer.body.text;
this.populateEditor(this.composer.body.text);
if (this.composer.body.html) {
// Although (still) sanitized, this is still HTML we did not create and
// so it gets to live in an iframe. Its read-only and the user needs to
// be able to see what they are sending, so reusing the viewing
// functionality is desirable.
var ishims = iframeShims.createAndInsertIframeForContent(
this.composer.body.html, this.scrollContainer,
this.htmlBodyContainer, /* append */ null,
'noninteractive',
/* no click handler because no navigation desired */ null);
this.htmlIframeNode = ishims.iframe;
}
// There is a bit more possibility of async work done in the iframeShims
// internals, but this is close enough and is better than breaking open
// the internals of the iframeShims to get the final number.
if (!this._emittedContentEvents) {
evt.emit('metrics:contentDone');
this._emittedContentEvents = true;
}
},
/**
* If this draft came from the outbox, it might have a sendStatus
* description explaining why the send failed. Display it if so.
*
* The sendStatus information on this messages is provided through
* the sendOutboxMessages job; see `jobs/outbox.js` in GELAM for details.
*/
renderSendStatus: function() {
var sendStatus = this.composer.sendStatus || {};
if (sendStatus.state === 'error') {
var badAddresses = sendStatus.badAddresses || [];
// For debugging, report some details to the console, masking
// recipients for privacy.
console.log('Editing a failed outbox message. Details:',
JSON.stringify({
err: sendStatus.err,
badAddressCount: badAddresses.length,
sendFailures: sendStatus.sendFailures
}, null, ' '));
var l10nId;
if (badAddresses.length || sendStatus.err === 'bad-recipient') {
l10nId = 'send-failure-recipients';
} else {
l10nId = 'send-failure-unknown';
}
this.errorMessage.setAttribute('data-l10n-id', l10nId);
this.errorMessage.classList.remove('collapsed');
} else {
this.errorMessage.classList.add('collapsed');
}
},
/**
* Return true if the given address is syntactically valid.
*
* @param {String} address
* The email address to validate, as a string.
* @return {Boolean}
*/
isValidAddress: function(address) {
// An address is valid if model.api.parseMailbox thinks it
// contains a valid address. (It correctly classifies names that
// are not valid addresses.)
var mailbox = this.model.api.parseMailbox(address);
return mailbox && mailbox.address;
},
/**
* Extract addresses from the bubbles and/or inputs, returning a map
* with keys for 'to', 'cc', 'bcc', 'all', and 'invalid' addresses.
*/
extractAddresses: function() {
var allAddresses = [];
var invalidAddresses = [];
// Extract the addresses from the bubbles as well as any partial
// addresses entered in the text input.
var frobAddressNode = (function(node) {
var bubbles = node.parentNode.querySelectorAll('.cmp-peep-bubble');
var addrList = [];
for (var i = 0; i < bubbles.length; i++) {
var dataSet = bubbles[i].dataset;
addrList.push({ name: dataSet.name, address: dataSet.address });
}
if (node.value.trim().length !== 0) {
var mailbox = this.model.api.parseMailbox(node.value);
addrList.push({ name: mailbox.name, address: mailbox.address });
}
addrList.forEach(function(addr) {
allAddresses.push(addr);
if (!this.isValidAddress(addr.address)) {
invalidAddresses.push(addr);
}
}.bind(this));
return addrList;
}.bind(this));
// NOTE: allAddresses contains invalidAddresses, but we never
// actually send a message directly using either of those lists.
// We use to/cc/bcc for that, and our send validation here
// prevents users from sending a message with invalid addresses.
return {
to: frobAddressNode(this.toNode),
cc: frobAddressNode(this.ccNode),
bcc: frobAddressNode(this.bccNode),
all: allAddresses,
invalid: invalidAddresses
};
},
haveAddressesChanged: function() {
var addrs = this.extractAddresses();
function addressesDiffer(a, b) {
return a.length !== b.length || a.some(function(e, i) {
return e !== b[i].address;
});
}
return addressesDiffer(this.composer.to, addrs.to) ||
addressesDiffer(this.composer.cc, addrs.cc) ||
addressesDiffer(this.composer.bcc, addrs.bcc);
},
_saveStateToComposer: function() {
var addrs = this.extractAddresses();
this.composer.to = addrs.to;
this.composer.cc = addrs.cc;
this.composer.bcc = addrs.bcc;
this.composer.subject = this.subjectNode.value;
this.composer.body.text = this.fromEditor();
// The HTML representation cannot currently change in our UI, so no
// need to save it. However, what we send to the back-end is what gets
// sent, so if you want to implement editing UI and change this here,
// go crazy.
},
_closeCard: function() {
this._selfClosed = true;
cards.removeCardAndSuccessors(this, 'animate');
},
_saveNeeded: function() {
// If no composer, then it means the card was destroyed before full
// setup, which means there is nothing to save.
if (!this.composer) {
return false;
}
var hasNewContent = this.fromEditor() !== this.composer.body.text;
// We need `to save / ask about deleting the draft if:
// There's any recipients listed, there's a subject, there's anything in
// the body, there are attachments, or we already created a draft for this
// guy in which case we really want to provide the option to delete the
// draft.
return (this.subjectNode.value || hasNewContent ||
this.haveAddressesChanged() || this.composer.attachments.length ||
this.composer.hasDraft);
},
_saveDraft: function(reason, callback) {
// If the send process is happening, suppress automatic saves.
// (Manual saves should not happen when 'sending' is true, but breaking
// auto-saves would be very bad form.)
if (this.sending && reason === 'automatic') {
console.log('compose: skipping autosave because send in progress');
return;
}
this._saveStateToComposer();
evt.emit('uiDataOperationStart', this._dataIdSaveDraft);
this.composer.saveDraft(function() {
evt.emit('uiDataOperationStop', this._dataIdSaveDraft);
if (callback) {
callback();
}
}.bind(this));
},
createBubbleNode: function(name, address) {
var bubble = cmpPeepBubbleNode.cloneNode(true);
bubble.classList.add('peep-bubble');
bubble.classList.add('msg-peep-bubble');
bubble.setAttribute('data-address', address);
bubble.querySelector('.cmp-peep-address').textContent = address;
var nameNode = bubble.querySelector('.cmp-peep-name');
if (!name) {
nameNode.textContent = address.indexOf('@') !== -1 ?
address.split('@')[0] : address;
} else {
nameNode.textContent = name;
bubble.setAttribute('data-name', name);
}
bubble.dataset.address = bubble.dataset.address || bubble.dataset.name;
return bubble;
},
getExistingEntriesForAutocomplete: function() {
var addrs = this.extractAddresses(),
all = [];
addrPropNames.forEach(function(prop) {
var ary = addrs[prop];
if (ary.length) {
all = all.concat(ary);
}
});
return all;
},
autocompleteSelected: function(event) {
var match = event.detail.match,
inputNode = event.detail.inputNode;
this.addFromEntry(match, inputNode);
},
addFromEntry: function(match, inputNode) {
inputNode.style.width = '0.5rem';
this.insertBubble(inputNode, match.name, match.address);
inputNode.value = '';
inputNode.focus();
},
/**
* insertBubble: We can set the input text node, name and address to
* insert a bubble before text input.
*/
insertBubble: function(node, name, address) {
var container = node.parentNode;
var bubble = this.createBubbleNode(name || address, address);
container.insertBefore(bubble, node);
this.validateAddresses();
},
/**
* deleteBubble: Delete the bubble from the parent container.
*/
deleteBubble: function(node) {
if (!node) {
return;
}
var container = node.parentNode;
if (node.classList.contains('cmp-peep-bubble')) {
container.removeChild(node);
}
this.validateAddresses();
},
/**
* editBubble: Turn the bubble back into editable text.
*/
editBubble: function(node) {
if (!node) {
return;
}
var container = node.parentNode;
if (node.classList.contains('cmp-peep-bubble')) {
container.removeChild(node);
var input = container.querySelector('.cmp-addr-text');
// If there is already a partially or fully entered address in
// the typing area, force it to be converted into a bubble, even
// though the resulting address may not be valid. If it's not
// valid, that bubble can subsequently be edited. This helps
// avoid the user losing anything they typed in.
if (input.value.length > 0) {
input.value = input.value + ',';
this.onAddressInput({ target: input }); // Bubblize if necessary.
}
var address = node.dataset.address;
var selStart = input.value.length;
var selEnd = selStart + address.length;
input.value += address;
input.focus();
this.onAddressInput({ target: input }); // Force width calculations.
input.setSelectionRange(selStart, selEnd);
}
this.validateAddresses();
},
/**
* Handle bubble deletion while keyboard backspace keydown.
*/
onAddressKeydown: function(evt) {
var node = evt.target;
if (evt.keyCode === 8 && node.value === '') {
//delete bubble
var previousBubble = node.previousElementSibling;
this.deleteBubble(previousBubble);
// Deleting a bubble changes the positions of the inputs, let the
// autocomplete know about it.
this.autocomplete.onInput();
}
},
/**
* Handle bubble creation while keyboard comma input.
*/
onAddressInput: function(evt) {
var node = evt.target;
var entryMatch;
var makeBubble = false;
// When do we want to tie off this e-mail address, put it into a bubble
// and clear the input box so the user can type another address?
switch (node.value.slice(-1)) {
// If they hit space and we believe they've already typed an email
// address! (Space is okay in a display name or to delimit a display
// name from the e-mail address)
//
// We use the presence of an '@' character as indicating that the e-mail
// address
case ' ':
makeBubble = node.value.indexOf('@') !== -1;
break;
// We started out supporting comma, but now it's not on our keyboard at
// all in type=email mode! We aren't terribly concerned about it not
// being usable in display names, although we really should check for
// quoting...
case ',':
// Semicolon is on the keyboard, and we also don't care about it not
// being usable in display names.
case ';':
makeBubble = true;
break;
}
if (makeBubble) {
entryMatch = this.model.api.parseMailbox(node.value);
this.addFromEntry(entryMatch, node);
}
// XXX: Workaround to get the length of the string. Here we create a dummy
// div for computing actual string size for changing input
// size dynamically.
if (!this.stringContainer) {
this.stringContainer = document.createElement('div');
this.appendChild(this.stringContainer);
var inputStyle = window.getComputedStyle(node);
this.stringContainer.style.fontSize = inputStyle.fontSize;
}
this.stringContainer.style.display = 'inline-block';
this.stringContainer.textContent = node.value;
node.style.width =
(this.stringContainer.clientWidth + 2) + 'px';
this.validateAddresses();
},
onContainerClick: function(evt) {
var target = evt.target;
// Popup the context menu if clicked target is peer bubble.
if (target.classList.contains('cmp-peep-bubble')) {
var contents = cmpContactMenuNode.cloneNode(true);
var email = target.querySelector('.cmp-peep-address').textContent;
var headerNode = contents.getElementsByTagName('header')[0];
// Setup the marquee structure
Marquee.setup(email, headerNode);
// Activate marquee once the contents DOM are added to document
cards.setStatusColor(contents);
document.body.appendChild(contents);
Marquee.activate('alternate', 'ease');
var formSubmit = (function(evt) {
cards.setStatusColor();
document.body.removeChild(contents);
switch (evt.explicitOriginalTarget.className) {
case 'cmp-contact-menu-edit':
this.editBubble(target);
break;
case 'cmp-contact-menu-delete':
this.deleteBubble(target);
break;
case 'cmp-contact-menu-cancel':
break;
}
return false;
}).bind(this);
contents.addEventListener('submit', formSubmit);
return;
}
// While user clicks on the container, focus on input to triger
// the keyboard.
var input = evt.currentTarget.getElementsByClassName('cmp-addr-text')[0];
focusInputAndPositionCursorFromContainerClick(evt, input);
},
/**
* Helper to show the appropriate error when we refuse to add attachments.
*/
_warnAttachmentSizeExceeded: function(numAttachments) {
var dialog = msgAttachConfirmNode.cloneNode(true);
var title = dialog.getElementsByTagName('h1')[0];
var content = dialog.getElementsByTagName('p')[0];
if (numAttachments > 1) {
// Note! attachments with an "s" versus the case below.
mozL10n.setAttributes(title, 'composer-attachments-large');
mozL10n.setAttributes(content, 'compose-attchments-size-exceeded');
} else {
mozL10n.setAttributes(title, 'composer-attachment-large');
mozL10n.setAttributes(content, 'compose-attchment-size-exceeded');
}
ConfirmDialog.show(dialog,
{
// ok
id: 'msg-attach-ok',
handler: function() {
// There is nothing to do.
}.bind(this)
}
);
},
/**
* Used to count when attachment has been fully processed by this.composer.
* Broken out as a separate member method to avoid inline closures in
* addAttachmentsSubjectToSizeLimits that may lead to holding on to too much
* memory.
*/
_onAttachmentDone: function() {
this._totalAttachmentsDone += 1;
if (this._totalAttachmentsDone < this._totalAttachmentsFinishing) {
return;
}
// Give a bit of time for all the DB transactions to clean up.
// Unfortunately there are no good signals to do this decisively so just
// adding a bit of a buffer, just to be nice for super low memory
// devices. Not a catastrophe if work is still going on when the timeout
// fires.
setTimeout(function() {
var wantAttachment = this._wantAttachment;
this._totalAttachmentsFinishing = 0;
this._totalAttachmentsDone = 0;
this._wantAttachment = false;
// Close out the toaster if it was showing. While the toaster could
// be showing for some other reason, this is the most likely cause,
// and want to give the user the impression of fast action.
if (toaster.isShowing()) {
toaster.hide();
}
// If the user wanted to add something else, proceed, since in many
// cases, the user just had to wait a second or so before we could
// proceed anyway.
if (wantAttachment) {
this.onAttachmentAdd();
}
}.bind(this), 600);
},
/**
* Given a list of Blobs/Files that we want to attach, attach as many as
* possible and generate an error message for any we can't attach. This
* will update the UI as a side-effect; you do not need to do it.
*/
addAttachmentsSubjectToSizeLimits: function(toAttach) {
// Tally the size of the already-attached attachments.
var totalSize = this.calculateTotalAttachmentsSize();
// Keep attaching until we find one that puts us over the limit. Then
// generate an error whose plurality is based on the number of attachments
// we are not attaching. We do not do any bin-packing smarts where we try
// and see if any of the attachments in `toAttach` might fit.
//
// This specific behaviour is potentially a little odd; we're going with
// consistency of the original implementation of bug 871852 but without
// the horrible bug introduced by bug 871897 and being addressed by this
// in bug 1006271.
var attachedAny = false;
while (toAttach.length) {
var attachment = toAttach.shift();
totalSize += attachment.blob.size;
if (totalSize >= MAX_ATTACHMENT_SIZE) {
this._warnAttachmentSizeExceeded(1 + toAttach.length);
break;
}
this._totalAttachmentsFinishing += 1;
this.composer.addAttachment(attachment, this._onAttachmentDone);
attachedAny = true;
}
if (attachedAny) {
this.renderAttachments();
}
},
/**
* Build the UI that displays the current attachments. Invokes
* `updateAttachmentsSize` too so you don't have to.
*/
renderAttachments: function() {
if (this.composer.attachments && this.composer.attachments.length) {
// Clean the container before we insert the new attachments
this.attachmentsContainer.innerHTML = '';
var attTemplate = cmpAttachmentItemNode,
filenameTemplate =
attTemplate.getElementsByClassName('cmp-attachment-filename')[0],
filesizeTemplate =
attTemplate.getElementsByClassName('cmp-attachment-filesize')[0];
for (var i = 0; i < this.composer.attachments.length; i++) {
var attachment = this.composer.attachments[i];
filenameTemplate.textContent = attachment.name;
fileDisplay.fileSize(filesizeTemplate, attachment.blob.size);
var attachmentNode = attTemplate.cloneNode(true);
this.attachmentsContainer.appendChild(attachmentNode);
attachmentNode.classList.add(mimeToClass(attachment.blob.type));
attachmentNode.getElementsByClassName('cmp-attachment-remove')[0]
.addEventListener('click',
this.onClickRemoveAttachment.bind(
this, attachmentNode, attachment));
}
this.updateAttachmentsSize();
this.attachmentsContainer.classList.remove('collapsed');
}
else {
this.attachmentsContainer.classList.add('collapsed');
}
this.updateAttachmentsAriaLabel();
},
/**
* Update the label used for accessibility that describes the attachments
* included.
*/
updateAttachmentsAriaLabel: function() {
// Only include total size in KB if there is more than 1 attachment.
var kilobytes = this.composer.attachments.length > 1 ?
Math.ceil(this.calculateTotalAttachmentsSize() / 1024) : 0;
mozL10n.setAttributes(this.attachmentsContainer,
'compose-attachments-container', { kilobytes: kilobytes });
},
/**
* Calculate the total size of all attachments included.
*/
calculateTotalAttachmentsSize: function() {
var totalSize = 0;
for (var i = 0; i < this.composer.attachments.length; i++) {
totalSize += this.composer.attachments[i].blob.size;
}
return totalSize;
},
/**
* Update the summary that says how many attachments we have and the
* aggregate attachment size.
*/
updateAttachmentsSize: function() {
mozL10n.setAttributes(this.attachmentLabel, 'compose-attachments',
{ n: this.composer.attachments.length });
if (this.composer.attachments.length === 0) {
this.attachmentsSize.textContent = '';
// When there is no attachments, hide the container
// to keep the style of empty attachments
this.attachmentsContainer.classList.add('collapsed');
}
else {
fileDisplay.fileSize(this.attachmentsSize,
this.calculateTotalAttachmentsSize());
}
// Only display the total size when the number of attachments is more
// than 1
if (this.composer.attachments.length > 1) {
this.attachmentTotal.classList.remove('collapsed');
} else {
this.attachmentTotal.classList.add('collapsed');
}
},
onClickRemoveAttachment: function(node, attachment) {
node.parentNode.removeChild(node);
this.composer.removeAttachment(attachment);
this.updateAttachmentsAriaLabel();
this.updateAttachmentsSize();
},
/**
* Save the draft if there's anything to it, close the card.
*/
onBack: function() {
var goBack = (function() {
if (this.activity) {
// We need more testing here to make sure the behavior that back
// to originated activity works perfectly without any crash or
// unable to switch back.
this.activity.postError('cancelled');
this.activity = null;
}
this._closeCard();
}).bind(this);
if (!this._saveNeeded()) {
console.log('compose: back: no save needed, exiting without prompt');
goBack();
return;
}
console.log('compose: back: save needed, prompting');
var menu = cmpDraftMenuNode.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 'cmp-draft-save':
console.log('compose: explicit draft save on exit');
this._saveDraft('explicit');
goBack();
break;
case 'cmp-draft-discard':
console.log('compose: explicit draft discard on exit');
this.composer.abortCompositionDeleteDraft();
goBack();
break;
case 'cmp-draft-cancel':
console.log('compose: canceled compose exit');
break;
}
return false;
}).bind(this);
menu.addEventListener('submit', formSubmit);
},
/**
* Save the draft if there's anything to it. Called by Cards if this card
* is also the current card.
*/
onCurrentCardDocumentVisibilityChange: function() {
if (document.hidden && this._saveNeeded()) {
console.log('compose: autosaving; we became hidden and save needed.');
this._saveDraft('automatic');
}
},
/**
* Validate that the provided addresses are valid. Enable the send
* button conditional on all addresses being correct. If all
* addresses are correct and we had previously displayed a
* sendStatus error, hide the sendStatus error display.
*
* @return {Boolean}
* True if all addresses are valid, otherwise false.
*/
validateAddresses: function() {
var addrs = this.extractAddresses();
// The send button should only be disabled if the addresses are
// empty. They can still tap the send button if there are invalid
// addresses.
if (addrs.all.length === 0) {
this.sendButton.setAttribute('aria-disabled', 'true');
} else {
this.sendButton.setAttribute('aria-disabled', 'false');
}
if (addrs.invalid.length === 0) {
// If the error message is visible, meaning they opened this
// message from the outbox after a send failure, remove the error
// when they've corrected the recipients.
this.errorMessage.classList.add('collapsed');
return true; // No invalid addresses.
} else {
return false; // Some addresses were invalid.
}
},
/**
* If the user attempts to tap the send button while there are
* invalid addresses, display a dialog to warn them to correct the
* error. Otherwise, go ahead and send the message.
*/
onSend: function() {
if (!this.validateAddresses()) {
ConfirmDialog.show(cmpInvalidAddressesNode.cloneNode(true), {
id: 'cmp-confirm-invalid-addresses',
handler: function() {
// There is nothing to do.
}
});
} else {
this.reallySend();
}
},
/**
* Actually send the message, foregoing any validation that the
* addresses are valid (as we did in `onSend` above).
*/
reallySend: function() {
/* Check if already lock is enabled,
* If so disable it and then re enable the lock
*/
this._saveStateToComposer();
var activity = this.activity;
// Indicate we are sending so we can suppress any of our auto-save logic
// from trying to fire.
this.sending = true;
// Initiate the send.
console.log('compose: initiating send');
evt.emit('uiDataOperationStart', this._dataIdSendEmail);
this.composer.finishCompositionSendMessage(function(sendInfo) {
evt.emit('uiDataOperationStop', this._dataIdSendEmail);
// Card could have been destroyed in the meantime,
// via an app card reset (not a _selfClosed case),
// so do not bother with the rest of this work if
// that was the case.
if (!this.composer) {
return;
}
if (activity) {
// Just mention the action completed, but do not give
// specifics, to maintain some privacy.
activity.postResult('complete');
activity = null;
}
this._closeCard();
}.bind(this));
},
onContactAdd: function(event) {
event.stopPropagation();
var contactBtn = event.target;
var self = this;
contactBtn.classList.remove('show');
try {
var activity = new MozActivity({
name: 'pick',
data: {
type: 'webcontacts/email'
}
});
activity.onsuccess = function success() {
if (this.result.email) {
var emt = contactBtn.parentElement.querySelector('.cmp-addr-text');
var name = this.result.name;
if (Array.isArray(name)) {
name = name[0];
}
self.insertBubble(emt, name, this.result.email);
}
};
} catch (e) {
console.log('WebActivities unavailable? : ' + e);
}
},
onAttachmentAdd: function(event) {
if (event) {
event.stopPropagation();
}
// To be nice on memory consumption, wait for any previous attachment to
// finish attaching before triggering another attachment action.
if (this._totalAttachmentsFinishing > 0) {
// Use a separate flag than testing if the toaster is showing, in case
// the toaster is shown for some other reason. In that case, do not want
// to trigger activity after previous attachment completes.
this._wantAttachment = true;
toaster.toast({
text: mozL10n.get('compose-attachment-still-working')
});
return;
}
try {
console.log('compose: attach: triggering web activity');
var activity = new MozActivity({
name: 'pick',
data: {
type: ['image/*', 'video/*', 'audio/*', 'application/*',
'text/vcard'],
nocrop: true
}
});
activity.onsuccess = (function success() {
// Load the util on demand, since one small codepath needs it, and
// it avoids needing to bundle util's dependencies in a built layer.
require(['attachment_name'], function(attachmentName) {
var blob = activity.result.blob,
name = activity.result.blob.name || activity.result.name,
count = this.composer.attachments.length + 1;
name = attachmentName.ensureName(blob, name, count);
console.log('compose: attach activity success:', name);
name = name.substring(name.lastIndexOf('/') + 1);
this.addAttachmentsSubjectToSizeLimits([{
name: name,
blob: activity.result.blob
}]);
}.bind(this));
}).bind(this);
} catch (e) {
console.log('WebActivities unavailable? : ' + e);
}
},
die: function() {
// If confirming for prompt when destroyed, just remove
// and if save is needed, it will be autosaved below.
if (this._savePromptMenu) {
document.body.removeChild(this._savePromptMenu);
this._savePromptMenu = null;
}
// If something else besides the card causes this card
// to die, but we have a draft to save, do it now.
// However, wait for the draft save to complete before
// completely shutting down the composer.
if (!this._selfClosed && this._saveNeeded()) {
console.log('compose: autosaving draft because not self-closed');
this._saveDraft('automatic');
}
if (this.composer) {
this.composer.die();
this.composer = null;
}
}
}
];
});