Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1198 lines
43 KiB
JavaScript
Executable file
1198 lines
43 KiB
JavaScript
Executable file
/*global MozActivity */
|
|
'use strict';
|
|
define(function(require) {
|
|
|
|
var MimeMapper,
|
|
msgDeleteConfirmNode = require('tmpl!./msg/delete_confirm.html'),
|
|
msgContactMenuNode = require('tmpl!./msg/contact_menu.html'),
|
|
msgReplyMenuNode = require('tmpl!./msg/reply_menu.html'),
|
|
msgBrowseConfirmNode = require('tmpl!./msg/browse_confirm.html'),
|
|
msgPeepBubbleNode = require('tmpl!./msg/peep_bubble.html'),
|
|
msgAttachmentItemNode = require('tmpl!./msg/attachment_item.html'),
|
|
msgAttachmentDisabledConfirmNode =
|
|
require('tmpl!./msg/attachment_disabled_confirm.html'),
|
|
msgAttachmentDidNotOpenAlertNode =
|
|
require('tmpl!./msg/attachment_did_not_open_alert.html'),
|
|
cards = require('cards'),
|
|
ConfirmDialog = require('confirm_dialog'),
|
|
date = require('date'),
|
|
toaster = require('toaster'),
|
|
HeaderCursor = require('header_cursor'),
|
|
evt = require('evt'),
|
|
iframeShims = require('iframe_shims'),
|
|
Marquee = require('marquee'),
|
|
mozL10n = require('l10n!'),
|
|
queryURI = require('query_uri'),
|
|
mimeToClass = require('mime_to_class'),
|
|
fileDisplay = require('file_display'),
|
|
messageDisplay = require('message_display');
|
|
|
|
var CONTENT_TYPES_TO_CLASS_NAMES = [
|
|
null,
|
|
'msg-body-content',
|
|
'msg-body-signature',
|
|
'msg-body-leadin',
|
|
null,
|
|
'msg-body-disclaimer',
|
|
'msg-body-list',
|
|
'msg-body-product',
|
|
'msg-body-ads'
|
|
];
|
|
var CONTENT_QUOTE_CLASS_NAMES = [
|
|
'msg-body-q1',
|
|
'msg-body-q2',
|
|
'msg-body-q3',
|
|
'msg-body-q4',
|
|
'msg-body-q5',
|
|
'msg-body-q6',
|
|
'msg-body-q7',
|
|
'msg-body-q8',
|
|
'msg-body-q9'
|
|
];
|
|
var MAX_QUOTE_CLASS_NAME = 'msg-body-qmax';
|
|
|
|
var OCTET_STREAM_TYPE = 'application/octet-stream';
|
|
|
|
// This function exists just to avoid lint errors around
|
|
// "do not use 'new' for side effects.
|
|
function sendActivity(obj) {
|
|
return new MozActivity(obj);
|
|
}
|
|
|
|
return [
|
|
require('./base_card')(require('template!./message_reader.html')),
|
|
{
|
|
createdCallback: function() {
|
|
// The body elements for the (potentially multiple) iframes we created to
|
|
// hold HTML email content.
|
|
this.htmlBodyNodes = [];
|
|
|
|
this._on('msg-up-btn', 'click', 'onPrevious');
|
|
this._on('msg-down-btn', 'click', 'onNext');
|
|
this._on('msg-reply-btn', 'click', 'onReplyMenu');
|
|
this._on('msg-delete-btn', 'click', 'onDelete');
|
|
this._on('msg-star-btn', 'click', 'onToggleStar');
|
|
this._on('msg-move-btn', 'click', 'onMove');
|
|
this._on('msg-mark-read-btn', 'click', 'onMarkRead');
|
|
this._on('msg-envelope-bar', 'click', 'onEnvelopeClick');
|
|
this._on('msg-reader-load-infobar', 'click', 'onLoadBarClick');
|
|
|
|
this._emittedContentEvents = false;
|
|
this.disableReply();
|
|
|
|
// whether or not we've built the body DOM the first time
|
|
this._builtBodyDom = false;
|
|
|
|
// Bind some methods to this so they can be used as event listeners
|
|
this.handleBodyChange = this.handleBodyChange.bind(this);
|
|
this.onMessageSuidNotFound = this.onMessageSuidNotFound.bind(this);
|
|
this.onCurrentMessage = this.onCurrentMessage.bind(this);
|
|
},
|
|
|
|
onArgs: function(args) {
|
|
this.model = args.model;
|
|
this.messageSuid = args.messageSuid;
|
|
|
|
var headerCursor = this.headerCursor = args.headerCursor ||
|
|
new HeaderCursor(this.model);
|
|
|
|
headerCursor.on('messageSuidNotFound', this.onMessageSuidNotFound);
|
|
headerCursor.latest('currentMessage', this.onCurrentMessage);
|
|
|
|
// This should handle the case where we jump right into the reader.
|
|
headerCursor.setCurrentMessage(this.header);
|
|
},
|
|
|
|
_contextMenuType: {
|
|
VIEW_CONTACT: 1,
|
|
CREATE_CONTACT: 2,
|
|
ADD_TO_CONTACT: 4,
|
|
REPLY: 8,
|
|
NEW_MESSAGE: 16
|
|
},
|
|
|
|
/**
|
|
* 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,
|
|
|
|
// Method to help bind event listeners to method names, and ensures
|
|
// a header object before activating the method, to protect the buttons
|
|
// from being activated while the model is still loading.
|
|
_on: function(className, eventName, method, skipProtection) {
|
|
this.getElementsByClassName(className)[0]
|
|
.addEventListener(eventName, function(evt) {
|
|
if (this.header || skipProtection) {
|
|
return this[method](evt);
|
|
}
|
|
}.bind(this), false);
|
|
},
|
|
|
|
_setHeader: function(header) {
|
|
this.header = header.makeCopy();
|
|
this.hackMutationHeader = header;
|
|
|
|
// - mark message read (if it is not already)
|
|
if (!this.header.isRead) {
|
|
this.header.setRead(true);
|
|
} else {
|
|
this.readBtn.classList.remove('unread');
|
|
mozL10n.setAttributes(this.readBtn, 'message-mark-read-button');
|
|
}
|
|
|
|
this.starBtn.classList.toggle('msg-star-btn-on',
|
|
this.hackMutationHeader.isStarred);
|
|
this.starBtn.setAttribute('aria-pressed',
|
|
this.hackMutationHeader.isStarred);
|
|
|
|
this.emit('header');
|
|
},
|
|
|
|
postInsert: function() {
|
|
this._inDom = true;
|
|
|
|
// If have a message that is waiting for the DOM, finish
|
|
// out the display work.
|
|
if (this._afterInDomMessage) {
|
|
this.onCurrentMessage(this._afterInDomMessage);
|
|
this._afterInDomMessage = null;
|
|
}
|
|
},
|
|
|
|
told: function(args) {
|
|
if (args.messageSuid) {
|
|
this.messageSuid = args.messageSuid;
|
|
}
|
|
},
|
|
|
|
handleBodyChange: function(evt) {
|
|
this.buildBodyDom(evt.changeDetails);
|
|
},
|
|
|
|
onBack: function(event) {
|
|
cards.removeCardAndSuccessors(this, 'animate');
|
|
},
|
|
|
|
/**
|
|
* Broadcast that we need to move previous if there's a previous sibling.
|
|
*
|
|
* @param {Event} event previous arrow click event.
|
|
*/
|
|
onPrevious: function(event) {
|
|
this.headerCursor.advance('previous');
|
|
},
|
|
|
|
/**
|
|
* Broadcast that we need to move next if there's a next sibling.
|
|
*
|
|
* @param {Event} event next arrow click event.
|
|
*/
|
|
onNext: function(event) {
|
|
this.headerCursor.advance('next');
|
|
},
|
|
|
|
onMessageSuidNotFound: function(messageSuid) {
|
|
// If no message was found, then go back. This card
|
|
// may have been created from obsolete data, like an
|
|
// old notification for a message that no longer exists.
|
|
// This stops atTop since the most likely case for this
|
|
// entry point is either clicking on a message that is
|
|
// at the top of the inbox in the HTML cache, or from a
|
|
// notification for a new message, which would be near
|
|
// the top.
|
|
if (this.messageSuid === messageSuid) {
|
|
this.onBack();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Set the message we're reading.
|
|
*
|
|
* @param {MessageCursor.CurrentMessage} currentMessage representation of
|
|
* the email we're currently reading.
|
|
*/
|
|
onCurrentMessage: function(currentMessage) {
|
|
// If the card is not in the DOM yet, do not proceed, as
|
|
// the iframe work needs to happen once DOM is available.
|
|
if (!this._inDom) {
|
|
this._afterInDomMessage = currentMessage;
|
|
return;
|
|
}
|
|
|
|
// Ignore doing extra work if current message is the same as the one
|
|
// already tied to this message reader.
|
|
if (this.header && this.header.id === currentMessage.header.id) {
|
|
return;
|
|
}
|
|
|
|
// Set our current message.
|
|
this.messageSuid = null;
|
|
this._setHeader(currentMessage.header);
|
|
this.clearDom();
|
|
|
|
// Display the header and fetch the body for display.
|
|
this.latestOnce('header', function() {
|
|
// iframes need to be linked into the DOM tree before their
|
|
// contentDocument can be instantiated.
|
|
this.buildHeaderDom(this);
|
|
|
|
this.header.getBody({ downloadBodyReps: true }, function(body) {
|
|
// If the header has changed since the last getBody call, ignore.
|
|
if (this.header.id !== body.id) {
|
|
return;
|
|
}
|
|
|
|
this.body = body;
|
|
|
|
// always attach the change listener.
|
|
body.onchange = this.handleBodyChange;
|
|
|
|
// if the body reps are downloaded show the message immediately.
|
|
if (body.bodyRepsDownloaded) {
|
|
this.buildBodyDom();
|
|
}
|
|
|
|
// XXX trigger spinner
|
|
//
|
|
}.bind(this));
|
|
}.bind(this));
|
|
|
|
// Previous.
|
|
var hasPrevious = currentMessage.siblings.hasPrevious;
|
|
this.previousBtn.disabled = !hasPrevious;
|
|
this.previousIcon.classList[hasPrevious ? 'remove' : 'add'](
|
|
'icon-disabled');
|
|
|
|
// Next.
|
|
var hasNext = currentMessage.siblings.hasNext;
|
|
this.nextBtn.disabled = !hasNext;
|
|
this.nextIcon.classList[hasNext ? 'remove' : 'add']('icon-disabled');
|
|
},
|
|
|
|
reply: function() {
|
|
cards.eatEventsUntilNextCard();
|
|
var composer = this.header.replyToMessage(null, () => {
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composer: composer
|
|
});
|
|
});
|
|
},
|
|
|
|
replyAll: function() {
|
|
cards.eatEventsUntilNextCard();
|
|
var composer = this.header.replyToMessage('all', () => {
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composer: composer
|
|
});
|
|
});
|
|
},
|
|
|
|
forward: function() {
|
|
var needToPrompt = this.header.hasAttachments ||
|
|
this.body.embeddedImageCount > 0;
|
|
|
|
var forwardMessage = (function() {
|
|
cards.eatEventsUntilNextCard();
|
|
var composer = this.header.forwardMessage('inline', () => {
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composer: composer
|
|
});
|
|
});
|
|
}.bind(this));
|
|
|
|
if (needToPrompt) {
|
|
var dialog = msgAttachmentDisabledConfirmNode.cloneNode(true);
|
|
ConfirmDialog.show(dialog,
|
|
{
|
|
id: 'msg-attachment-disabled-ok',
|
|
handler: function() {
|
|
forwardMessage();
|
|
}
|
|
},
|
|
{
|
|
id: 'msg-attachment-disabled-cancel',
|
|
handler: null
|
|
}
|
|
);
|
|
} else {
|
|
forwardMessage();
|
|
}
|
|
},
|
|
|
|
// TODO: canReplyAll should be moved into GELAM.
|
|
/** Returns true if Reply All should be shown as a distinct option. */
|
|
canReplyAll: function() {
|
|
// If any e-mail is listed as 'to' or 'cc' and doesn't match this
|
|
// user's account, 'Reply All' should be enabled.
|
|
var myAddresses = this.model.account.identities.map(function(ident) {
|
|
return ident.address;
|
|
});
|
|
|
|
var otherAddresses = (this.header.to || []).concat(this.header.cc || []);
|
|
if (this.header.replyTo && this.header.replyTo.author) {
|
|
otherAddresses.push(this.header.replyTo.author);
|
|
}
|
|
for (var i = 0; i < otherAddresses.length; i++) {
|
|
var otherAddress = otherAddresses[i];
|
|
if (otherAddress.address &&
|
|
myAddresses.indexOf(otherAddress.address) == -1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
onReplyMenu: function(event) {
|
|
var contents = msgReplyMenuNode.cloneNode(true);
|
|
cards.setStatusColor(contents);
|
|
document.body.appendChild(contents);
|
|
|
|
// reply menu selection handling
|
|
var formSubmit = (function(evt) {
|
|
cards.setStatusColor();
|
|
document.body.removeChild(contents);
|
|
switch (evt.explicitOriginalTarget.className) {
|
|
case 'msg-reply-menu-reply':
|
|
this.reply();
|
|
break;
|
|
case 'msg-reply-menu-reply-all':
|
|
this.replyAll();
|
|
break;
|
|
case 'msg-reply-menu-forward':
|
|
this.forward();
|
|
break;
|
|
case 'msg-reply-menu-cancel':
|
|
break;
|
|
}
|
|
return false;
|
|
}).bind(this);
|
|
contents.addEventListener('submit', formSubmit);
|
|
|
|
if (!this.canReplyAll()) {
|
|
contents.querySelector('.msg-reply-menu-reply-all')
|
|
.classList.add('collapsed');
|
|
}
|
|
},
|
|
|
|
onDelete: function() {
|
|
var dialog = msgDeleteConfirmNode.cloneNode(true);
|
|
var content = dialog.getElementsByTagName('p')[0];
|
|
mozL10n.setAttributes(content, 'message-edit-delete-confirm');
|
|
ConfirmDialog.show(dialog,
|
|
{ // Confirm
|
|
id: 'msg-delete-ok',
|
|
handler: function() {
|
|
var op = this.header.deleteMessage();
|
|
cards.removeCardAndSuccessors(this, 'animate');
|
|
toaster.toastOperation(op);
|
|
}.bind(this)
|
|
},
|
|
{ // Cancel
|
|
id: 'msg-delete-cancel',
|
|
handler: null
|
|
}
|
|
);
|
|
},
|
|
|
|
onToggleStar: function() {
|
|
this.starBtn.classList.toggle('msg-star-btn-on',
|
|
!this.hackMutationHeader.isStarred);
|
|
|
|
this.hackMutationHeader.isStarred = !this.hackMutationHeader.isStarred;
|
|
this.starBtn.setAttribute('aria-pressed',
|
|
this.hackMutationHeader.isStarred);
|
|
this.header.setStarred(this.hackMutationHeader.isStarred);
|
|
},
|
|
|
|
onMove: function() {
|
|
//TODO: Please verify move functionality after api landed.
|
|
cards.folderSelector(this.model, function(folder) {
|
|
var op = this.header.moveMessage(folder);
|
|
cards.removeCardAndSuccessors(this, 'animate');
|
|
toaster.toastOperation(op);
|
|
}.bind(this), function(folder) {
|
|
return folder.isValidMoveTarget;
|
|
});
|
|
},
|
|
|
|
setRead: function(isRead) {
|
|
this.hackMutationHeader.isRead = isRead;
|
|
this.header.setRead(isRead);
|
|
|
|
// Want the button state to reflect the current read state.
|
|
this.readBtn.classList.toggle('unread', !isRead);
|
|
mozL10n.setAttributes(this.readBtn,
|
|
isRead ? 'message-mark-read-button' : 'message-mark-unread-button');
|
|
},
|
|
|
|
onMarkRead: function() {
|
|
this.setRead(!this.hackMutationHeader.isRead);
|
|
},
|
|
|
|
/**
|
|
* Handle peep bubble click event and trigger context menu.
|
|
*/
|
|
onEnvelopeClick: function(event) {
|
|
var target = event.target;
|
|
if (!target.classList.contains('msg-peep-bubble')) {
|
|
return;
|
|
}
|
|
// - peep click
|
|
this.onPeepClick(target);
|
|
},
|
|
|
|
onPeepClick: function(target) {
|
|
var contents = msgContactMenuNode.cloneNode(true);
|
|
var peep = target.peep;
|
|
var headerNode = contents.getElementsByTagName('header')[0];
|
|
// Setup the marquee structure
|
|
Marquee.setup(peep.address, headerNode);
|
|
|
|
// Activate marquee once the contents DOM are added to document
|
|
document.body.appendChild(contents);
|
|
// XXX Remove 'ease' if linear animation is wanted
|
|
Marquee.activate('alternate', 'ease');
|
|
|
|
// -- context menu selection handling
|
|
var formSubmit = (function(evt) {
|
|
document.body.removeChild(contents);
|
|
switch (evt.explicitOriginalTarget.className) {
|
|
// All of these mutations are immediately reflected, easily observed
|
|
// and easily undone, so we don't show them as toaster actions.
|
|
case 'msg-contact-menu-new':
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composerData: {
|
|
message: this.header,
|
|
onComposer: function(composer) {
|
|
composer.to = [{
|
|
address: peep.address,
|
|
name: peep.name
|
|
}];
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
case 'msg-contact-menu-view':
|
|
sendActivity({
|
|
name: 'open',
|
|
data: {
|
|
type: 'webcontacts/contact',
|
|
params: {
|
|
'id': peep.contactId
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
case 'msg-contact-menu-create-contact':
|
|
var params = {
|
|
'email': peep.address
|
|
};
|
|
|
|
if (peep.name) {
|
|
params.givenName = peep.name;
|
|
}
|
|
|
|
sendActivity({
|
|
name: 'new',
|
|
data: {
|
|
type: 'webcontacts/contact',
|
|
params: params
|
|
}
|
|
});
|
|
|
|
// since we already have contact change listeners that are hooked up
|
|
// to the UI, we leave it up to them to update the UI for us.
|
|
break;
|
|
case 'msg-contact-menu-add-to-existing-contact':
|
|
sendActivity({
|
|
name: 'update',
|
|
data: {
|
|
type: 'webcontacts/contact',
|
|
params: {
|
|
'email': peep.address
|
|
}
|
|
}
|
|
});
|
|
|
|
// since we already have contact change listeners that are hooked up
|
|
// to the UI, we leave it up to them to update the UI for us.
|
|
break;
|
|
case 'msg-contact-menu-reply':
|
|
//TODO: We need to enter compose view with specific email address.
|
|
var composer = this.header.replyToMessage(null, () => {
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composer: composer
|
|
});
|
|
});
|
|
break;
|
|
}
|
|
return false;
|
|
}).bind(this);
|
|
contents.addEventListener('submit', formSubmit);
|
|
|
|
// -- populate context menu
|
|
var contextMenuOptions = this._contextMenuType.NEW_MESSAGE;
|
|
var messageType = peep.type;
|
|
|
|
if (messageType === 'from') {
|
|
contextMenuOptions |= this._contextMenuType.REPLY;
|
|
}
|
|
|
|
if (peep.isContact) {
|
|
contextMenuOptions |= this._contextMenuType.VIEW_CONTACT;
|
|
} else {
|
|
contextMenuOptions |= this._contextMenuType.CREATE_CONTACT;
|
|
contextMenuOptions |= this._contextMenuType.ADD_TO_CONTACT;
|
|
}
|
|
|
|
if (contextMenuOptions & this._contextMenuType.VIEW_CONTACT) {
|
|
contents.querySelector('.msg-contact-menu-view')
|
|
.classList.remove('collapsed');
|
|
}
|
|
if (contextMenuOptions & this._contextMenuType.CREATE_CONTACT) {
|
|
contents.querySelector('.msg-contact-menu-create-contact')
|
|
.classList.remove('collapsed');
|
|
}
|
|
if (contextMenuOptions & this._contextMenuType.ADD_TO_CONTACT) {
|
|
contents.querySelector('.msg-contact-menu-add-to-existing-contact')
|
|
.classList.remove('collapsed');
|
|
}
|
|
if (contextMenuOptions & this._contextMenuType.REPLY) {
|
|
contents.querySelector('.msg-contact-menu-reply')
|
|
.classList.remove('collapsed');
|
|
}
|
|
if (contextMenuOptions & this._contextMenuType.NEW_MESSAGE) {
|
|
contents.querySelector('.msg-contact-menu-new')
|
|
.classList.remove('collapsed');
|
|
}
|
|
},
|
|
|
|
onLoadBarClick: function(event) {
|
|
var self = this;
|
|
var loadBar = this.loadBar;
|
|
if (!this.body.embeddedImagesDownloaded) {
|
|
this.body.downloadEmbeddedImages(function() {
|
|
// this gets nulled out when we get killed, so use this to bail.
|
|
// XXX of course, this closure will cause us to potentially hold onto
|
|
// a lot of garbage, so it would be better to add an
|
|
// 'onimagesdownloaded' to body so that the closure would end up as
|
|
// part of a cycle that would get collected.
|
|
if (!self.body) {
|
|
return;
|
|
}
|
|
|
|
for (var i = 0; i < self.htmlBodyNodes.length; i++) {
|
|
self.body.showEmbeddedImages(self.htmlBodyNodes[i],
|
|
self.iframeResizeHandler);
|
|
}
|
|
});
|
|
// XXX really we should check for external images to display that load
|
|
// bar, although it's a bit silly to have both in a single e-mail.
|
|
loadBar.classList.add('collapsed');
|
|
}
|
|
else {
|
|
for (var i = 0; i < this.htmlBodyNodes.length; i++) {
|
|
this.body.showExternalImages(this.htmlBodyNodes[i],
|
|
this.iframeResizeHandler);
|
|
}
|
|
loadBar.classList.add('collapsed');
|
|
}
|
|
},
|
|
|
|
getAttachmentBlob: function(attachment, callback) {
|
|
try {
|
|
// Get the file contents as a blob, so we can open the blob
|
|
var storageType = attachment._file[0];
|
|
var filename = attachment._file[1];
|
|
var storage = navigator.getDeviceStorage(storageType);
|
|
var getreq = storage.get(filename);
|
|
|
|
getreq.onerror = function() {
|
|
console.warn('Could not open attachment file: ', filename,
|
|
getreq.error.name);
|
|
};
|
|
|
|
getreq.onsuccess = function() {
|
|
// Now that we have the file, return the blob within callback function
|
|
var blob = getreq.result;
|
|
callback(blob);
|
|
};
|
|
} catch (ex) {
|
|
console.warn('Exception getting attachment from device storage:',
|
|
attachment._file, '\n', ex, '\n', ex.stack);
|
|
}
|
|
},
|
|
|
|
onDownloadAttachmentClick: function(node, attachment) {
|
|
node.setAttribute('state', 'downloading');
|
|
// We register all downloads with the download manager. Previously we had
|
|
// thought about only registering non-media types because the media types
|
|
// were already covered by built-in apps. But we didn't have a good
|
|
// reason for that; perhaps risk avoidance? So everybody gets logged!
|
|
// Note that POP3 also does this, but that happens in pop3/sync.js in
|
|
// the back-end and the front-end has no control over that.
|
|
var registerWithDownloadManager = true;
|
|
attachment.download(function downloaded() {
|
|
if (!attachment._file) {
|
|
return;
|
|
}
|
|
|
|
node.setAttribute('state', 'downloaded');
|
|
}, null, registerWithDownloadManager);
|
|
},
|
|
|
|
onViewAttachmentClick: function(node, attachment) {
|
|
console.log('trying to open', attachment._file, 'known type:',
|
|
attachment.mimetype);
|
|
if (!attachment._file) {
|
|
return;
|
|
}
|
|
|
|
if (attachment.isDownloaded) {
|
|
this.getAttachmentBlob(attachment, function(blob) {
|
|
try {
|
|
// Now that we have the file, use an activity to open it
|
|
if (!blob) {
|
|
throw new Error('Blob does not exist');
|
|
}
|
|
|
|
// - Figure out the best MIME type
|
|
//
|
|
// We have three MIME type databases at our disposal:
|
|
//
|
|
// - mimetypes(.js): A reasonably comprehensive database but that's
|
|
// not continually updated and potentially is out-of-date about
|
|
// some audio and video types.
|
|
//
|
|
// - nsIMIMEService via DeviceStorage. Anything that was stored
|
|
// in DeviceStorage will have its MIME type looked-up using
|
|
// nsIMIMEService which in turn checks a short hard-coded list
|
|
// (mainly containing the core web video/audio/doc types),
|
|
// preferences, the OS, extensions, plugins, category manager
|
|
// stuff, and then a slightly longer list of hard-coded entries.
|
|
//
|
|
// - shared/js/mime_mapper.js: It knows about all of the media types
|
|
// supported by Gecko and our media apps. It at least has
|
|
// historically been updated pretty promptly.
|
|
//
|
|
//
|
|
// For IMAP and POP3, we get the MIME type from the composer of the
|
|
// message. They explicitly include a MIME type. Assuming the
|
|
// author of the message also created the attachment, there's a very
|
|
// high probability they included a valid MIME type (based on a
|
|
// local file extension mapping). If the author forwarded a message
|
|
// with the attachment maintained, there's also a good chance the
|
|
// MIME type was maintained. If the author downloaded the
|
|
// attachment but lacked a mapping for the file extension, the
|
|
// reported MIME type is probably application/octet-stream. As
|
|
// of writing this, our IMAP and POP3 implementations do not
|
|
// second-guess the reported MIME type if it is
|
|
// application/octet-stream.
|
|
//
|
|
// In the ActiveSync case, we are not given any MIME type
|
|
// information and our ActiveSync library uses mimetypes.js to
|
|
// map the extension type. This may result in
|
|
// application/octet-stream being returned.
|
|
//
|
|
// Given all of this, our process for determining MIME types is to:
|
|
// - Trust the MIME type we have on file for the message if it's
|
|
// anything other than application/octet-stream.
|
|
var useType = attachment.mimetype;
|
|
// - If it was octet-stream (or somehow missing), we check if
|
|
// DeviceStorage has an opinion. We use it if so.
|
|
if (!useType || useType === OCTET_STREAM_TYPE) {
|
|
useType = blob.type;
|
|
}
|
|
// - If we still think it's octet-stream (or falsey), we ask the
|
|
// MimeMapper to map the file extension to a MIME type.
|
|
if (!useType || useType === OCTET_STREAM_TYPE) {
|
|
useType = MimeMapper.guessTypeFromFileProperties(
|
|
attachment.filename, OCTET_STREAM_TYPE);
|
|
}
|
|
// - If it's falsey (MimeMapper returns an emptry string if it
|
|
// can't map), we set the value to application/octet-stream.
|
|
if (!useType) {
|
|
useType = OCTET_STREAM_TYPE;
|
|
}
|
|
// - At this point, we're fine with application/octet-stream.
|
|
// Although there are some file-types where we can just chuck
|
|
// "application/" on the front, there aren't enough of them.
|
|
// Apps can, however, use a regexp filter on the filename we
|
|
// provide to capture extension types that way.
|
|
console.log('triggering open activity with MIME type:', useType);
|
|
|
|
var activity = new MozActivity({
|
|
name: 'open',
|
|
data: {
|
|
type: useType,
|
|
filename: attachment.filename,
|
|
blob: blob,
|
|
// the PDF viewer really wants a "url". download_helper.js
|
|
// provides the local filesystem path which is sketchy and
|
|
// non-sensical. We just provide the filename again.
|
|
url: attachment.filename
|
|
}
|
|
});
|
|
activity.onerror = function() {
|
|
console.warn('Problem with "open" activity', activity.error.name);
|
|
// NO_PROVIDER is returned if there's nothing to service the
|
|
// activity.
|
|
if (activity.error.name === 'NO_PROVIDER') {
|
|
var dialog = msgAttachmentDidNotOpenAlertNode.cloneNode(true);
|
|
ConfirmDialog.show(dialog,
|
|
{
|
|
id: 'msg-attachment-did-not-open-ok',
|
|
handler: null
|
|
},
|
|
{
|
|
handler: null
|
|
}
|
|
);
|
|
}
|
|
};
|
|
activity.onsuccess = function() {
|
|
console.log('"open" activity allegedly succeeded');
|
|
};
|
|
}
|
|
catch (ex) {
|
|
console.warn('Problem creating "open" activity:', ex, '\n',
|
|
ex.stack);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
onHyperlinkClick: function(event, linkNode, linkUrl, linkText) {
|
|
if (/^mailto:/i.test(linkUrl)) {
|
|
// Fast path to compose. Works better than an activity, since
|
|
// "canceling" the activity has freaky consequences: what does it
|
|
// mean to cancel ourselves? What is the sound of one hand
|
|
// clapping?
|
|
var data = queryURI(linkUrl);
|
|
cards.pushCard('compose', 'animate', {
|
|
model: this.model,
|
|
composerData: {
|
|
onComposer: (composer, composeCard) => {
|
|
// Copy the to, cc, bcc, subject, body to the compose.
|
|
// It is OK to do this blind key copy since queryURI
|
|
// explicitly only populates expected fields, does not
|
|
// blindly accept input from the outside, and the queryURI
|
|
// properties match the property names allowed on composer.
|
|
Object.keys(data).forEach((key) => {
|
|
if (key === 'to' || key === 'cc' || key === 'bcc') {
|
|
composer[key] = data[key].map((addr) => {
|
|
return this.model.api.parseMailbox(addr).address;
|
|
}).filter((value) => {
|
|
// Filter out nulls if address parsing failed.
|
|
return value;
|
|
});
|
|
} else {
|
|
composer[key] = data[key];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
var dialog = msgBrowseConfirmNode.cloneNode(true);
|
|
var content = dialog.getElementsByTagName('p')[0];
|
|
mozL10n.setAttributes(content, 'browse-to-url-prompt',
|
|
{ url: linkUrl });
|
|
ConfirmDialog.show(dialog,
|
|
{ // Confirm
|
|
id: 'msg-browse-ok',
|
|
handler: function() {
|
|
// Pop out to what is likely the browser, or the user's preferred
|
|
// viewer for the URL. This keeps the URL out of our cookie
|
|
// jar/data space too.
|
|
sendActivity({
|
|
name: 'view',
|
|
data: {
|
|
type: 'url',
|
|
url: linkUrl
|
|
}
|
|
});
|
|
}.bind(this)
|
|
},
|
|
{ // Cancel
|
|
id: 'msg-browse-cancel',
|
|
handler: null
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
_populatePlaintextBodyNode: function(bodyNode, rep) {
|
|
for (var i = 0; i < rep.length; i += 2) {
|
|
var node = document.createElement('div'), cname;
|
|
|
|
var etype = rep[i] & 0xf;
|
|
if (etype === 0x4) {
|
|
var qdepth = (((rep[i] >> 8) & 0xff) + 1);
|
|
if (qdepth > 8) {
|
|
cname = MAX_QUOTE_CLASS_NAME;
|
|
} else {
|
|
cname = CONTENT_QUOTE_CLASS_NAMES[qdepth];
|
|
}
|
|
}
|
|
else {
|
|
cname = CONTENT_TYPES_TO_CLASS_NAMES[etype];
|
|
}
|
|
if (cname) {
|
|
node.setAttribute('class', cname);
|
|
}
|
|
|
|
var subnodes = this.model.api.utils.linkifyPlain(rep[i + 1], document);
|
|
for (var iNode = 0; iNode < subnodes.length; iNode++) {
|
|
node.appendChild(subnodes[iNode]);
|
|
}
|
|
|
|
bodyNode.appendChild(node);
|
|
}
|
|
},
|
|
|
|
buildHeaderDom: function(domNode) {
|
|
var header = this.header;
|
|
|
|
// -- Header
|
|
function updatePeep(peep) {
|
|
var nameNode = peep.element.querySelector('.msg-peep-content');
|
|
|
|
if (peep.type === 'from') {
|
|
// We display the sender of the message's name in the header and the
|
|
// address in the bubble.
|
|
domNode.querySelector('.msg-reader-header-label')
|
|
.textContent = peep.name || peep.address;
|
|
|
|
nameNode.textContent = peep.address;
|
|
nameNode.classList.add('msg-peep-address');
|
|
}
|
|
else {
|
|
nameNode.textContent = peep.name || peep.address;
|
|
if (!peep.name && peep.address) {
|
|
nameNode.classList.add('msg-peep-address');
|
|
} else {
|
|
nameNode.classList.remove('msg-peep-address');
|
|
}
|
|
}
|
|
}
|
|
|
|
function addHeaderEmails(type, peeps) {
|
|
var lineClass = 'msg-envelope-' + type + '-line';
|
|
var lineNode = domNode.getElementsByClassName(lineClass)[0];
|
|
|
|
if (!peeps || !peeps.length) {
|
|
lineNode.classList.add('collapsed');
|
|
return;
|
|
}
|
|
|
|
// Make sure it is not hidden from a next/prev action.
|
|
lineNode.classList.remove('collapsed');
|
|
|
|
// Because we can avoid having to do multiple selector lookups, we just
|
|
// mutate the template in-place...
|
|
var peepTemplate = msgPeepBubbleNode;
|
|
|
|
for (var i = 0; i < peeps.length; i++) {
|
|
var peep = peeps[i];
|
|
peep.type = type;
|
|
peep.element = peepTemplate.cloneNode(true);
|
|
peep.element.peep = peep;
|
|
peep.onchange = updatePeep;
|
|
updatePeep(peep);
|
|
lineNode.appendChild(peep.element);
|
|
}
|
|
}
|
|
|
|
addHeaderEmails('from', [header.author]);
|
|
addHeaderEmails('to', header.to);
|
|
addHeaderEmails('cc', header.cc);
|
|
addHeaderEmails('bcc', header.bcc);
|
|
|
|
var dateNode = domNode.querySelector('.msg-envelope-date');
|
|
var dateTime = dateNode.dataset.time = header.date.valueOf();
|
|
date.relativeDateElement(dateNode, dateTime);
|
|
|
|
messageDisplay.subject(domNode.querySelector('.msg-envelope-subject'),
|
|
header);
|
|
},
|
|
|
|
clearDom: function() {
|
|
// Clear header emails.
|
|
Array.slice(this.querySelectorAll('.msg-peep-bubble')).forEach(
|
|
function(node) {
|
|
node.parentNode.removeChild(node);
|
|
}
|
|
);
|
|
|
|
// Nuke rendered attachments.
|
|
var attachmentsContainer =
|
|
this.querySelector('.msg-attachments-container');
|
|
attachmentsContainer.innerHTML = '';
|
|
|
|
// Nuke existing body, show progress while waiting
|
|
// for message to load.
|
|
this.rootBodyNode.innerHTML =
|
|
'<progress data-l10n-id="message-body-container-progress"></progress>';
|
|
|
|
// Make sure load bar is not shown between loads too.
|
|
this.loadBar.classList.add('collapsed');
|
|
},
|
|
|
|
/**
|
|
* Render the DOM nodes for bodyReps and the attachments container.
|
|
* If we have information on which parts of the message changed,
|
|
* only update those DOM nodes; otherwise, update the whole thing.
|
|
*
|
|
* @param {object} changeDetails
|
|
* @param {array} changeDetails.bodyReps An array of changed item indexes.
|
|
* @param {array} changeDetails.attachments An array of changed item
|
|
* indexes.
|
|
*/
|
|
buildBodyDom: function(/* optional */ changeDetails) {
|
|
var body = this.body;
|
|
|
|
// If the card has been destroyed (so no more body, as it is nulled in
|
|
// die()) or header has changed since this method was scheduled to be
|
|
// called (rapid taps of the next/previous buttons), ingore the call.
|
|
if (!body || this.header.id !== body.id) {
|
|
return;
|
|
}
|
|
|
|
var domNode = this,
|
|
rootBodyNode = this.rootBodyNode,
|
|
reps = body.bodyReps,
|
|
hasExternalImages = false,
|
|
showEmbeddedImages = body.embeddedImageCount &&
|
|
body.embeddedImagesDownloaded;
|
|
|
|
|
|
// The first time we build the body DOM, do one-time bootstrapping:
|
|
if (!this._builtBodyDom) {
|
|
iframeShims.bindSanitizedClickHandler(rootBodyNode,
|
|
this.onHyperlinkClick.bind(this),
|
|
rootBodyNode,
|
|
null);
|
|
this._builtBodyDom = true;
|
|
}
|
|
|
|
// If we have fully downloaded one body part, the user has
|
|
// something to read so get rid of the spinner.
|
|
// XXX: Potentially improve the UI to show if we're still
|
|
// downloading the rest of the body even if we already have some
|
|
// of it.
|
|
if (reps.length && reps[0].isDownloaded) {
|
|
// remove progress bar if we've retrieved the first rep
|
|
var progressNode = rootBodyNode.querySelector('progress');
|
|
if (progressNode) {
|
|
progressNode.parentNode.removeChild(progressNode);
|
|
}
|
|
}
|
|
|
|
// The logic below depends on having removed the progress node!
|
|
|
|
for (var iRep = 0; iRep < reps.length; iRep++) {
|
|
var rep = reps[iRep];
|
|
|
|
// Create an element to hold this body rep. Even if we aren't
|
|
// updating this rep right now, we need to have a placeholder.
|
|
var repNode = rootBodyNode.childNodes[iRep];
|
|
if (!repNode) {
|
|
repNode = rootBodyNode.appendChild(document.createElement('div'));
|
|
}
|
|
|
|
// Skip updating this rep if it's not updated.
|
|
if (changeDetails && changeDetails.bodyReps &&
|
|
changeDetails.bodyReps.indexOf(iRep) === -1) {
|
|
continue;
|
|
}
|
|
|
|
// Wipe out the existing contents of the rep node so we can
|
|
// replace it. We can just nuke innerHTML since we add click
|
|
// handlers on the rootBodyNode, and for text/html parts the
|
|
// listener is a child of repNode so it will get destroyed too.
|
|
repNode.innerHTML = '';
|
|
|
|
if (rep.type === 'plain') {
|
|
this._populatePlaintextBodyNode(repNode, rep.content);
|
|
}
|
|
else if (rep.type === 'html') {
|
|
var iframeShim = iframeShims.createAndInsertIframeForContent(
|
|
rep.content, this.scrollContainer, repNode, null,
|
|
'interactive', this.onHyperlinkClick.bind(this));
|
|
var iframe = iframeShim.iframe;
|
|
var bodyNode = iframe.contentDocument.body;
|
|
this.iframeResizeHandler = iframeShim.resizeHandler;
|
|
this.model.api.utils.linkifyHTML(iframe.contentDocument);
|
|
this.htmlBodyNodes.push(bodyNode);
|
|
|
|
if (body.checkForExternalImages(bodyNode)) {
|
|
hasExternalImages = true;
|
|
}
|
|
if (showEmbeddedImages) {
|
|
body.showEmbeddedImages(bodyNode, this.iframeResizeHandler);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The image logic checks embedded image counts, so this should be
|
|
// able to run every time:
|
|
// -- HTML-referenced Images
|
|
var loadBar = this.loadBar;
|
|
if (body.embeddedImageCount && !body.embeddedImagesDownloaded) {
|
|
loadBar.classList.remove('collapsed');
|
|
mozL10n.setAttributes(this.loadBarText, 'message-download-images-tap',
|
|
{ n: body.embeddedImageCount });
|
|
}
|
|
else if (hasExternalImages) {
|
|
loadBar.classList.remove('collapsed');
|
|
mozL10n.setAttributes(this.loadBarText, 'message-show-external-images');
|
|
}
|
|
else {
|
|
loadBar.classList.add('collapsed');
|
|
}
|
|
|
|
// -- Attachments (footer)
|
|
// An attachment can be in 1 of 3 possible states for UI purposes:
|
|
// - Not downloadable: We can't download this message because we wouldn't
|
|
// be able to do anything with it if we downloaded it. Anything that's
|
|
// not a supported image type falls in this category.
|
|
// - Downloadable, not downloaded: The user can trigger download of the
|
|
// attachment to DeviceStorage.
|
|
// - Downloadable, downloaded: The attachment is already fully downloaded
|
|
// to DeviceStorage and we can trigger its display.
|
|
var attachmentsContainer =
|
|
domNode.querySelector('.msg-attachments-container');
|
|
if (body.attachments && body.attachments.length) {
|
|
// If buildBodyDom is called multiple times, the attachment
|
|
// state might change, so we must ensure the attachment list is
|
|
// not collapsed if we now have attachments.
|
|
attachmentsContainer.classList.remove('collapsed');
|
|
// We need MimeMapper to help us determining the downloadable
|
|
// attachments but it might not be loaded yet, so load before use it.
|
|
require(['shared/js/mime_mapper'], function(mapper) {
|
|
if (!MimeMapper) {
|
|
MimeMapper = mapper;
|
|
}
|
|
|
|
var attTemplate = msgAttachmentItemNode,
|
|
filenameTemplate =
|
|
attTemplate.querySelector('.msg-attachment-filename'),
|
|
filesizeTemplate =
|
|
attTemplate.querySelector('.msg-attachment-filesize');
|
|
for (var iAttach = 0; iAttach < body.attachments.length; iAttach++) {
|
|
|
|
// Create an element to hold this attachment.
|
|
var attNode = attachmentsContainer.childNodes[iAttach];
|
|
if (!attNode) {
|
|
attNode = attachmentsContainer.appendChild(
|
|
document.createElement('li'));
|
|
}
|
|
|
|
// Skip updating this attachment if it's not updated.
|
|
if (changeDetails && changeDetails.attachments &&
|
|
changeDetails.attachments.indexOf(iAttach) === -1) {
|
|
continue;
|
|
}
|
|
|
|
var attachment = body.attachments[iAttach], state;
|
|
var extension = attachment.filename.split('.').pop();
|
|
|
|
// Keeping in-sync with the compose.js value of 22 now, plus some
|
|
// slop to deal with encoding and just be fine with the general
|
|
// provider upper bound of 25.
|
|
var MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024;
|
|
var attachmentDownloadable = true;
|
|
var mimeClass = mimeToClass(attachment.mimetype ||
|
|
MimeMapper.guessTypeFromExtension(extension));
|
|
|
|
if (attachment.isDownloaded) {
|
|
state = 'downloaded';
|
|
} else if (!attachment.isDownloadable) {
|
|
state = 'nodownload';
|
|
attachmentDownloadable = false;
|
|
} else if (attachment.sizeEstimateInBytes > MAX_ATTACHMENT_SIZE) {
|
|
state = 'toolarge';
|
|
attachmentDownloadable = false;
|
|
} else {
|
|
state = 'downloadable';
|
|
}
|
|
attTemplate.setAttribute('state', state);
|
|
filenameTemplate.textContent = attachment.filename;
|
|
fileDisplay.fileSize(filesizeTemplate,
|
|
attachment.sizeEstimateInBytes);
|
|
|
|
var attachmentNode = attTemplate.cloneNode(true);
|
|
attachmentNode.classList.add(mimeClass);
|
|
attachmentsContainer.replaceChild(attachmentNode, attNode);
|
|
|
|
var downloadButton = attachmentNode.querySelector(
|
|
'.msg-attachment-download');
|
|
downloadButton.disabled = !attachmentDownloadable;
|
|
if (attachmentDownloadable) {
|
|
downloadButton.addEventListener(
|
|
'click', this.onDownloadAttachmentClick.bind(
|
|
this, attachmentNode, attachment));
|
|
}
|
|
attachmentNode.setAttribute('aria-disabled',
|
|
!attachmentDownloadable);
|
|
attachmentNode.querySelector('.msg-attachment-view')
|
|
.addEventListener('click',
|
|
this.onViewAttachmentClick.bind(
|
|
this, attachmentNode, attachment));
|
|
this.enableReply();
|
|
}
|
|
}.bind(this));
|
|
}
|
|
else {
|
|
attachmentsContainer.classList.add('collapsed');
|
|
this.enableReply();
|
|
}
|
|
},
|
|
|
|
disableReply: function() {
|
|
var btn = this.querySelector('.msg-reply-btn');
|
|
btn.setAttribute('aria-disabled', true);
|
|
},
|
|
|
|
enableReply: function() {
|
|
var btn = this.querySelector('.msg-reply-btn');
|
|
btn.removeAttribute('aria-disabled');
|
|
|
|
// Inform that content is ready. Done here because reply is only enabled
|
|
// once the full body is available.
|
|
if (!this._emittedContentEvents) {
|
|
evt.emit('metrics:contentDone');
|
|
this._emittedContentEvents = true;
|
|
}
|
|
},
|
|
|
|
die: function() {
|
|
var headerCursor = this.headerCursor;
|
|
headerCursor.removeListener('messageSuidNotFound',
|
|
this.onMessageSuidNotFound);
|
|
headerCursor.removeListener('currentMessage', this.onCurrentMessage);
|
|
|
|
// Our header was makeCopy()d from the message-list and so needs to be
|
|
// explicitly removed since it is not part of a slice.
|
|
if (this.header) {
|
|
this.header.__die();
|
|
this.header = null;
|
|
}
|
|
if (this.body) {
|
|
this.body.die();
|
|
this.body = null;
|
|
}
|
|
}
|
|
}
|
|
];
|
|
});
|