Use uppercase since that is what the DOM for HTML defaults to, use nodeName rather than tagName for cases when there the check is false and ancestor is the document element, add unit tests.
344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
/*
|
|
* Copyright 2016 Software Freedom Conservancy Inc.
|
|
* Copyright 2016 Michael Gratton <mike@vee.net>
|
|
*
|
|
* This software is licensed under the GNU Lesser General Public License
|
|
* (version 2.1 or later). See the COPYING file in this distribution.
|
|
*/
|
|
|
|
/**
|
|
* Application logic for ConversationWebView.
|
|
*/
|
|
let ConversationPageState = function() {
|
|
this.init.apply(this, arguments);
|
|
};
|
|
|
|
ConversationPageState.QUOTE_CONTAINER_CLASS = "geary-quote-container";
|
|
ConversationPageState.QUOTE_HIDE_CLASS = "geary-hide";
|
|
|
|
// Keep these in sync with ConversationWebView
|
|
ConversationPageState.NOT_DECEPTIVE = 0;
|
|
ConversationPageState.DECEPTIVE_HREF = 1;
|
|
ConversationPageState.DECEPTIVE_DOMAIN = 2;
|
|
|
|
ConversationPageState.prototype = {
|
|
__proto__: PageState.prototype,
|
|
init: function() {
|
|
PageState.prototype.init.apply(this, []);
|
|
|
|
let state = this;
|
|
document.addEventListener("click", function(e) {
|
|
if (e.target.tagName == "A" &&
|
|
state.linkClicked(e.target)) {
|
|
e.preventDefault();
|
|
}
|
|
}, true);
|
|
},
|
|
/**
|
|
* Add email headers for printing
|
|
*/
|
|
addPrintHeaders: function(headers) {
|
|
let headerTable = document.getElementById('geary-message-headers');
|
|
if (headerTable) headerTable.parentNode.removeChild(headerTable);
|
|
|
|
headerTable = document.createElement('table');
|
|
headerTable.id = 'geary-message-headers';
|
|
for (header in headers) {
|
|
let row = headerTable.appendChild(document.createElement('tr'));
|
|
let name = row.appendChild(document.createElement('th'));
|
|
let value = row.appendChild(document.createElement('td'));
|
|
name.textContent = header;
|
|
value.textContent = headers[header];
|
|
}
|
|
|
|
document.body.insertBefore(headerTable, document.body.firstChild);
|
|
},
|
|
loaded: function() {
|
|
this.updateDirection();
|
|
this.createControllableQuotes();
|
|
this.wrapSignature();
|
|
PageState.prototype.loaded.apply(this, []);
|
|
},
|
|
/**
|
|
* Set dir="auto" if not already set.
|
|
*
|
|
* This should provide a slightly better RTL experience.
|
|
*/
|
|
updateDirection: function() {
|
|
let dir = document.documentElement.dir;
|
|
if (dir == null || dir.trim() == "") {
|
|
document.documentElement.dir = "auto";
|
|
}
|
|
},
|
|
/**
|
|
* Add top level blockquotes to hide/show container.
|
|
*/
|
|
createControllableQuotes: function() {
|
|
let blockquoteList = document.documentElement.querySelectorAll("blockquote");
|
|
for (let i = 0; i < blockquoteList.length; ++i) {
|
|
let blockquote = blockquoteList.item(i);
|
|
let nextSibling = blockquote.nextSibling;
|
|
let parent = blockquote.parentNode;
|
|
|
|
// Only insert into a quote container if the element is a
|
|
// top level blockquote
|
|
if (!ConversationPageState.isDescendantOf(blockquote, "BLOCKQUOTE")) {
|
|
let quoteHeight = blockquote.offsetHeight;
|
|
|
|
// Only make the quote it controllable if it is tall enough
|
|
let isControllable = (quoteHeight > 120);
|
|
|
|
let quoteContainer = document.createElement("DIV");
|
|
quoteContainer.classList.add(
|
|
ConversationPageState.QUOTE_CONTAINER_CLASS
|
|
);
|
|
if (isControllable) {
|
|
quoteContainer.classList.add("geary-controllable");
|
|
quoteContainer.classList.add(
|
|
ConversationPageState.QUOTE_HIDE_CLASS
|
|
);
|
|
}
|
|
|
|
let quoteDiv = document.createElement("DIV");
|
|
quoteDiv.classList.add("geary-quote");
|
|
|
|
quoteDiv.appendChild(blockquote);
|
|
quoteContainer.appendChild(quoteDiv);
|
|
parent.insertBefore(quoteContainer, nextSibling);
|
|
|
|
let containerHeight = quoteDiv.offsetHeight;
|
|
|
|
let state = this;
|
|
function newControllerButton(styleClass, text) {
|
|
let button = document.createElement("BUTTON");
|
|
button.classList.add("geary-button");
|
|
button.type = "button";
|
|
button.onclick = function() {
|
|
let hide = ConversationPageState.QUOTE_HIDE_CLASS;
|
|
quoteContainer.classList.toggle(hide);
|
|
|
|
// Update the preferred height. We calculate
|
|
// what the difference should be rather than
|
|
// getting it directly, since WK won't ever
|
|
// shrink the height of the HTML element.
|
|
let height = quoteHeight - containerHeight;
|
|
if (quoteContainer.classList.contains(hide)) {
|
|
height = state.lastPreferredHeight - height;
|
|
} else {
|
|
height = state.lastPreferredHeight + height;
|
|
}
|
|
state.updatePreferredHeight(height);
|
|
};
|
|
button.appendChild(document.createTextNode(text));
|
|
|
|
let container = document.createElement("DIV");
|
|
container.classList.add(styleClass);
|
|
container.appendChild(button);
|
|
|
|
return container;
|
|
}
|
|
|
|
if (isControllable) {
|
|
quoteContainer.appendChild(newControllerButton(
|
|
"geary-shower", "▼ ▼ ▼"
|
|
));
|
|
quoteContainer.appendChild(newControllerButton(
|
|
"geary-hider", "▲ ▲ ▲"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Look for and wrap a signature.
|
|
*
|
|
* Most HTML signatures fall into one
|
|
* of these designs which are handled by this method:
|
|
*
|
|
* 1. GMail: <div>-- </div>$SIGNATURE
|
|
* 2. GMail Alternate: <div><span>-- </span></div>$SIGNATURE
|
|
* 3. Thunderbird: <div>-- <br>$SIGNATURE</div>
|
|
*
|
|
*/
|
|
wrapSignature: function() {
|
|
let possibleSigs = document.documentElement.querySelectorAll("div,span,p");
|
|
let i = 0;
|
|
let sigRegex = new RegExp("^--\\s*$");
|
|
let alternateSigRegex = new RegExp("^--\\s*(?:<br|\\R)");
|
|
for (; i < possibleSigs.length; ++i) {
|
|
// Get the div and check that it starts a signature block
|
|
// and is not inside a quote.
|
|
let div = possibleSigs.item(i);
|
|
let innerHTML = div.innerHTML;
|
|
if ((sigRegex.test(innerHTML) || alternateSigRegex.test(innerHTML)) &&
|
|
!ConversationPageState.isDescendantOf(div, "BLOCKQUOTE")) {
|
|
break;
|
|
}
|
|
}
|
|
// If we have a signature, move it and all of its following
|
|
// siblings that are not quotes inside a signature div.
|
|
if (i < possibleSigs.length) {
|
|
let elem = possibleSigs.item(i);
|
|
let parent = elem.parentNode;
|
|
let signatureContainer = document.createElement("DIV");
|
|
signatureContainer.classList.add("geary-signature");
|
|
do {
|
|
// Get its sibling _before_ we move it into the signature div.
|
|
let sibling = elem.nextSibling;
|
|
signatureContainer.appendChild(elem);
|
|
elem = sibling;
|
|
} while (elem != null);
|
|
parent.appendChild(signatureContainer);
|
|
}
|
|
},
|
|
getSelectionForQuoting: function() {
|
|
let quote = null;
|
|
let selection = window.getSelection();
|
|
if (!selection.isCollapsed) {
|
|
let range = selection.getRangeAt(0);
|
|
let ancestor = range.commonAncestorContainer;
|
|
if (ancestor.nodeType != Node.ELEMENT_NODE) {
|
|
ancestor = ancestor.parentNode;
|
|
}
|
|
|
|
// If the selection is part of a plain text message,
|
|
// we have to stick it in an appropriately styled div,
|
|
// so that new lines are preserved.
|
|
let dummy = document.createElement("DIV");
|
|
let includeDummy = false;
|
|
if (ConversationPageState.isDescendantOf(ancestor, "DIV", "plaintext")) {
|
|
dummy.classList.add("plaintext");
|
|
dummy.setAttribute("style", "white-space: pre-wrap;");
|
|
includeDummy = true;
|
|
}
|
|
dummy.appendChild(range.cloneContents());
|
|
|
|
// Remove the chrome we put around quotes, leaving
|
|
// only the blockquote element.
|
|
let quotes = dummy.querySelectorAll(
|
|
"." + ConversationPageState.QUOTE_CONTAINER_CLASS
|
|
);
|
|
for (let i = 0; i < quotes.length; i++) {
|
|
let div = quotes.item(i);
|
|
let blockquote = div.querySelector("blockquote");
|
|
div.parentNode.replaceChild(blockquote, div);
|
|
}
|
|
|
|
quote = includeDummy ? dummy.outerHTML : dummy.innerHTML;
|
|
}
|
|
return quote;
|
|
},
|
|
getSelectionForFind: function() {
|
|
let value = null;
|
|
let selection = window.getSelection();
|
|
|
|
if (selection.rangeCount > 0) {
|
|
let range = selection.getRangeAt(0);
|
|
value = range.toString().trim();
|
|
if (value == "") {
|
|
value = null;
|
|
}
|
|
}
|
|
return value;
|
|
},
|
|
linkClicked: function(link) {
|
|
let cancelClick = false;
|
|
let href = link.href;
|
|
if (!href.startsWith("mailto:")) {
|
|
let text = link.innerText;
|
|
let reason = ConversationPageState.isDeceptiveText(text, href);
|
|
if (reason != ConversationPageState.NOT_DECEPTIVE) {
|
|
cancelClick = true;
|
|
window.webkit.messageHandlers.deceptiveLinkClicked.postMessage({
|
|
reason: reason,
|
|
text: text,
|
|
href: href,
|
|
location: ConversationPageState.getNodeBounds(link)
|
|
});
|
|
}
|
|
}
|
|
|
|
return cancelClick;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Returns an [x, y, width, height] array of a node's bounds.
|
|
*/
|
|
ConversationPageState.getNodeBounds = function(node) {
|
|
let x = 0;
|
|
let y = 0;
|
|
let parent = node;
|
|
while (parent != null) {
|
|
x += parent.offsetLeft;
|
|
y += parent.offsetTop;
|
|
parent = parent.offsetParent;
|
|
}
|
|
return {
|
|
x: x,
|
|
y: y,
|
|
width: node.offsetWidth,
|
|
height: node.offsetHeight
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Test for URL-like `text` that leads somewhere other than `href`.
|
|
*/
|
|
ConversationPageState.isDeceptiveText = function(text, href) {
|
|
// First, does text look like a URI? Right now, just test whether
|
|
// it has <string>.<string> in it. More sophisticated tests are
|
|
// possible.
|
|
let domain = new RegExp("([a-z]*://)?" // Optional scheme
|
|
+ "([^\\s:/]+\\.[^\\s:/\\.]+)" // Domain
|
|
+ "(/[^\\s]*)?"); // Optional path
|
|
let textParts = text.match(domain);
|
|
if (textParts == null) {
|
|
return ConversationPageState.NOT_DECEPTIVE;
|
|
}
|
|
let hrefParts = href.match(domain);
|
|
if (hrefParts == null) {
|
|
// If href doesn't look like a URL, something is fishy, so
|
|
// warn the user
|
|
return ConversationPageState.DECEPTIVE_HREF;
|
|
}
|
|
|
|
// Second, do the top levels of the two domains match? We
|
|
// compare the top n levels, where n is the minimum of the
|
|
// number of levels of the two domains.
|
|
let textDomain = textParts[2].toLowerCase().split(".").reverse();
|
|
let hrefDomain = hrefParts[2].toLowerCase().split(".").reverse();
|
|
let segmentCount = Math.min(textDomain.length, hrefDomain.length);
|
|
if (segmentCount == 0) {
|
|
return ConversationPageState.DECEPTIVE_DOMAIN;
|
|
}
|
|
for (let i = 0; i < segmentCount; i++) {
|
|
if (textDomain[i] != hrefDomain[i]) {
|
|
return ConversationPageState.DECEPTIVE_DOMAIN;
|
|
}
|
|
}
|
|
|
|
return ConversationPageState.NOT_DECEPTIVE;
|
|
};
|
|
|
|
/**
|
|
* See if this element has an ancestor with the given tag and class.
|
|
*
|
|
* ancestorTag must be all uppercase.
|
|
*
|
|
* If ancestorClass is null, no class checking is done.
|
|
*/
|
|
ConversationPageState.isDescendantOf = function(node, ancestorTag, ancestorClass = null) {
|
|
let ancestor = node.parentNode;
|
|
while (ancestor != null) {
|
|
if (ancestor.nodeName.toUpperCase() == ancestorTag) {
|
|
if (!ancestorClass || ancestor.classList.contains(ancestorClass)) {
|
|
return true;
|
|
}
|
|
}
|
|
ancestor = ancestor.parentNode;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
var geary = new ConversationPageState();
|