VulpesOS/gaia/apps/email/js/cards/message_reader.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

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;
}
}
}
];
});