/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ /* import-globals-from MsgComposeCommands.js */ const { MsgUtils } = ChromeUtils.importESModule( "resource:///modules/MimeMessageUtils.sys.mjs" ); var gCloudAttachmentLinkManager = { init() { this.cloudAttachments = []; const bucket = document.getElementById("attachmentBucket"); bucket.addEventListener("attachments-removed", this); bucket.addEventListener("attachment-converted-to-regular", this); bucket.addEventListener("attachment-uploaded", this); bucket.addEventListener("attachment-moved", this); bucket.addEventListener("attachment-renamed", this); // If we're restoring a draft that has some attachments, // check to see if any of them are marked to be sent via // cloud, and if so, add them to our list. for (let i = 0; i < bucket.getRowCount(); ++i) { const attachment = bucket.getItemAtIndex(i).attachment; if (attachment && attachment.sendViaCloud) { this.cloudAttachments.push(attachment); } } gMsgCompose.RegisterStateListener(this); }, NotifyComposeFieldsReady() {}, NotifyComposeBodyReady() {}, ComposeProcessDone() {}, SaveInFolderDone() {}, async handleEvent(event) { const mailDoc = document.getElementById("messageEditor").contentDocument; if ( event.type == "attachment-renamed" || event.type == "attachment-moved" ) { const cloudFileUpload = event.target.cloudFileUpload; let items = []; const list = mailDoc.getElementById("cloudAttachmentList"); if (list) { items = list.getElementsByClassName("cloudAttachmentItem"); } for (const item of items) { // The original attachment is stored in the events detail property. if (item.dataset.contentLocation == event.detail.contentLocation) { item.replaceWith(await this._createNode(mailDoc, cloudFileUpload)); } } if (event.type == "attachment-moved") { await this._updateServiceProviderLinks(mailDoc); } } else if (event.type == "attachment-uploaded") { if (this.cloudAttachments.length == 0) { this._insertHeader(mailDoc); } const cloudFileUpload = event.target.cloudFileUpload; const attachment = event.target.attachment; this.cloudAttachments.push(attachment); await this._insertItem(mailDoc, cloudFileUpload); } else if ( event.type == "attachments-removed" || event.type == "attachment-converted-to-regular" ) { let items = []; const list = mailDoc.getElementById("cloudAttachmentList"); if (list) { items = list.getElementsByClassName("cloudAttachmentItem"); } const attachments = Array.isArray(event.detail) ? event.detail : [event.detail]; for (const attachment of attachments) { // Remove the attachment from the message body. if (list) { for (const item of items) { if (item.dataset.contentLocation == attachment.contentLocation) { item.remove(); } } } // Now, remove the attachment from our internal list. const index = this.cloudAttachments.indexOf(attachment); if (index != -1) { this.cloudAttachments.splice(index, 1); } } await this._updateAttachmentCount(mailDoc); await this._updateServiceProviderLinks(mailDoc); if (items.length == 0) { if (list) { list.remove(); } this._removeRoot(mailDoc); } } }, /** * Removes the root node for an attachment list in an HTML email. * * @param {Document} aDocument - the document to remove the root node from */ _removeRoot(aDocument) { const header = aDocument.getElementById("cloudAttachmentListRoot"); if (header) { header.remove(); } }, /** * Given some node, returns the textual HTML representation for the node * and its children. * * @param {Document} aDocument - the document that the node is embedded in * @param {DOMNode} aNode - the node to get the textual representation from */ _getHTMLRepresentation(aDocument, aNode) { const tmp = aDocument.createElement("p"); tmp.appendChild(aNode); return tmp.innerHTML; }, /** * Returns the plain text equivalent of the given HTML markup, ready to be * inserted into a compose editor. * * @param {string} aMarkup - the HTML markup that should be converted */ _getTextRepresentation(aMarkup) { return MsgUtils.convertToPlainText(aMarkup, true).replaceAll("\r\n", "\n"); }, /** * Generates an appropriately styled link. * * @param {Document} aDocument - the document to append the link to - doesn't * actually get appended, but is used to generate the anchor node * @param {string} aContent - the textual content of the link * @param {string} aHref - the HREF attribute for the generated link * @param {string} aColor - the CSS color string for the link */ _generateLink(aDocument, aContent, aHref, aColor) { const link = aDocument.createElement("a"); link.href = aHref; link.textContent = aContent; link.style.cssText = `color: ${aColor} !important`; return link; }, _findInsertionPoint(aDocument) { const mailBody = aDocument.querySelector("body"); const editor = GetCurrentEditor(); const selection = editor.selection; const childNodes = mailBody.childNodes; let childToInsertAfter, childIndex; // First, search for any text nodes that are immediate children of // the body. If we find any, we'll insert after those. for (childIndex = childNodes.length - 1; childIndex >= 0; childIndex--) { if (childNodes[childIndex].nodeType == Node.TEXT_NODE) { childToInsertAfter = childNodes[childIndex]; break; } } if (childIndex != -1) { selection.collapse( childToInsertAfter, childToInsertAfter.nodeValue ? childToInsertAfter.nodeValue.length : 0 ); if ( childToInsertAfter.nodeValue && childToInsertAfter.nodeValue.length > 0 ) { editor.insertLineBreak(); } editor.insertLineBreak(); return; } // If there's a signature, let's get a hold of it now. const signature = mailBody.querySelector(".moz-signature"); // Are we replying? const replyCitation = mailBody.querySelector(".moz-cite-prefix"); if (replyCitation) { if (gCurrentIdentity && gCurrentIdentity.replyOnTop == 0) { // Replying below quote - we'll select the point right before // the signature. If there's no signature, we'll just use the // last node. if (signature && signature.previousSibling) { selection.collapse( mailBody, Array.from(childNodes).indexOf(signature.previousSibling) ); } else { selection.collapse(mailBody, childNodes.length - 1); editor.insertLineBreak(); if (!gMsgCompose.composeHTML) { editor.insertLineBreak(); } selection.collapse(mailBody, childNodes.length - 2); } } else if (replyCitation.previousSibling) { // Replying above quote let nodeIndex = Array.from(childNodes).indexOf( replyCitation.previousSibling ); if (nodeIndex <= 0) { editor.insertLineBreak(); nodeIndex = 1; } selection.collapse(mailBody, nodeIndex); } else { editor.beginningOfDocument(); editor.insertLineBreak(); } return; } // Are we forwarding? const forwardBody = mailBody.querySelector(".moz-forward-container"); if (forwardBody) { if (forwardBody.previousSibling) { let nodeIndex = Array.from(childNodes).indexOf( forwardBody.previousSibling ); if (nodeIndex <= 0) { editor.insertLineBreak(); nodeIndex = 1; } // If we're forwarding, insert just before the forward body. selection.collapse(mailBody, nodeIndex); } else { // Just insert after a linebreak at the top. editor.beginningOfDocument(); editor.insertLineBreak(); selection.collapse(mailBody, 1); } return; } // If we haven't figured it out at this point, let's see if there's a // signature, and just insert before it. if (signature && signature.previousSibling) { let nodeIndex = Array.from(childNodes).indexOf(signature.previousSibling); if (nodeIndex <= 0) { editor.insertLineBreak(); nodeIndex = 1; } selection.collapse(mailBody, nodeIndex); return; } // If we haven't figured it out at this point, let's just put it // at the bottom of the message body. If the "bottom" is also the top, // then we'll insert a linebreak just above it. let nodeIndex = childNodes.length - 1; if (nodeIndex <= 0) { editor.insertLineBreak(); nodeIndex = 1; } selection.collapse(mailBody, nodeIndex); }, /** * Attempts to find any elements with an id in aIDs, and sets those elements * id attribute to the empty string, freeing up the ids for later use. * * @param {Document} aDocument - the document to search for the elements * @param {string[]} aIDs - an array of id strings */ _resetNodeIDs(aDocument, aIDs) { for (const id of aIDs) { const node = aDocument.getElementById(id); if (node) { node.id = ""; } } }, /** * Insert the header for the cloud attachment list, which we'll use to * as an insertion point for the individual cloud attachments. * * @param {Document} aDocument - the document to insert the header into */ _insertHeader(aDocument) { // If there already exists a cloudAttachmentListRoot, // cloudAttachmentListHeader, cloudAttachmentListFooter or // cloudAttachmentList in the document, strip them of their IDs so that we // don't conflict with them. this._resetNodeIDs(aDocument, [ "cloudAttachmentListRoot", "cloudAttachmentListHeader", "cloudAttachmentList", "cloudAttachmentListFooter", ]); const editor = GetCurrentEditor(); const selection = editor.selection; const originalAnchor = selection.anchorNode; const originalOffset = selection.anchorOffset; // Save off the selection ranges so we can restore them later. const ranges = []; for (let i = 0; i < selection.rangeCount; i++) { ranges.push(selection.getRangeAt(i)); } this._findInsertionPoint(aDocument); const root = editor.createElementWithDefaults("div"); const header = editor.createElementWithDefaults("div"); let list = editor.createElementWithDefaults("div"); const footer = editor.createElementWithDefaults("div"); if (gMsgCompose.composeHTML) { root.style.fontFamily = "Calibri, sans-serif"; root.style.color = "#18181b"; root.style.padding = "9px"; root.style.backgroundColor = "#ddeefe"; header.style.marginBottom = "9px"; list = editor.createElementWithDefaults("ul"); list.style.backgroundColor = "#ffffff"; list.style.padding = "9px"; list.style.listStyleType = "none"; list.display = "inline-block"; } root.id = "cloudAttachmentListRoot"; header.id = "cloudAttachmentListHeader"; list.id = "cloudAttachmentList"; footer.id = "cloudAttachmentListFooter"; // It's really quite strange, but if we don't set // the innerHTML of each element to be non-empty, then // the nodes fail to be added to the compose window. root.innerHTML = " "; header.innerHTML = " "; list.innerHTML = " "; footer.innerHTML = " "; root.appendChild(header); root.appendChild(list); root.appendChild(footer); editor.insertElementAtSelection(root, false); if (!root.previousSibling || root.previousSibling.localName == "span") { root.parentNode.insertBefore(editor.document.createElement("br"), root); } if ( root.nextElementSibling?.localName == "br" && root.nextElementSibling.nextElementSibling?.localName == "br" && root.nextElementSibling.nextElementSibling.nextElementSibling ?.localName == "br" ) { // Too many
s. Remove one. root.nextElementSibling.remove(); } // Remove the space, which would end up in the plain text converted // version. list.innerHTML = ""; selection.collapse(originalAnchor, originalOffset); // Restore the selection ranges. for (const range of ranges) { selection.addRange(range); } }, /** * Updates the count of how many attachments have been added * in HTML emails. * * @param {Document} aDocument - the document that contains the header node */ async _updateAttachmentCount(aDocument) { const header = aDocument.getElementById("cloudAttachmentListHeader"); if (!header) { return; } const entries = aDocument.querySelectorAll( "#cloudAttachmentList > .cloudAttachmentItem" ); header.textContent = await l10nCompose.formatValue( "cloud-file-count-header", { count: entries.length, } ); }, /** * Updates the service provider links in the footer. * * @param {Document} aDocument - the document that contains the footer node */ async _updateServiceProviderLinks(aDocument) { const footer = aDocument.getElementById("cloudAttachmentListFooter"); if (!footer) { return; } const providers = []; const entries = aDocument.querySelectorAll( "#cloudAttachmentList > .cloudAttachmentItem" ); for (const entry of entries) { if (!entry.dataset.serviceUrl) { continue; } const link_markup = this._generateLink( aDocument, entry.dataset.serviceName, entry.dataset.serviceUrl, "dark-grey" ).outerHTML; if (!providers.includes(link_markup)) { providers.push(link_markup); } } let content = ""; if (providers.length == 1) { content = await l10nCompose.formatValue( "cloud-file-service-provider-footer-single", { link: providers[0], } ); } else if (providers.length > 1) { const lastLink = providers.pop(); const firstLinks = providers.join(", "); content = await l10nCompose.formatValue( "cloud-file-service-provider-footer-multiple", { firstLinks, lastLink, } ); } if (gMsgCompose.composeHTML) { // eslint-disable-next-line no-unsanitized/property footer.innerHTML = content; } else { footer.textContent = this._getTextRepresentation(content); } }, /** * Insert the information for a cloud attachment. * * @param {Document} aDocument - the document to insert the item into * @param {CloudFileTemplate} aCloudFileUpload - object with information about * the uploaded file */ async _insertItem(aDocument, aCloudFileUpload) { let list = aDocument.getElementById("cloudAttachmentList"); if (!list) { this._insertHeader(aDocument); list = aDocument.getElementById("cloudAttachmentList"); } list.appendChild(await this._createNode(aDocument, aCloudFileUpload)); await this._updateAttachmentCount(aDocument); await this._updateServiceProviderLinks(aDocument); }, /** * @typedef CloudFileDate * @property {integer} timestamp - milliseconds since epoch * @property {DateTimeFormat} format - format object of Intl.DateTimeFormat */ /** * @typedef CloudFileTemplate * @property {string} serviceName - name of the upload service provider * @property {string} serviceIcon - icon of the upload service provider * @property {string} serviceUrl - web interface of the upload service provider * @property {boolean} downloadPasswordProtected - link is password protected * @property {integer} downloadLimit - download limit of the link * @property {CloudFileDate} downloadExpiryDate - expiry date of the link */ /** * Create the link node for a cloud attachment. * * @param {Document} aDocument - the document to insert the item into * @param {CloudFileTemplate} aCloudFileUpload - object with information about * the uploaded file * @param {boolean} composeHTML - override gMsgCompose.composeHTML */ async _createNode( aDocument, aCloudFileUpload, composeHTML = gMsgCompose.composeHTML ) { const iconSize = 32; const locales = { service: 0, size: 1, link: 2, "password-protected-link": 3, "expiry-date": 4, "download-limit": 5, "tooltip-password-protected-link": 6, }; const l10n_values = await l10nCompose.formatValues([ { id: "cloud-file-template-service-name" }, { id: "cloud-file-template-size" }, { id: "cloud-file-template-link" }, { id: "cloud-file-template-password-protected-link" }, { id: "cloud-file-template-expiry-date" }, { id: "cloud-file-template-download-limit" }, { id: "cloud-file-tooltip-password-protected-link" }, ]); let node = aDocument.createElement("li"); node.style.border = "1px solid #CDCDCD"; node.style.borderRadius = "5px"; node.style.marginTop = "6px"; node.style.marginBottom = "6px"; node.style.padding = "9px"; node.style.display = "grid"; node.style.gridTemplateColumns = "0fr 1fr 0fr 0fr"; node.style.alignItems = "center"; const statsRow = (name, content, contentLink) => { const entry = aDocument.createElement("span"); entry.style.gridColumn = `2 / span 3`; entry.style.fontSize = "small"; const description = aDocument.createElement("span"); description.style.color = "dark-grey"; description.textContent = `${l10n_values[locales[name]]} `; entry.appendChild(description); let value; if (composeHTML && contentLink) { value = this._generateLink(aDocument, content, contentLink, "#595959"); } else { value = aDocument.createElement("span"); value.style.color = "#595959"; value.textContent = content; } value.classList.add(`cloudfile-${name}`); entry.appendChild(value); entry.appendChild(aDocument.createElement("br")); return entry; }; const paperclip = aDocument.createElement("div"); paperclip.classList.add("paperClipIcon"); paperclip.style.gridArea = "1 / 1"; paperclip.style.display = "inline-block"; paperclip.style.marginRight = "5px"; paperclip.style.width = `${iconSize}px`; paperclip.style.height = `${iconSize}px`; paperclip.style.backgroundSize = `${iconSize}px`; if (aCloudFileUpload.downloadPasswordProtected) { paperclip.title = l10n_values[locales["tooltip-password-protected-link"]]; paperclip.style.backgroundImage = "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIfSURBVFhH7ZfLK0RRHMfvNd6PMV4Lj5UkO5bslJIdf4ClRw2TlY2yt2EhsZO9DYoFoiSvJBZkI6SsNMyIiLnH93vmXDF5HNe9pHzqM797fufMPb+Zc4Z7jC+QBnvgJryD93AddkH2eUop3IPiHXdgCfSEdLgLOdE+bIFFSl4zZxeRAl2HXzsn2IIZTCTAHPs4hsvhOlxz3rxRtt6GfRyzJlsucw1582zZehv2cUxEtlyGN6afkThuFa7EL7+H0wK03pek4q/xJwtYVv4YumurO+4V/3vgvwAvC5iHTfHL9zFV/Ah7J9tjE9s2r/K3YwWlD8IaREP+ExPCWBDJVl+gM3LEto0nBURHCiuNpBiflvLjqWcufDFfdVbo4ly1PVoC0xrAaz4qnLdiVjk1hVhArvDRFxuSYxQeFSAaGHzCbAuEIsf0URjtsithX3i1Cf18yewKn8kWyOu+OlWXuSpKnBRwpWKxioTXi7BCtr6Ak004BZvhJAwyAUZhb3Q0bwKxXmY+xVzyB8MNOgXwE/NrC0A+clXBDZV7iYkC7GK18AcvTZ0lOFGRE5NDWAtn4A28hdPQEToFcG1Jq4qERXAZ+DCaBXk+cIROAePQgh2whgk30SngAA7CVDgLq6Fr6P4M++Ec5PmPp6BhWAdzIA+m3BOO0C2AJ2GuMyfme0KQp6Ao5EmZf/fLDGFuI2oi+EEcUQm5JDywhpWc2MFGNIwn/WmcKhqF50UAAAAASUVORK5CYII=)"; } else { paperclip.style.backgroundImage = "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAVFJREFUWIXtl8FKw0AQhj8EbQ/p0Ut8AVEPgYLUB+i5L6J9E0Wtr1HPgl48WU8K1Tfw4LktxUAhHvZfiMXUbdhVhB0Yms78M/NldwkJuFsD6AMjYCYfASfKBbUd4BkoKvxJmiDWKA1/AXrAtrynmIUIshJ9DXgEmt/km8oVwHEIANu8u0LTleYhBMBUzZMVmkSaSQgAe9DW1d3L/wzAqW6jJpQ3+5cA3vbW1Vz3Np6BCBABIkAE+DWAmX7TUixdynm15Wf6jf5fa3Cq60K5qrraNuHrK1kbmJcGWJ8rB9DC4yvaq5odlmK7wBB4lw8Vs9ZRzdgHwLmaXa5RM1DNmQ+AA2ABfACZgz4DctXs+QAAuMLc0dsPEJk0BXDhazjAFnCnxjlmiTuYg5kAR4rl0twCmz4BLMQAs7RVH6kLzJ17H162fczhGmO+mqa6PqXGnn8CxMN0PcC9DrQAAAAASUVORK5CYII=)"; } if (composeHTML) { node.appendChild(paperclip); } // If this message is send in plain text only, do not add a link to the file // name. let name = aDocument.createElement("span"); name.textContent = aCloudFileUpload.name; if (composeHTML) { name = this._generateLink( aDocument, aCloudFileUpload.name, aCloudFileUpload.url, "#105bbc" ); name.setAttribute("moz-do-not-send", "true"); name.style.gridArea = "1 / 2"; } name.classList.add("cloudfile-name"); node.appendChild(name); node.appendChild(aDocument.createElement("br")); node.appendChild( statsRow("size", gMessenger.formatFileSize(aCloudFileUpload.size)) ); node.appendChild(aDocument.createElement("br")); const serviceIcon = aDocument.createElement("div"); serviceIcon.classList.add("cloudfile-service-icon"); serviceIcon.style.gridArea = "1 / 3"; serviceIcon.style.display = "inline-block"; serviceIcon.style.margin = "0 5px"; serviceIcon.style.width = `${iconSize}px`; serviceIcon.style.height = `${iconSize}px`; serviceIcon.style.backgroundSize = `${iconSize}px`; if (aCloudFileUpload.serviceIcon) { if (!/^(chrome|moz-extension):\/\//i.test(aCloudFileUpload.serviceIcon)) { serviceIcon.style.backgroundImage = "url(" + aCloudFileUpload.serviceIcon + ")"; } else { try { // Let's use the goodness from MsgComposeCommands.js since we're // sitting right in a compose window. serviceIcon.style.backgroundImage = "url(" + window.loadBlockedImage(aCloudFileUpload.serviceIcon, true) + ")"; } catch (e) { // Couldn't load the referenced image. console.error(e); } } } node.appendChild(serviceIcon); const serviceRow = () => { const service = aDocument.createDocumentFragment(); const description = aDocument.createElement("span"); description.style.display = "none"; description.textContent = `${l10n_values[locales.service]} `; service.appendChild(description); const providerName = aDocument.createElement("span"); providerName.style.gridArea = "1 / 4"; providerName.style.color = "#595959"; providerName.style.fontSize = "small"; providerName.textContent = aCloudFileUpload.serviceName; providerName.classList.add("cloudfile-service-name"); service.appendChild(providerName); service.appendChild(aDocument.createElement("br")); return service; }; if (aCloudFileUpload.downloadExpiryDate) { node.appendChild( statsRow( "expiry-date", new Date( aCloudFileUpload.downloadExpiryDate.timestamp ).toLocaleString( undefined, aCloudFileUpload.downloadExpiryDate.format || { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short", } ) ) ); } if (aCloudFileUpload.downloadLimit) { node.appendChild( statsRow("download-limit", aCloudFileUpload.downloadLimit) ); } if (composeHTML || aCloudFileUpload.serviceUrl) { node.appendChild(serviceRow()); } const linkElementLocaleId = aCloudFileUpload.downloadPasswordProtected ? "password-protected-link" : "link"; node.appendChild( statsRow(linkElementLocaleId, aCloudFileUpload.url, aCloudFileUpload.url) ); // An extra line break is needed for the converted plain text version, if it // should have a gap between its
  • elements. if (composeHTML) { node.appendChild(aDocument.createElement("br")); } // Generate the plain text version from the HTML. The used method needs a