/*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 = ''; // 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; } } } ]; });