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 (
+
+ );
+};
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 %>
-
+
<% 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');
+ });
});
});