UNPKG

@ideasio/add-to-homescreen-react

Version:

A React component providing add-to-home-screen functionality for progressive webapps on different platforms and browsers.

723 lines (618 loc) 26 kB
import PropTypes from 'prop-types'; import React, { useEffect } from 'react'; import './addToHomeScreen.scss'; import DEFAULT_CONFIGURATION from './addToHomeScreenConfiguration.json'; export default function AddToHomeScreen(props) { const DEFAULT_PROMPT = { title: 'Do you want to install this application on your homescreen?', cancelMsg: 'Not now', installMsg: 'Install', guidanceCancelMsg: 'Close', src: 'images/logos/default/StoreLogo.png' }; const DEFAULT_SESSION = { lastDisplayTime: 0, // last time we displayed the message returningVisitor: false, // is this the first time you visit displayCount: 0, // number of times the message has been shown optedOut: false, // has the user opted out added: false, // has been actually added to the home screen pageViews: 0 }; let configuration = buildConfiguration(); doLog(`final configuration: ${ JSON.stringify(configuration) }`); let session = {}; let platform = {}; let guidanceTargetUrls = []; let isAthDialogShown = false; let showNativePrompt = false; let canPromptState; let beforeInstallPromptEvent; let autoHideTimer; useEffect(initialize, []); function initialize() { if ('onbeforeinstallprompt' in window) { doLog('add beforeinstallprompt listener'); window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); showNativePrompt = true; } if ('onappinstalled' in window) { window.addEventListener('appinstalled', function (evt) { doLog('A2HS installed'); session.added = true; updateSession(); if (configuration.onInstall) { configuration.onInstall.call(this); } }); } checkPlatform(); let sessionString = window.localStorage.getItem(configuration.appId); session = sessionString ? JSON.parse(sessionString) : DEFAULT_SESSION; if (session && session.added) { // there is nothing to do if app was already added to home screen return; } if ('serviceWorker' in navigator) { let manifestElement = document.querySelector('[rel=\'manifest\']'); if (!manifestElement) { doLog('no manifest file'); platform.isCompatible = false; } setTimeout(function () { // we wait 1 sec until we execute this because sometimes the browser needs a little time to register the service worker navigator.serviceWorker.getRegistration().then(afterServiceWorkerCheck); buildGuidanceURLs(configuration.customPromptPlatformDependencies); }, 1000); } else { afterServiceWorkerCheck({}); } } function buildConfiguration() { let options = Object.assign({}, DEFAULT_CONFIGURATION, props); options.customPromptContent = Object.assign({}, DEFAULT_CONFIGURATION.customPromptContent, props.customPromptContent); options.customPromptElements = Object.assign({}, DEFAULT_CONFIGURATION.customPromptElements, props.customPromptElements); options.customPromptPlatformDependencies = Object.assign({}, DEFAULT_CONFIGURATION.customPromptPlatformDependencies, props.customPromptPlatformDependencies); for (let key in DEFAULT_CONFIGURATION.customPromptPlatformDependencies) { if (DEFAULT_CONFIGURATION.customPromptPlatformDependencies.hasOwnProperty(key)) { if (props.customPromptPlatformDependencies) { options.customPromptPlatformDependencies[key] = Object.assign({}, DEFAULT_CONFIGURATION.customPromptPlatformDependencies[key], props.customPromptPlatformDependencies[key]); } else { options.customPromptPlatformDependencies[key] = DEFAULT_CONFIGURATION.customPromptPlatformDependencies[key]; } } } return options; } function buildGuidanceURLs(prompts) { for (let key in prompts) { if (prompts.hasOwnProperty(key)) { let target = prompts[key].targetUrl; doLog('building guidance urls: ' + key + target ? '/' + target : ''); if (target) { guidanceTargetUrls.push(target); } } } } // show hint images for browsers without native prompt /* * Currently: * iOS Safari * FireFox Android * Samsung Android * Opera Android */ function showPlatformGuidance(skipNative) { let target = getPlatform(skipNative); doLog('showing platform guidance for: ' + target); let athWrapper = document.querySelector(`.${ configuration.customPromptElements.container }`); if (athWrapper) { if (autoHideTimer) { clearTimeout(autoHideTimer); } if (!skipNative && target === 'native' && beforeInstallPromptEvent) { closePrompt(); triggerNativePrompt(); } else { let promptTarget = Object.assign({}, DEFAULT_PROMPT, configuration.customPromptContent, configuration.customPromptPlatformDependencies[target]); if (promptTarget.targetUrl) { location.replace(promptTarget.targetUrl); } else { if (promptTarget.images && promptTarget.images.length > 0) { let promptDialogBannerBody = athWrapper.querySelector(`.${ configuration.customPromptElements.banner }`); let promptDialogGuidanceBody = athWrapper.querySelector(`.${ configuration.customPromptElements.guidance }`); let promptDialogGuidanceImageCell = athWrapper.querySelector(`.${ configuration.customPromptElements.guidanceImageCell }`); let promptDialogGuidanceCancelButton = athWrapper.querySelector(`.${ configuration.customPromptElements.guidanceCancelButton }`); promptDialogBannerBody.classList.add(configuration.hideClass); promptDialogGuidanceBody.classList.add(configuration.showClass); for (let index = 0; index < promptTarget.images.length; index++) { let img = new Image(); img.src = promptTarget.images[index].src; img.alt = promptTarget.images[index].alt; if (promptTarget.images[index].classes) { img.classList.add(...promptTarget.images[index].classes); } promptDialogGuidanceImageCell.appendChild(img); } if (promptDialogGuidanceCancelButton) { promptDialogGuidanceCancelButton.addEventListener('click', cancelPrompt); promptDialogGuidanceCancelButton.classList.remove(configuration.hideClass); promptDialogGuidanceCancelButton.innerText = promptTarget.guidanceCancelMsg != null ? promptTarget.guidanceCancelMsg : ((promptTarget.action && promptTarget.action.guidanceCancel) ? promptTarget.action.guidanceCancel : ''); } } if (!isVisible(athWrapper)) { athWrapper.classList.add(...promptTarget.showClasses); athWrapper.classList.remove(configuration.hideClass); } let hideAfter = (configuration.lifespan >= 10) ? configuration.lifespan : 10; autoHideTimer = setTimeout(autoHide, hideAfter * 1000); } } } } function isVisible(element) { let dimensions = element.getBoundingClientRect(); return dimensions.width !== 0 && dimensions.height !== 0; } function afterServiceWorkerCheck(serviceWorker) { if (!serviceWorker) { doLog('no service worker'); platform.isCompatible = false; } doLog('service worker found - increasing page views'); session.pageViews += 1; updateSession(); // override defaults that are dependent on each other if (configuration && configuration.debug && (typeof configuration.activateLogging === 'undefined')) { configuration.activateLogging = true; } // setup the debug environment if (configuration.debug) { platform.isCompatible = true; } if (configuration.onInit) { configuration.onInit.call(this); } doLog('decide to show: autoStart: ' + configuration.startAutomatically + ' ### beforeInstallPromptEvent: ' + beforeInstallPromptEvent + ' ### showNativePrompt: ' + showNativePrompt); if (configuration.startAutomatically && !!beforeInstallPromptEvent) { doLog('autoStart - displaying call-out'); show(); } else if (!showNativePrompt) { doLog('not showing native prompt - displaying call-out'); show(); } else { doLog('did decide to show nothing'); } } function doLog(logString) { if (configuration.activateLogging) { console.log('Add to Homescreen: ' + logString); } } function updateSession() { window.localStorage.setItem(configuration.appId, JSON.stringify(session)); } function checkPlatform() { // browser info and capability let userAgent = window.navigator.userAgent; doLog('checking platform - found user agent: ' + userAgent); platform.isIDevice = (/iphone|ipod|ipad/i).test(userAgent); platform.isSamsung = /Samsung/i.test(userAgent); platform.isFireFox = /Firefox/i.test(userAgent); platform.isOpera = /opr/i.test(userAgent); platform.isEdge = /edg/i.test(userAgent); // Opera & FireFox only Trigger on Android if (platform.isFireFox) { platform.isFireFox = /android/i.test(userAgent); } if (platform.isOpera) { platform.isOpera = /android/i.test(userAgent); } platform.isChromium = ('onbeforeinstallprompt' in window); platform.isInWebAppiOS = (window.navigator.standalone === true); platform.isInWebAppChrome = (window.matchMedia('(display-mode: standalone)').matches); platform.isMobileSafari = platform.isIDevice && userAgent.indexOf('Safari') > -1 && userAgent.indexOf('CriOS') < 0; platform.isStandalone = platform.isInWebAppiOS || platform.isInWebAppChrome; platform.isiPad = (platform.isMobileSafari && userAgent.indexOf('iPad') > -1); platform.isiPhone = (platform.isMobileSafari && userAgent.indexOf('iPad') === -1); platform.isCompatible = (platform.isChromium || platform.isMobileSafari || platform.isSamsung || platform.isFireFox || platform.isOpera); } function getPlatform(native) { if (configuration.debug && typeof configuration.debug === 'string') { return 'native'; } if (platform.isChromium && native === undefined) { return 'native'; } else if (platform.isFireFox) { return 'firefox'; } else if (platform.isiPad) { return 'ipad'; } else if (platform.isiPhone) { return 'iphone'; } else if (platform.isOpera) { return 'opera'; } else if (platform.isSamsung) { return 'samsung'; } else if (platform.isEdge) { return 'edge'; } else if (platform.isChromium) { return 'chromium'; } else { return ''; } } function handleBeforeInstallPrompt(event) { event.preventDefault(); doLog('capturing the native A2HS prompt'); beforeInstallPromptEvent = event; delayedShow(); } function delayedShow() { setTimeout(performShow, configuration.startDelay * 1000 + 500); } function show() { // message already on screen if (isAthDialogShown) { doLog('not displaying call-out because already shown on screen'); return; } isAthDialogShown = true; if (document.readyState === 'interactive' || document.readyState === 'complete') { delayedShow(); } else { document.onreadystatechange = function () { if (document.readyState === 'complete') { delayedShow(); } }; } } function performShow() { if (canPrompt()) { if (beforeInstallPromptEvent && !configuration.mustShowCustomPrompt) { doLog('show native prompt'); triggerNativePrompt(); } else { let target = getPlatform(); let athWrapper = document.querySelector(`.${ configuration.customPromptElements.container }`); doLog(`show generic prompt for platform ${ target }`); if (athWrapper && !session.optedOut) { athWrapper.classList.remove(configuration.hideClass); let promptTarget = Object.assign({}, DEFAULT_PROMPT, configuration.customPromptContent, configuration.customPromptPlatformDependencies[target]); if (promptTarget.showClasses) { promptTarget.showClasses = promptTarget.showClasses.concat(configuration.showClasses); } else { promptTarget.showClasses = configuration.showClasses; } for (let index = 0; index < promptTarget.showClasses.length; index++) { athWrapper.classList.add(promptTarget.showClasses[index]); } let promptDialogTitle = athWrapper.querySelector(`.${ configuration.customPromptElements.title }`); let promptDialogLogo = athWrapper.querySelector(`.${ configuration.customPromptElements.logo }`); let promptDialogCancelButton = athWrapper.querySelector(`.${ configuration.customPromptElements.cancelButton }`); let promptDialogInstallButton = athWrapper.querySelector(`.${ configuration.customPromptElements.installButton }`); if (promptDialogTitle && promptTarget.title) { promptDialogTitle.innerText = promptTarget.title; } if (promptDialogLogo) { if (promptTarget.src) { promptDialogLogo.src = promptTarget.src; promptDialogLogo.alt = promptTarget.title || 'Install application'; } else { promptDialogLogo.remove(); } } if (promptDialogInstallButton) { promptDialogInstallButton.addEventListener('click', handleInstall); promptDialogInstallButton.classList.remove(configuration.hideClass); promptDialogInstallButton.innerText = promptTarget.installMsg != null ? promptTarget.installMsg : ((promptTarget.action && promptTarget.action.ok) ? promptTarget.action.ok : ''); } if (promptDialogCancelButton) { promptDialogCancelButton.addEventListener('click', cancelPrompt); promptDialogCancelButton.classList.remove(configuration.hideClass); promptDialogCancelButton.innerText = promptTarget.cancelMsg != null ? promptTarget.cancelMsg : ((promptTarget.action && promptTarget.action.cancel) ? promptTarget.action.cancel : ''); } } if (configuration.lifespan && configuration.lifespan > 0) { autoHideTimer = setTimeout(autoHide, configuration.lifespan * 1000); } } // fire the custom onShow event if (configuration.onShow) { configuration.onShow.call(this); } // increment the display count session.lastDisplayTime = Date.now(); session.displayCount++; updateSession(); } } function canPrompt() { if (canPromptState !== undefined) { // already evaluated the situation, so don't do it again doLog('canPrompt() already evaluated: ' + canPromptState.toString()); return canPromptState; } canPromptState = false; if (configuration.customCriteria !== null) { let passCustom = typeof configuration.customCriteria === 'function' ? configuration.customCriteria() : !!configuration.customCriteria; if (!passCustom) { doLog('not displaying call-out because a custom criteria was not met.'); return false; } } // using a double negative here to detect if service workers are not supported // if not then don't bother asking to add to install the PWA if (!('serviceWorker' in navigator)) { doLog('not displaying call-out because service workers are not supported'); return false; } // the device is not supported if (!platform.isCompatible) { doLog('not displaying call-out because device not supported'); doLog('platform: ' + JSON.stringify(platform)); return false; } let now = Date.now(); let lastDisplayTime = session.lastDisplayTime; // we obey the display pace (prevent the message to popup too often) if (now - lastDisplayTime < configuration.displayPace * 60000) { doLog('not displaying call-out because displayed recently'); return false; } // obey the maximum number of display count if (configuration.maxDisplayCount && session.displayCount >= configuration.maxDisplayCount) { doLog('not displaying call-out because displayed too many times already'); return false; } // check if this is a valid location // TODO: should include at least the home page here // by default all pages are valid, which can cause issues on iOS // TODO: maybe trigger a redirect back to the home page for iOS let isValidLocation = !configuration.validLocation.length; for (let i = configuration.validLocation.length; i--;) { if (configuration.validLocation[i].test(document.location.href)) { isValidLocation = true; break; } } if (!isValidLocation) { doLog('not displaying call-out because not a valid location'); return false; } let isGuidanceURL = false; for (let i = guidanceTargetUrls.length; i--;) { if (document.location.href.indexOf(guidanceTargetUrls[i]) > -1) { isGuidanceURL = true; break; } } if (isGuidanceURL) { doLog('not displaying call-out because this is a guidance URL'); return false; } if (session.pageViews < configuration.minPageViews) { doLog('not displaying call-out because not enough visits'); return false; } // critical errors: if (session.optedOut) { doLog('not displaying call-out because user opted out'); return false; } if (session.added) { doLog('not displaying call-out because already added to the home screen'); return false; } // check if the app is in stand alone mode // this applies to iOS if (platform.isStandalone) { // execute the onAdd event if we haven't already if (!session.added) { session.added = true; updateSession(); if (configuration.onAdd) { configuration.onAdd.call(this); } } doLog('not displaying call-out because in standalone mode'); return false; } // check if this is a returning visitor if (!session.returningVisitor) { session.returningVisitor = true; updateSession(); // we do not show the message if this is your first visit if (configuration.skipFirstVisit) { doLog('not displaying call-out because skipping first visit'); return false; } } canPromptState = true; return true; } /* displays native A2HS prompt & stores results */ function triggerNativePrompt() { return beforeInstallPromptEvent.prompt() .then(function () { // Wait for the user to respond to the prompt return beforeInstallPromptEvent.userChoice; }) .then(function (choiceResult) { session.added = (choiceResult.outcome === 'accepted'); if (session.added) { doLog('user accepted the A2HS prompt'); if (configuration.onAdd) { configuration.onAdd(); } } else { if (configuration.onCancel) { configuration.onCancel(); } session.optedOut = true; doLog('user dismissed the A2HS prompt'); } updateSession(); beforeInstallPromptEvent = null; }) .catch(function (err) { doLog(err.message); if (err.message.indexOf('user gesture') > -1) { configuration.mustShowCustomPrompt = true; delayedShow(); } else if (err.message.indexOf('The app is already installed') > -1) { doLog(err.message); session.added = true; updateSession(); } else { doLog(err); return err; } }); } function cancelPrompt(event) { event.preventDefault(); if (configuration.onCancel) { configuration.onCancel(); } closePrompt(); return false; } function closePrompt() { let athWrapper = document.querySelector(`.${ configuration.customPromptElements.container }`); if (athWrapper) { let target = getPlatform(); let promptTarget = configuration.customPromptPlatformDependencies[target]; promptTarget.showClasses = promptTarget.showClasses.concat(configuration.showClasses); athWrapper.classList.remove(...promptTarget.showClasses); } } function handleInstall() { if (configuration.onInstall) { configuration.onInstall(); } if (beforeInstallPromptEvent && (!configuration.debug || getPlatform() === 'native')) { closePrompt(); triggerNativePrompt(); } else { showPlatformGuidance(true); } return false; } function autoHide() { let athWrapper = document.querySelector(`.${ configuration.customPromptElements.container }`); closePrompt(); if (athWrapper) { athWrapper.classList.add(configuration.hideClass); } } return ( <div className={ `${ configuration.customPromptElements.container } ${ configuration.customPromptElements.containerAddOns }` }> <div className={ `${ configuration.customPromptElements.banner } ${ configuration.customPromptElements.bannerAddOns }` }> <div className={ `${ configuration.customPromptElements.logoCell } ${ configuration.customPromptElements.logoCellAddOns }` }> <img alt="Application Logo" className={ `${ configuration.customPromptElements.logo } ${ configuration.customPromptElements.logoAddOns }` }/> </div> <div className={ `${ configuration.customPromptElements.titleCell } ${ configuration.customPromptElements.titleCellAddOns }` }> <div className={ `${ configuration.customPromptElements.title } ${ configuration.customPromptElements.titleAddOns }` }/> </div> <div className={ `${ configuration.customPromptElements.cancelButtonCell } ${ configuration.customPromptElements.cancelButtonCellAddOns }` }> <button className={ `${ configuration.customPromptElements.cancelButton } ${ configuration.customPromptElements.cancelButtonAddOns }` }>Not Now</button> </div> <div className={ `${ configuration.customPromptElements.installButtonCell } ${ configuration.customPromptElements.installButtonCellAddOns }` }> <button className={ `${ configuration.customPromptElements.installButton } ${ configuration.customPromptElements.installButtonAddOns }` }>Install</button> </div> </div> <div className={ `${ configuration.customPromptElements.guidance } ${ configuration.customPromptElements.guidanceAddOns }` }> <div className={ `${ configuration.customPromptElements.guidanceImageCell } ${ configuration.customPromptElements.guidanceImageCellAddOns }` }/> <div className={ `${ configuration.customPromptElements.cancelButtonCell } ${ configuration.customPromptElements.cancelButtonCellAddOns }` }> <button className={ `${ configuration.customPromptElements.guidanceCancelButton } ${ configuration.customPromptElements.guidanceCancelButtonAddOns }` }>Close</button> </div> </div> </div> ); } const platformPropType = PropTypes.shape({ showClasses: PropTypes.arrayOf(PropTypes.string), targetUrl: PropTypes.string, images: PropTypes.arrayOf(PropTypes.shape({ src: PropTypes.string, alt: PropTypes.string })), action: PropTypes.shape({ ok: PropTypes.string, cancel: PropTypes.string, guidanceCancel: PropTypes.string }) }); AddToHomeScreen.propTypes = { appId: PropTypes.string, debug: PropTypes.string, activateLogging: PropTypes.bool, startAutomatically: PropTypes.bool, skipFirstVisit: PropTypes.bool, minPageViews: PropTypes.number, startDelay: PropTypes.number, lifespan: PropTypes.number, displayPace: PropTypes.number, mustShowCustomPrompt: PropTypes.bool, maxDisplayCount: PropTypes.number, validLocation: PropTypes.arrayOf(PropTypes.string), onInit: PropTypes.func, onShow: PropTypes.func, onAdd: PropTypes.func, onInstall: PropTypes.func, onCancel: PropTypes.func, showClasses: PropTypes.arrayOf(PropTypes.string), showClass: PropTypes.string, hideClass: PropTypes.string, customCriteria: PropTypes.func, customPromptContent: PropTypes.shape({ title: PropTypes.string, src: PropTypes.string, cancelMsg: PropTypes.string, installMsg: PropTypes.string, guidanceCancelMsg: PropTypes.string }), customPromptElements: PropTypes.shape({ container: PropTypes.string, containerAddOns: PropTypes.string, banner: PropTypes.string, bannerAddOns: PropTypes.string, logoCell: PropTypes.string, logoCellAddOns: PropTypes.string, logo: PropTypes.string, logoAddOns: PropTypes.string, titleCell: PropTypes.string, titleCellAddOns: PropTypes.string, title: PropTypes.string, titleAddOns: PropTypes.string, cancelButtonCell: PropTypes.string, cancelButtonCellAddOns: PropTypes.string, cancelButton: PropTypes.string, cancelButtonAddOns: PropTypes.string, installButtonCell: PropTypes.string, installButtonCellAddOns: PropTypes.string, installButton: PropTypes.string, installButtonAddOns: PropTypes.string, guidance: PropTypes.string, guidanceAddOns: PropTypes.string, guidanceImageCell: PropTypes.string, guidanceImageCellAddOns: PropTypes.string, guidanceCancelButton: PropTypes.string, guidanceCancelButtonAddOns: PropTypes.string }), customPromptPlatformDependencies: PropTypes.shape({ native: platformPropType, chromium: platformPropType, edge: platformPropType, iphone: platformPropType, ipad: platformPropType, firefox: platformPropType, samsung: platformPropType, opera: platformPropType }) };