diff --git a/README.md b/README.md index 8be4787f1882eec74bbaf1619037b68b2b68cced..580cee758df54c1e02aa296af8f03e2fe36499ef 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,7 @@ in such a way that the original link is accessible. * Since only the *display* is changed, links that have the URL as the link text will now have link text that differs from the link target. Thunderbird picks up on this when you click a link and asks if you - want to visit the original link or the safe link. Due to the way the - tooltip is constructed, visiting the original link will most likely - fail. This is not by design and may change in the future. + want to visit the original link or the safe link. * When composing a message the original links are restored. This process could potentially change text that isn't meant to be diff --git a/extension/common.js b/extension/common.js index 5b431a51069965cde5005841477edbe8109606c5..32f296f6c6e89933f2a48d2d379b33ba638d0e55 100644 --- a/extension/common.js +++ b/extension/common.js @@ -22,11 +22,33 @@ // Shared code +/** + * Regexp that matches safe links. The original URL must be collected + * in match group 1. + */ const safelinksRegexp = new RegExp( 'https?://[^.]+[.]safelinks[.]protection[.]outlook[.]com/[?]url=([^&]+)&.*', 'gi' ); + +/** + * The ID for the popup element that is added to the HTML document. + */ +const safelinksPopupId = 'safelinks-cleaner-thunderbird-popup'; + + +/** + * The class that is added to the popup when visible. + */ +const safelinksPopupVisibleClass = 'safelinks-cleaner-thunderbird-popup-visible'; + + +/** + * Return the original URL for a safe link. + * @param {string} link - The safe link. + * @returns {string} The original link or the safe link if there was an error. + */ function untangleLink(link) { return link.replaceAll( safelinksRegexp, (match, url) => { @@ -40,10 +62,22 @@ function untangleLink(link) { }); } + +/** + * Check if a link is a safe link. + * @param {string} link - The URL to check. + * @returns {boolean} Returns true if the link is a safe link. + */ function isTangledLink(link) { return link.match(safelinksRegexp); } + +/** + * Return the text nodes under a DOM element. + * @param {Element} elem - The element to return text nodes for. + * @returns {Element[]} The text elements under elem. + */ function getTextNodes(elem) { var result = []; if (elem) { diff --git a/extension/display.js b/extension/display.js index 09c4b1d1ebe4d20efbf541612b2f0797c665643f..9ecc2593c41301e748f21af10f9c380c2859bd57 100644 --- a/extension/display.js +++ b/extension/display.js @@ -22,19 +22,140 @@ // Display script +let currentPopupTarget = null; +let hidePopupTimeout = null; + + +/** + * Return the popup div element, creating it if necessary. + * @returns {Element} The popup element. + */ +function getPopup() { + let popup = document.getElementById(safelinksPopupId); + if (!popup) { + popupElementLocked = false; + popup = document.createElement('div'); + popup.id = safelinksPopupId; + popup.addEventListener('mouseenter', cancelHidePopup, {passive: true}); + popup.addEventListener('mouseleave', scheduleHidePopup, {passive: true}); + document.body.appendChild(popup); + } + return popup; +} + + +/** + * Cancel hiding the popup (if it has been scheduled) and set + * hidePopupTimeout to null. + */ +function cancelHidePopup() { + if (hidePopupTimeout) { + clearTimeout(hidePopupTimeout); + hidePopupTimeout = null; + } +} + + +/** + * Hide the current popup. If there is no popup, one will be created. + */ +function hidePopup() { + cancelHidePopup(); + getPopup().classList.remove(safelinksPopupVisibleClass); + currentPopupTarget = undefined; +} + + +/** + * Schedule hiding the current popup. + */ +function scheduleHidePopup() { + if (!hidePopupTimeout) { + hidePopupTimeout = setTimeout(hidePopup, 100); + } +} + + +/** + * Get the absolute bounds of an element. + * @param {Element} elem - The element for which to return bounds. + * @returns {{top: number, left: number, right: number, bottom: + * number}} The top, left, right, and bottom coordinates of the + * element. + */ +function getAbsoluteBoundingRect(elem) { + let rect = elem.getBoundingClientRect(); + let scrollLeft = window.scrollX; + let scrollTop = window.scrollY; + return { + top: rect.top + window.scrollY, + left: rect.left + window.scrollX, + bottom: rect.bottom + window.scrollY, + right: rect.right + window.scrollX, + } +} + +/** + * Attempt to ensure that at least part of an element is visible. If + * the element's right-hand coordinate is off-screen, move it + * on-screen without moving the left-hand side off-screen. If the + * bottom of the element is off-screen, move it on-screen. + * @param {Element} elem - The element to show. + */ +function clampElementToDocument(elem) { + let elemBounds = getAbsoluteBoundingRect(elem); + + if (elemBounds.bottom > document.documentElement.scrollHeight) { + elem.style.removeProperty('top'); + elem.style.bottom = 0; + } + + if (elemBounds.right > document.documentElement.scrollWidth) { + elem.style.removeProperty('left'); + elem.style.right = 0; + if (getAbsoluteBoundingRect(elem).left < 0) { + elem.style.left = 0; + } + } +} + +/** + * Show the original URL of a link. + * @param {MouseEvent} event - The event triggering this handler. + */ +function showOriginalUrl(event) { + let popup = getPopup(); + cancelHidePopup(); + if (event.target != currentPopupTarget || !popup.classList.contains(safelinksPopupVisibleClass)) { + currentPopupTarget = event.target; + popup.textContent = untangleLink(event.target.href); + popup.style.removeProperty('bottom'); + popup.style.removeProperty('right'); + popup.style.left = event.clientX; + popup.style.top = event.clientY; + popup.classList.add(safelinksPopupVisibleClass); + //clampElementToDocument(popup); + } +} + +/** + * Add event handlers to a link so it will show the original url. + * @param {Element} link - The link to add the popup to. + */ +function addLinkPopup(link) { + link.addEventListener('mouseenter', showOriginalUrl, {passive: true}); + link.addEventListener('mouseleave', scheduleHidePopup, {passive: true}); +} + + for (const link of document.links) { - // Mangle the link text + // Untangle link text for (const node of getTextNodes(link)) { node.textContent = untangleLink(node.textContent); } - // Generate the tooltip and set link class + // Create popup event handlers if (isTangledLink(link.href)) { - let tooltiptext = untangleLink(link.href); - let tooltip = document.createElement('span'); - tooltip.classList.add('liu_safelinks_tooltip'); - tooltip.textContent = tooltiptext; - link.classList.add('liu_safelinks_link'); - link.prepend(tooltip); + addLinkPopup(link); } } diff --git a/extension/manifest.json b/extension/manifest.json index 208029ae1f767c6eeb3f26cb773d02b001164332..6991c2ddedeb82224799dafeffdd8761a286ba1e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "Microsoft ATP Safe Links Cleaner", "description": "__MSG_extensionDescription__", - "version": "1.1", + "version": "1.2", "author": "David Byers", "homepage_url": "https://safelinks.gitlab-pages.liu.se/safelinks-cleaner-thunderbird/", "default_locale": "en", diff --git a/extension/style.css b/extension/style.css index 169865b4f8977b689d0d752f02bf3741cc776f40..95f271cbd0983e90529959308997a5bc6a2ef07a 100644 --- a/extension/style.css +++ b/extension/style.css @@ -21,16 +21,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -a:hover .liu_safelinks_tooltip { - display: block; -} -.liu_safelinks_tooltip { +#safelinks-cleaner-thunderbird-popup { display: none; background: #fffff8; color: black; padding: 3px 3px 4px 3px; - position: absolute; + position: fixed; z-index: 1000; border: 1px solid black; -webkit-box-shadow: 0px 0px 6px 1px rgba(0,0,0,0.5); @@ -38,3 +35,7 @@ a:hover .liu_safelinks_tooltip { box-shadow: 0px 0px 6px 1px rgba(0,0,0,0.5); font: 14px sans-serif; } + +#safelinks-cleaner-thunderbird-popup.safelinks-cleaner-thunderbird-popup-visible { + display: block; +}