Runtime Banner v2 (#14119)

* Banner v2 - first working version

* Move pack_with_chunks_tag up + removing comments

* Trying to fix unlrelated tests

* Revert experiment

* Defer initialization until page is ready

* Test refactor + trying out UDL Server refactor

* Revert experiment - to be handled in separate PR

* refactor to use function component

* Cleanup unused lines in cypress test

* Update app/javascript/runtimeBanner/RuntimeBanner.jsx

Co-authored-by: Nick Taylor <nick@forem.com>

* Apply review feedback

* Update app/javascript/runtimeBanner/RuntimeBanner.jsx

Co-authored-by: Nick Taylor <nick@forem.com>

* Add import clause + extract functions outside of component

Co-authored-by: Nick Taylor <nick@forem.com>
This commit is contained in:
Fernando Valverde 2021-07-14 14:44:07 -06:00 committed by GitHub
parent 16334e1eac
commit 8095fdb891
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 193 additions and 138 deletions

View file

@ -17,7 +17,6 @@
function callInitializers() {
initializeBaseTracking();
initializeRuntimeBanner();
initializePaymentPointers();
initializeCommentsPage();
initializeArticleDate();

View file

@ -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 = '/';
}
}

View file

@ -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(<RuntimeBanner />, 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();

View file

@ -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 (
<div class="runtime-banner">
<a
href={targetURL}
class="flex items-center flex-1"
target="_blank"
rel="noopener noreferrer"
>
<svg
class="crayons-icon crayons-icon--default"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.44 23.363a.5.5 0 0 1 .59.286c.712 1.893 2.765 2.915 4.713 2.393 1.938-.553 3.206-2.465 2.876-4.46a.474.474 0 0 1 .368-.543l1.628-.437a.508.508 0 0 1 .607.35l.008.032c.642 3.416-1.444 6.743-4.778 7.705-3.352.898-6.813-.909-7.933-4.196a.488.488 0 0 1 .31-.63l.032-.009 1.579-.491z"
fill="#E9F0E8"
/>
<path
d="M12.828 11.415a.5.5 0 0 1-.59-.287c-.713-1.893-2.766-2.915-4.713-2.393-1.971.562-3.206 2.465-2.877 4.461a.474.474 0 0 1-.367.543l-1.628.436a.508.508 0 0 1-.607-.35l-.009-.032c-.642-3.416 1.444-6.742 4.779-7.704 3.352-.898 6.812.908 7.933 4.196a.488.488 0 0 1-.31.63l-.032.008-1.58.492z"
fill="#4CFCA7"
/>
<path
d="m22.142 8.509-1.691.453a.508.508 0 0 1-.607-.35l-.692-2.582a.508.508 0 0 1 .35-.607l1.724-.462a.508.508 0 0 0 .35-.606l-.435-1.626a.486.486 0 0 0-.607-.35l-4.269 1.178a.508.508 0 0 0-.35.606l.563 2.105.692 2.582.692 2.582.026.096L19.81 18.7c.068.255.32.427.575.359l1.596-.428a.508.508 0 0 0 .35-.607l-1.597-5.961c-.051-.192.042-.353.234-.405l1.884-.504a.508.508 0 0 0 .35-.607l-.435-1.626c-.086-.319-.37-.482-.625-.413zm2.155-.133a.526.526 0 0 1 .255-.581c.746-.405 1.157-1.301.934-2.13-.222-.83-.993-1.408-1.834-1.354a.463.463 0 0 1-.502-.344l-.436-1.626c-.068-.255.095-.538.342-.638l.064-.017a4.412 4.412 0 0 1 4.92 3.295c.59 2.2-.511 4.476-2.605 5.345a.513.513 0 0 1-.654-.27l-.017-.063-.467-1.617z"
fill="#FBC1F5"
/>
</svg>
<div class="flex flex-col pl-3">
<span>Forem</span>
<span>Open with the Forem app</span>
</div>
</a>
<button
onClick={dismissBanner}
type="button"
class="runtime-banner__dismiss crayons-btn crayons-btn--ghost crayons-btn--icon crayons-btn--inverted crayons-btn--s"
>
<svg
class="crayons-icon"
title="Dismiss banner: Open with the Forem app"
aria="true"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</div>
);
};

View file

@ -0,0 +1 @@
export * from './RuntimeBanner';

View file

@ -19,11 +19,11 @@
<meta name="environment" content="<%= Rails.env %>">
<%= 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) %>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
@ -104,20 +104,7 @@
</div>
<% end %>
<%= yield %>
<div class="Browser-iOS-only">
<div class="runtime-banner">
<a href="<%= URL.deep_link(request.path) %>" target="_blank" class="flex items-center flex-1">
<%= inline_svg_tag("logo-forem-app.svg", aria_hidden: true, class: "crayons-icon crayons-icon--default", width: 32, height: 32) %>
<div class="flex flex-col pl-3">
<span class="">Forem</span>
<span class="">Open with the Forem app</span>
</div>
</a>
<button type="button" class="runtime-banner__dismiss crayons-btn crayons-btn--ghost crayons-btn--icon crayons-btn--inverted crayons-btn--s">
<%= inline_svg_tag("x.svg", aria: true, class: "crayons-icon", title: "Dismiss banner: Open with the Forem app") %>
</button>
</div>
</div>
<div id="runtime-banner-container"></div>
</div>
</div>
<% unless internal_navigation? %>

View file

@ -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');
});
});
});