diff --git a/app/assets/javascripts/initializePage.js b/app/assets/javascripts/initializePage.js index ba2e336d7..0c9796e77 100644 --- a/app/assets/javascripts/initializePage.js +++ b/app/assets/javascripts/initializePage.js @@ -17,7 +17,6 @@ function callInitializers() { initializeBaseTracking(); - initializeRuntimeBanner(); initializePaymentPointers(); initializeCommentsPage(); initializeArticleDate(); diff --git a/app/assets/javascripts/initializers/initializeRuntimeBanner.js b/app/assets/javascripts/initializers/initializeRuntimeBanner.js deleted file mode 100644 index 176980f56..000000000 --- a/app/assets/javascripts/initializers/initializeRuntimeBanner.js +++ /dev/null @@ -1,85 +0,0 @@ -/* global Runtime */ - -/** - * Callback that dismisses the Runtime Banner - */ -function handleDismissRuntimeBanner() { - const runtimeBanner = document.querySelector('.runtime-banner'); - if (runtimeBanner) { - runtimeBanner.remove(); - } -} - -/** - * This function redirects the browser to custom schemes, i.e. deep link into - * mobile apps. A separate function is needed in this case in order to test - * the feature using Cypress. More details can be found here: - * https://medium.com/cypress-io-thailand/understand-stub-spy-apis-that-will-make-your-test-better-than-ever-on-cypress-io-797cb9eb205a#b6ad - * - * @param {string} targetURI - The target custom scheme URI - */ -function launchCustomSchemeDeepLink(targetURI) { - window.location.href = targetURI; -} - -/** - * Function that run on every page load (including InstantClick redirects) and - * is in charge of the setup that runs the following features: - * - addEventListener to dismiss the banner when users tap on X button - * - When the user lands in "/r/mobile" - * - Timeout that presents the fallback page if couldn't seamless deep link - * - Setup the correct fallback button links based on platform (iOS/Android) - */ -function initializeRuntimeBanner() { - // This will provide the dismiss functionality for the Runtime Banner - const bannerDismiss = document.querySelector('.runtime-banner__dismiss'); - if (bannerDismiss) { - bannerDismiss.addEventListener('click', handleDismissRuntimeBanner); - } - - // If the "Install now"/"Try again" buttons exist in the DOM it means we are - // trying to deep link into the mobile app after being redirected by the - // Runtime Banner itself (the browser is currently in `/r/mobile`) - const installNowButton = document.getElementById('link-to-mobile-install'); - const retryButton = document.getElementById('link-to-mobile-install-retry'); - if (!installNowButton || !retryButton) { - return; - } - // Since we were redirected to `/r/mobile` by the Banner itself we can safely - // dismiss it by re-using the handleDismissRuntimeBanner function - handleDismissRuntimeBanner(); - - // The target path comes in via GET request query params - const urlParams = new URLSearchParams(window.location.search); - const targetPath = urlParams.get('deep_link'); - - // Constants - they will become dynamic (configurable by creators) in upcoming releases - const FOREM_IOS_SCHEME = 'com.forem.app'; - const FOREM_APP_STORE_URL = - 'https://apps.apple.com/us/app/forem/id1536933197'; - const FOREM_GOOGLE_PLAY_URL = - 'https://play.google.com/store/apps/details?id=to.dev.dev_android'; - - if (Runtime.currentOS() === 'iOS') { - // The install now must target Apple's AppStore - installNowButton.href = FOREM_APP_STORE_URL; - - // We try to deep link directly by launching a custom scheme and populate - // the retry button in case the user will need it - const targetLink = `${FOREM_IOS_SCHEME}://${window.location.host}${targetPath}`; - retryButton.href = targetLink; - launchCustomSchemeDeepLink(targetLink); - } else if (Runtime.currentOS() === 'Android') { - const targetIntent = - 'intent://scan/#Intent;scheme=zxing;package=com.google.zxing.client.android;end'; - retryButton.href = targetIntent; - installNowButton.href = FOREM_GOOGLE_PLAY_URL; - - // Android support will not be available yet. Links to `/r/mobile` won't be - // visible in Android browsers. However, as a fallback (safety) measure to - // avoid having Android users land in this page we redirect them back to the - // home page. This to avoids users landing in an unsupported (not working - // for them) page. - window.location.href = '/'; - } -} diff --git a/app/javascript/packs/runtimeBanner.jsx b/app/javascript/packs/runtimeBanner.jsx new file mode 100644 index 000000000..041f14fec --- /dev/null +++ b/app/javascript/packs/runtimeBanner.jsx @@ -0,0 +1,32 @@ +import { h, render } from 'preact'; +import { RuntimeBanner } from '../runtimeBanner'; + +function loadElement() { + const container = document.getElementById('runtime-banner-container'); + if (container) { + render(, container); + } +} + +function initializeBannerWhenPageIsReady() { + setTimeout(() => { + if (document.body.getAttribute('data-loaded') === 'true') { + // We're ready to initialize + window.InstantClick.on('change', () => { + loadElement(); + }); + + loadElement(); + } else { + // Page hasn't initialized yet. We need to wait until the page is ready + initializeBannerWhenPageIsReady(); + } + }, 100); +} + +// This pack relies on the same logic as `packs/listings` & `packs/Chat`. The +// banner lives in every page (including the main feed) and a race condition +// occurs when the page initializes for the first time or when signing out (no +// cache request). In order to avoid this we defer the initialization until the +// page is actually ready. +initializeBannerWhenPageIsReady(); diff --git a/app/javascript/runtimeBanner/RuntimeBanner.jsx b/app/javascript/runtimeBanner/RuntimeBanner.jsx new file mode 100644 index 000000000..6b82304fb --- /dev/null +++ b/app/javascript/runtimeBanner/RuntimeBanner.jsx @@ -0,0 +1,145 @@ +/* global Runtime */ + +import { h } from 'preact'; +import { useEffect } from 'preact/hooks'; + +const BANNER_DISMISS_KEY = 'runtimeBannerDismissed'; + +function dismissBanner() { + localStorage.setItem(BANNER_DISMISS_KEY, true); + removeFromDOM(); +} + +function removeFromDOM() { + const container = document.getElementById('runtime-banner-container'); + container?.remove(); +} + +function handleDeepLinkFallback() { + // These buttons should exist in the DOM in order to attempt the fallback + // mechanism (they only exist in the path `/r/mobile`) + const installNowButton = document.getElementById('link-to-mobile-install'); + const retryButton = document.getElementById('link-to-mobile-install-retry'); + if (!installNowButton || !retryButton) { + return; + } + + // The target path comes in via GET request query params + const urlParams = new URLSearchParams(window.location.search); + const targetPath = urlParams.get('deep_link'); + + // Constants - they will become dynamic (configurable by creators) in upcoming releases + const FOREM_IOS_SCHEME = 'com.forem.app'; + const FOREM_APP_STORE_URL = + 'https://apps.apple.com/us/app/forem/id1536933197'; + const FOREM_GOOGLE_PLAY_URL = + 'https://play.google.com/store/apps/details?id=to.dev.dev_android'; + + if (Runtime.currentOS() === 'iOS') { + // The install now must target Apple's AppStore + installNowButton.href = FOREM_APP_STORE_URL; + + // We try to deep link directly by launching a custom scheme and populate + // the retry button in case the user will need it + const targetLink = `${FOREM_IOS_SCHEME}://${window.location.host}${targetPath}`; + retryButton.href = targetLink; + window.location.href = targetLink; + } else if (Runtime.currentOS() === 'Android') { + const targetIntent = + 'intent://scan/#Intent;scheme=com.forem.app;package=com.forem.app;end'; + retryButton.href = targetIntent; + installNowButton.href = FOREM_GOOGLE_PLAY_URL; + + // Android support isn't available yet. Android users visiting `/r/mobile` + // will be redirected to the home page so they don't land on a unsupported + // page until this feature is ready for them. + window.location.href = '/'; + } +} + +/** + * A banner that will be displayed to provide a deep link into a specified + * ConsumerApp based on the Runtime context. If the banner is dismissed it will + * keep track of this in localStorage so it won't be rendered again. + */ +export const RuntimeBanner = () => { + useEffect(() => { + // The banner shouldn't appear if it was already dismissed or if it doesn't match the context + if ( + localStorage.getItem(BANNER_DISMISS_KEY) || + Runtime.currentContext() !== 'Browser-iOS' + ) { + removeFromDOM(); + return; + } + + // If we land on `/r/mobile` it means the automatic (i.e. Universal Links) + // deep linking didn't go through and we want to rely on the fallback + // mechanisms (i.e. custom scheme) to open the apps. + // Also, we should never render the banner in this fallback path. + if (window.location.pathname === '/r/mobile') { + removeFromDOM(); + handleDeepLinkFallback(); + return; + } + }, []); + + const targetPath = `https://${window.location.host}/r/mobile?deep_link=${window.location.pathname}`; + const targetURL = `https://udl.forem.com/?r=${encodeURIComponent( + targetPath, + )}`; + + return ( +
+ + + + + + +
+ Forem + Open with the Forem app +
+
+ +
+ ); +}; diff --git a/app/javascript/runtimeBanner/index.js b/app/javascript/runtimeBanner/index.js new file mode 100644 index 000000000..b5435db7d --- /dev/null +++ b/app/javascript/runtimeBanner/index.js @@ -0,0 +1 @@ +export * from './RuntimeBanner'; diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 95fb8ad1f..fc4ffd55e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,11 +19,11 @@ <%= render "layouts/styles", qualifier: "main" %> <% unless user_signed_in? %> - <%= javascript_packs_with_chunks_tag "base", "Search", defer: true %> + <%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", defer: true %> <% end %> <%= javascript_include_tag "base", defer: true %> <% if user_signed_in? %> - <%= javascript_packs_with_chunks_tag "base", "Search", "onboardingRedirectCheck", "contentDisplayPolicy", defer: true %> + <%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", "onboardingRedirectCheck", "contentDisplayPolicy", defer: true %> <% end %> <%= yield(:page_meta) %> @@ -104,20 +104,7 @@ <% end %> <%= yield %> -
-
- - <%= inline_svg_tag("logo-forem-app.svg", aria_hidden: true, class: "crayons-icon crayons-icon--default", width: 32, height: 32) %> -
- Forem - Open with the Forem app -
-
- -
-
+
<% unless internal_navigation? %> diff --git a/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js b/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js index 9b3de507f..88f526dd1 100644 --- a/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js +++ b/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js @@ -29,44 +29,20 @@ describe('Runtime Banner Deep Linking', () => { }); }); - it('should redirect to custom scheme when someone taps on the banner', () => { - const deepLinkPath = '/custom_path'; - const launchCustomSchemeDeepLinkStub = (url) => { - expect(url).to.eq(`com.forem.app:/${deepLinkPath}`); - }; - cy.window() - .then((win) => { - cy.stub( - win, - 'launchCustomSchemeDeepLink', - launchCustomSchemeDeepLinkStub, - ); - }) - .then(() => { - // After visiting the deep link redirect page (`/r/mobile`) it should - // immediately trigger the custom scheme, which gets caught by the stub - cy.visit(`/r/mobile?deep_link=${deepLinkPath}`); - }); - }); - it('should show a loading spinner and then the fallback page', () => { const deepLinkPath = '/custom_path2'; - cy.visit(`/r/mobile?deep_link=${deepLinkPath}`) - .then(() => { - // The loading spinner appears with the following text - cy.get('p').contains('Opening the mobile app...').should('be.visible'); - }) - .then(() => { - // After 3 seconds the following options appear visible - cy.get('p') - .contains('Whoops! Did you get stuck trying to open the mobile app?') - .should('be.visible'); - cy.get('a').contains('Take me back').should('be.visible'); - cy.get('a').contains('Try again').should('be.visible'); - cy.get('a').contains('Install the app').should('be.visible'); + cy.visit(`/r/mobile?deep_link=${deepLinkPath}`).then(() => { + // The loading spinner appears with the following text + cy.get('p').contains('Opening the mobile app...').should('be.visible'); + cy.get('p') + .contains('Whoops! Did you get stuck trying to open the mobile app?') + .should('be.visible'); + cy.get('a').contains('Take me back').should('be.visible'); + cy.get('a').contains('Try again').should('be.visible'); + cy.get('a').contains('Install the app').should('be.visible'); - // Also the loading text should not exist anymore - cy.get('p').contains('Opening the mobile app...').should('not.exist'); - }); + // Also the loading text should not exist anymore + cy.get('p').contains('Opening the mobile app...').should('not.exist'); + }); }); });