//= require_tree ./initializers //= require_tree ./utilities //= require lib/xss //= require initializePage //= require utilities/getImageForLink //= require @honeybadger-io/js/dist/browser/honeybadger.js //= require i18n //= require ahoy.js/dist/ahoy.js I18n.defaultLocale = 'en'; I18n.locale = document.body.dataset.locale; I18n.translations = JSON.parse(document.getElementById('i18n-translations').dataset.translations); var instantClick , InstantClick = instantClick = function(document, location, $userAgent) { // Internal variables var $isChromeForIOS = $userAgent.indexOf(' CriOS/') > -1 , $currentLocationWithoutHash , $urlToPreload , $preloadTimer , $lastTouchTimestamp // Preloading-related variables , $history = {} , $xhr , $url = false , $mustRedirect = false , $fetchedBodies = {} , $timing = {} , $isPreloading = false , $isWaitingForCompletion = false , $trackedAssets = [] // Variables defined by public functions , $preloadOnMousedown , $delayBeforePreload , $eventsCallbacks = { fetch: [], receive: [], wait: [], change: [], restore: [] } ////////// HELPERS ////////// function removeHash(url) { var index = url.indexOf('#') if (index < 0) { return url } return url.substr(0, index) } function getLinkTarget(target) { while (target && target.nodeName != 'A') { target = target.parentNode } return target } function isNotPreloadable(elem) { do { if (!elem.hasAttribute) { // Parent of break } if (elem.hasAttribute('data-instant')) { return false } if (elem.hasAttribute('data-no-instant')) { return true } } while (elem = elem.parentNode) return false } function isPreloadable(a) { var domain = location.protocol + '//' + location.host if (a.target // target="_blank" etc. || a.hasAttribute('download') || a.href.indexOf(domain + '/') != 0 // Another domain, or no href attribute || (a.href.indexOf('#') > -1 && removeHash(a.href) == $currentLocationWithoutHash) // Anchor || isNotPreloadable(a) ) { return false } return true } function triggerPageEvent(eventType, arg1, arg2, arg3) { var returnValue = false for (var i = 0; i < $eventsCallbacks[eventType].length; i++) { if (eventType == 'receive') { var altered = $eventsCallbacks[eventType][i](arg1, arg2, arg3) if (altered) { /* Update args for the next iteration of the loop. */ if ('body' in altered) { arg2 = altered.body } if ('title' in altered) { arg3 = altered.title } returnValue = altered } } else { $eventsCallbacks[eventType][i](arg1, arg2, arg3) } } return returnValue } function changePage(title, body, newUrl, scrollY, pop) { var pageContentDiv = document.getElementById("page-content"); var memberMenuButton = document.getElementById("member-menu-button") if (memberMenuButton) { memberMenuButton.classList.remove('showing') } document.body.replaceChild(body, pageContentDiv) var prog = document.getElementById("navigation-progress"); prog.classList.remove("showing"); if (newUrl) { const routeChangeTarget = document.getElementById('route-change-target'); if(routeChangeTarget) { routeChangeTarget.focus(); } document.getElementById('page-route-change').textContent = title; history.pushState(null, null, newUrl.replace("?samepage=true","").replace("&samepage=true","")) var hashIndex = newUrl.indexOf('#'), hashElem = hashIndex > -1 && ( document.getElementById(newUrl.substr(hashIndex + 1)) || document.querySelector(`[name=${newUrl.substr(hashIndex + 1)}].anchor`) ), offset = 0, samePage = newUrl.indexOf("samepage=true") > -1; if (hashElem) { while (hashElem.offsetParent) { offset += hashElem.offsetTop hashElem = hashElem.offsetParent } } if (!samePage){ scrollTo(0, offset) } $currentLocationWithoutHash = removeHash(newUrl) } else { scrollTo(0, scrollY) } if ($isChromeForIOS && document.title == title) { /* Chrome for iOS: * * 1. Removes title on pushState, so the title needs to be set after. * * 2. Will not set the title if it's identical when trimmed, so * appending a space won't do; but a non-breaking space works. */ document.title = title + String.fromCharCode(160) } else { document.title = title } instantanize() if (pop) { triggerPageEvent('restore') } else { triggerPageEvent('change', false) } } function setPreloadingAsHalted() { $isPreloading = false $isWaitingForCompletion = false } function removeNoscriptTags(html) { /* Must be done on text, not on a node's innerHTML, otherwise strange * things happen with implicitly closed elements (see the Noscript test). */ return html.replace(//gi, '') } ////////// EVENT LISTENERS ////////// function mousedownListener(e) { if ($lastTouchTimestamp > (+new Date - 500)) { return // Otherwise, click doesn't fire } var a = getLinkTarget(e.target) if (!a || !isPreloadable(a)) { return } preload(a.href) } function mouseoverListener(e) { if ($lastTouchTimestamp > (+new Date - 500)) { return // Otherwise, click doesn't fire } var a = getLinkTarget(e.target) if (!a || !isPreloadable(a)) { return } a.addEventListener('mouseout', mouseoutListener) if (!$delayBeforePreload) { preload(a.href) } else { $urlToPreload = a.href $preloadTimer = setTimeout(preload, $delayBeforePreload) } getImageForLink(a); } function touchstartListener(e) { $lastTouchTimestamp = +new Date var a = getLinkTarget(e.target) if (!a || !isPreloadable(a)) { return } if ($preloadOnMousedown) { a.removeEventListener('mousedown', mousedownListener) } else { a.removeEventListener('mouseover', mouseoverListener) } preload(a.href); getImageForLink(a); } // If a link is focused, it is preloaded just like on mouseover. // It also covers the issue where a user needs to press // twice in order to follow a focused link. function focusListener(e) { var a = getLinkTarget(e.target) if (!a || !isPreloadable(a)) { return } if (!$delayBeforePreload) { preload(a.href) } else { $urlToPreload = a.href $preloadTimer = setTimeout(preload, $delayBeforePreload) } getImageForLink(a); } function clickListener(e) { try{ var a = getLinkTarget(e.target) if (!a || !isPreloadable(a)) { return } if (e.which > 1 || e.metaKey || e.ctrlKey) { // Opening in new tab return } display(a.href); e.preventDefault(); } catch(err){ console.log(err); } } function mouseoutListener() { if ($preloadTimer) { clearTimeout($preloadTimer) $preloadTimer = false return } if (!$isPreloading || $isWaitingForCompletion) { return } $xhr.abort() setPreloadingAsHalted() } function readystatechangeListener() { processXHR($xhr,$url); } function processXHR(xhr,url) { if (xhr.readyState < 4) { return } if (xhr.status == 0) { /* Request aborted */ return } $timing.ready = +new Date - $timing.start var pageContentDiv = document.getElementById("page-content"); if (pageContentDiv && xhr.status === 200 && xhr.getResponseHeader('Content-Type').match(/\/(x|ht|xht)ml/)) { var doc = document.implementation.createHTMLDocument(''); doc.documentElement.innerHTML = removeNoscriptTags(xhr.responseText) var title = doc.title var body = doc.getElementById("page-content") var alteredOnReceive = triggerPageEvent('receive', url, body, title) if (alteredOnReceive) { if ('body' in alteredOnReceive) { body = alteredOnReceive.body } if ('title' in alteredOnReceive) { title = alteredOnReceive.title } } $fetchedBodies[url] = {body:body, title:title}; var urlWithoutHash = removeHash(url) var elems = doc.head.children , found = 0 , elem , data for (var i = 0; i < elems.length; i++) { elem = elems[i] if (elem.hasAttribute('data-instant-track')) { data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML for (var j = 0; j < $trackedAssets.length; j++) { if ($trackedAssets[j] == data) { found++ } } } } if (found != $trackedAssets.length) { $mustRedirect = true // Assets have changed } } else { $mustRedirect = true // Not an HTML document } if ($isWaitingForCompletion && $url === url) { $isWaitingForCompletion = false display($url) } } function popstateListener() { var loc = removeHash(location.href) if (loc == $currentLocationWithoutHash) { return } if (!(loc in $history)) { location.href = location.href return } $history[$currentLocationWithoutHash] = { body: document.getElementById("page-content"), title: document.title, scrollY: pageYOffset } $currentLocationWithoutHash = loc changePage($history[loc].title, $history[loc].body, false, $history[loc].scrollY, true) } ////////// MAIN FUNCTIONS ////////// function instantanize(isInitializing) { var docBody = document.body; if (docBody) { document.body.addEventListener('touchstart', touchstartListener, true) document.body.addEventListener('focus', focusListener, true) if ($preloadOnMousedown) { document.body.addEventListener('mousedown', mousedownListener, true) } else { document.body.addEventListener('mouseover', mouseoverListener, true) } document.body.addEventListener('click', clickListener, true) } if (!isInitializing) { var scriptElementsInDOM = document.body.getElementsByTagName('script') , scriptElementsToCopy = [] , originalElement , copyElement , parentNode , nextSibling , i // `scriptElementsInDOM` will change during the copy of scripts if // a script add or delete script elements, so we need to put script // elements in an array to loop through them correctly. for (i = 0; i < scriptElementsInDOM.length; i++) { scriptElementsToCopy.push(scriptElementsInDOM[i]) } for (i = 0; i < scriptElementsToCopy.length; i++) { originalElement = scriptElementsToCopy[i] if (!originalElement) { // Might have disappeared, see previous comment continue } if (originalElement.hasAttribute('data-no-instant')) { continue } copyElement = document.createElement('script') for (var j = 0; j < originalElement.attributes.length; j++) { copyElement.setAttribute(originalElement.attributes[j].name, originalElement.attributes[j].value) } copyElement.textContent = originalElement.textContent parentNode = originalElement.parentNode nextSibling = originalElement.nextSibling parentNode.removeChild(originalElement) parentNode.insertBefore(copyElement, nextSibling) } } } function preload(url, option) { if (!$preloadOnMousedown && 'display' in $timing && +new Date - ($timing.start + $timing.display) < 100) { return } if ($preloadTimer) { clearTimeout($preloadTimer) $preloadTimer = false } if (!url) { url = $urlToPreload } if ($isPreloading && (url == $url || $isWaitingForCompletion)) { return } $isPreloading = true $isWaitingForCompletion = false $mustRedirect = false $timing = { start: +new Date } if (url.indexOf("?") == -1) { var internalUrl = url+"?i=i" } else { var internalUrl = url+"&i=i" } removeExpiredKeys() triggerPageEvent('fetch') if (!$fetchedBodies[url]){ if (option === "force") { getURLInfo(url, function () { processXHR(this,url); }) } else { $url = url $xhr.open('GET', internalUrl) $xhr.send() } } } function removeExpiredKeys(option) { if ( Object.keys($fetchedBodies).length > 13 || option == "force" ) { $fetchedBodies = {}; } } function getURLInfo(url, callback) { var xhr = new XMLHttpRequest(); if (url.indexOf("?") == -1) { var internalUrl = url+"?i=i" } else { var internalUrl = url+"&i=i" } xhr.open ( "GET", internalUrl, true ); xhr.onreadystatechange = function() { if (xhr.readyState == 4) { // defensive check if (typeof callback == "function") { // apply() sets the meaning of "this" in the callback callback.apply(xhr); } } } // send the request *after* the event handler is defined xhr.send(); } function display(url) { $url = url; if($fetchedBodies[url]){ var body = $fetchedBodies[url]['body']; var title = $fetchedBodies[url]['title']; } else { var prog = document.getElementById("navigation-progress"); prog.classList.add("showing"); } if (!('display' in $timing)) { $timing.display = +new Date - $timing.start } if ($preloadTimer || !$isPreloading) { if ($preloadTimer && $url && $url != url) { location.href = url return } preload(url) triggerPageEvent('wait') $isWaitingForCompletion = true // Must be set *after* calling `preload` return } if ($isWaitingForCompletion) { /* The user clicked on a link while a page was preloading. Either on the same link or on another link. If it's the same link something might have gone wrong (or he could have double clicked, we don't handle that case), so we send him to the page without pjax. If it's another link, it hasn't been preloaded, so we redirect the user to it. */ location.href = url return } if ($mustRedirect) { location.href = $url return } if (!body) { triggerPageEvent('wait') $isWaitingForCompletion = true return } $history[$currentLocationWithoutHash] = { body: document.getElementById("page-content"), title: document.title, scrollY: pageYOffset } setPreloadingAsHalted() changePage(title, body, $url) } ////////// PUBLIC VARIABLE AND FUNCTIONS ////////// var supported = 'pushState' in history && (!$userAgent.match('Android') || $userAgent.match('Chrome/') || $userAgent.match('Firefox/')) && location.protocol != "file:" /* The (sad) state of Android's AOSP browsers: 2.3.7: pushState appears to work correctly, but `doc.documentElement.innerHTML = body` is buggy. Update: InstantClick doesn't use that anymore, but it may fail where 3.0 do, this needs testing again. 3.0: pushState appears to work correctly (though the address bar is only updated on focus), but `document.documentElement.replaceChild(doc.body, document.body)` throws DOMException: WRONG_DOCUMENT_ERR. 4.0.2: Doesn't support pushState. 4.0.4, 4.1.1, 4.2, 4.3: Claims support for pushState, but doesn't update the address bar. 4.4: Works correctly. Claims to be 'Chrome/30.0.0.0'. All androids tested with Android SDK's Emulator. Version numbers are from the browser's user agent. Because of this mess, the only allowed browser on Android is Chrome. */ function init(preloadingMode) { if ($currentLocationWithoutHash) { /* Already initialized */ return } if (!supported) { triggerPageEvent('change', true) return } if (preloadingMode == 'mousedown') { $preloadOnMousedown = true } else if (typeof preloadingMode == 'number') { $delayBeforePreload = preloadingMode } $currentLocationWithoutHash = removeHash(location.href) $history[$currentLocationWithoutHash] = { body: document.getElementById("page-content"), title: document.title, scrollY: pageYOffset } var elems = document.head.children , elem , data for (var i = 0; i < elems.length; i++) { elem = elems[i] if (elem.hasAttribute('data-instant-track')) { data = elem.getAttribute('href') || elem.getAttribute('src') || elem.innerHTML /* We can't use just `elem.href` and `elem.src` because we can't retrieve `href`s and `src`s from the Ajax response. */ $trackedAssets.push(data) } } $xhr = new XMLHttpRequest() $xhr.addEventListener('readystatechange', readystatechangeListener) instantanize(true) triggerPageEvent('change', true) addEventListener('popstate', popstateListener) addRefreshBehavior(); } function on(eventType, callback) { $eventsCallbacks[eventType].push(callback) } function addRefreshBehavior(){ if (!("ontouchstart" in document.documentElement)) { return } var script = document.createElement('script'); script.src = "<%= javascript_path 'lib/pulltorefresh.js' %>"; document.head.appendChild(script); var waitingOnPTR = setInterval(function(){ if (typeof PullToRefresh !== 'undefined') { var ptr = PullToRefresh.init({ mainElement: 'body', passive: true, onRefresh: function(){ window.location.reload(); } }); clearInterval(waitingOnPTR) } }, 1) } //////////////////// return { supported: supported, init: init, isPreloadable: isPreloadable, preload: preload, removeExpiredKeys: removeExpiredKeys, display: display, on: on } }(document, location, navigator.userAgent); // FUNCTIONAL CODE FOR PAGE function initializeBaseApp() { InstantClick.on('change', function() { initializePage(); }); InstantClick.init(); } // INITIALIZE/ERROR HANDLING Honeybadger.configure({ apiKey: document.body.dataset.honeybadgerKey, environment: "<%= Rails.env %>", revision: document.body.dataset.releaseFootprint, }); Honeybadger.beforeNotify(function(notice) { const ignorePatterns = [/ResizeObserver/i, /MetaMask/i, /MtPopUpList/i] return !(ignorePatterns.some((pattern) => pattern.test(notice.message))); }); // INITIALIZE AHOY TRACKING // Setting cookies to false matches what we do in ahoy's initializer. // Setting trackVisits to false prevents ahoy from creating a visit on the server-side. ahoy.configure({ cookies: false, trackVisits: false }); // Start BaseApp for Page initializeBaseApp()