Runtime Banner for Mobile Deep Linking (#13190)
* First version using custom schemes for iOS * Starting to take shape with /r/mobile redirect page * Wording and aasa * Adds e2e tests * Trigger CI * Tweaks to AASA * Uses external pivot domain to trigger Universal Links * Add missing external domain deep link * Fix test by enabling runtime_banner only in E2E tests * Fix URL encoding mismatch in Cypress test * banner sttyling * Cleanup unrelated changes * Add AASA tests + remove lingering Gemfile.lock changes * Fix cypress test * Remove unnecessary window global assignment * Trigger Travis * Replace querySelectorAll with querySelector * Apply suggestions from code review Co-authored-by: Vaidehi Joshi <vaidehi.sj@gmail.com> * Refactor inline comments, extract URL.deep_link logic and other review feedback * Small tweaks * Remove untrusted user-provided redirect (CodeQL suggestion) * Fix failing tests * Whoops - another test fix * Use Forem's UDL server * Extract timeoutDelay and add comment clarifying * Add target='_blank' to deep link * Add TODO comment for replacing hardcoded identifiers * Apply suggestions from code review Co-authored-by: Suzanne Aitchison <suzanne@forem.com> * Add a11y attributes Co-authored-by: Paweł Ludwiczak <ludwiczakpawel@gmail.com> Co-authored-by: Vaidehi Joshi <vaidehi.sj@gmail.com> Co-authored-by: Suzanne Aitchison <suzanne@forem.com>
This commit is contained in:
parent
f233be805b
commit
affc704c9b
13 changed files with 346 additions and 29 deletions
4
app/assets/images/logo-forem-app.svg
Normal file
4
app/assets/images/logo-forem-app.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="3" fill="#D55D5E"/>
|
||||
<path d="M16.499 4.741H7.5a.501.501 0 0 0-.5.502V6.74c0 .277.225.501.501.501H16.5a.501.501 0 0 0 .5-.501V5.243a.501.501 0 0 0-.501-.502zm0 4.839H7.5a.501.501 0 0 0-.501.501v1.498c0 .276.225.501.501.501h9a.501.501 0 0 0 .501-.501V10.08a.501.501 0 0 0-.501-.501zM7.47 14a.47.47 0 0 0-.47.469v4.062a.47.47 0 0 0 .47.469h1.562a.468.468 0 0 0 .468-.469v-4.062A.47.47 0 0 0 9.032 14H7.47z" fill="#F6CBF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 565 B |
|
|
@ -4,7 +4,7 @@
|
|||
initializeBaseTracking, initializeCommentsPage,
|
||||
initializeArticleDate, initializeArticleReactions, initNotifications,
|
||||
initializeCommentDate, initializeCommentDropdown, initializeSettings,
|
||||
initializeCommentPreview,
|
||||
initializeCommentPreview, initializeRuntimeBanner,
|
||||
initializeTimeFixer, initializeDashboardSort, initializePWAFunctionality,
|
||||
initializeEllipsisMenu, initializeArchivedPostFilter, initializeCreditsPage,
|
||||
initializeUserProfilePage, initializeProfileInfoToggle, initializePodcastPlayback,
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
function callInitializers() {
|
||||
initializeBaseTracking();
|
||||
initializeRuntimeBanner();
|
||||
initializePaymentPointers();
|
||||
initializeCommentsPage();
|
||||
initializeArticleDate();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
/* 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/dev-community/id1439094790';
|
||||
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 = '/';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,40 @@
|
|||
.runtime-banner {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(65px + env(safe-area-inset-bottom));
|
||||
z-index: var(--z-sticky);
|
||||
@import 'variables';
|
||||
@import '_mixins';
|
||||
|
||||
&__inner {
|
||||
background-color: #0b0c0c;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
.runtime-banner {
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
left: var(--su-4);
|
||||
right: var(--su-4);
|
||||
position: fixed;
|
||||
bottom: calc(var(--su-9) + env(safe-area-inset-bottom));
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
padding: var(--su-1);
|
||||
max-width: calc(100% - var(--su-4) * 2);
|
||||
z-index: var(--z-popover);
|
||||
border-radius: var(--radius);
|
||||
background: var(--snackbar-bg);
|
||||
color: var(--snackbar-color);
|
||||
font-size: var(--fs-s);
|
||||
|
||||
:any-link {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-deep-link-banner {
|
||||
margin: 30px;
|
||||
text-align: center;
|
||||
|
||||
.crayons-card {
|
||||
max-width: 500px;
|
||||
margin: 30px auto;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-deep-link-spinner {
|
||||
svg {
|
||||
margin: 30px auto;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
app/controllers/deep_links_controller.rb
Normal file
23
app/controllers/deep_links_controller.rb
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
class DeepLinksController < ApplicationController
|
||||
def mobile; end
|
||||
|
||||
# Apple Application Site Association
|
||||
def aasa
|
||||
# TODO: [@fdoxyz] Replace these hardcoded identifiers with configurations
|
||||
# creators can use to customize their Forems - `/admin/consumer_apps`
|
||||
supported_apps = ["R9SWHSQNV8.com.forem.app"]
|
||||
supported_apps << "R9SWHSQNV8.to.dev.ios" if SiteConfig.dev_to?
|
||||
render json: {
|
||||
applinks: {
|
||||
apps: supported_apps,
|
||||
details: supported_apps.map { |app_id| { appID: app_id, paths: ["/*"] } }
|
||||
},
|
||||
activitycontinuation: {
|
||||
apps: supported_apps
|
||||
},
|
||||
webcredentials: {
|
||||
apps: supported_apps
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -67,6 +67,16 @@ module URL
|
|||
ActionController::Base.helpers.image_url(image_name, host: host)
|
||||
end
|
||||
|
||||
# Creates a deep link URL (for mobile) to a page in the current Forem and it
|
||||
# relies on a UDL server to bounce back mobile users to the local `/r/mobile`
|
||||
# fallback page. More details here: https://github.com/forem/udl-server
|
||||
#
|
||||
# @param path [String] the target path to deep link
|
||||
def self.deep_link(path)
|
||||
target_path = CGI.escape(url("/r/mobile?deep_link=#{path}"))
|
||||
"https://forem-udl-server.herokuapp.com/?r=#{target_path}"
|
||||
end
|
||||
|
||||
def self.organization(organization)
|
||||
url(organization.slug)
|
||||
end
|
||||
|
|
|
|||
48
app/views/deep_links/mobile.html.erb
Normal file
48
app/views/deep_links/mobile.html.erb
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<div id="mobile-deep-link-spinner" class="flex flex-col" aria-live="polite">
|
||||
<svg class="crayons-icon crayons-spinner" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.364 5.636L16.95 7.05A7 7 0 1019 12h2a9 9 0 11-2.636-6.364z" fill="currentColor" class="grow-1"></path></svg>
|
||||
<p class="align-center">Opening the mobile app...</p>
|
||||
</div>
|
||||
|
||||
<div class="mobile-deep-link-banner invisible" role="alert">
|
||||
<div class="crayons-notice crayons-notice--warning">
|
||||
<p>
|
||||
Whoops! Did you get stuck trying to open the mobile app?
|
||||
</p>
|
||||
<p class="mt-5">
|
||||
Make sure it's installed and try again. If the problem persists contact <a href="mailto:<%= SiteConfig.email_addresses[:contact] %>"><%= SiteConfig.email_addresses[:contact] %></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="crayons-card">
|
||||
<div class="flex w-100">
|
||||
<a href="javascript:history.back()" class="crayons-btn crayons-btn--l crayons-btn--secondary grow-1 whitespace-nowrap mt-5 mx-5">
|
||||
Take me back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex w-100">
|
||||
<a id="link-to-mobile-install-retry" href="/" class="crayons-btn crayons-btn--l crayons-btn--secondary grow-1 whitespace-nowrap my-5 mx-5">
|
||||
Try again
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex w-100">
|
||||
<a id="link-to-mobile-install" href="/" class="crayons-btn crayons-btn--l grow-1 whitespace-nowrap mb-5 mx-5">
|
||||
Install the app
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// The timeoutDelay is the amount of time (ms) the spinner will be visible for
|
||||
// users. It is just "about 3 seconds", which gives users enough time to
|
||||
// deep link into the mobile apps. It's chosen arbitrarily and can be edited
|
||||
// in the future if other values want to be used. When the time is up the
|
||||
// fallback options are revealed ("take me back", "try again" & "install").
|
||||
const timeoutDelay = 3333;
|
||||
setTimeout(function() {
|
||||
document.getElementById('mobile-deep-link-spinner').remove();
|
||||
document.querySelectorAll('.mobile-deep-link-banner')[0].classList.remove('invisible');
|
||||
}, timeoutDelay);
|
||||
</script>
|
||||
|
|
@ -104,15 +104,21 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<%= yield %>
|
||||
<% if FeatureFlag.enabled?(:runtime_banner) || ENV['E2E'] == 'true' %>
|
||||
<div class="Browser-iOS">
|
||||
<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) %>
|
||||
<span class="ml-2">Open with the Forem app...</span>
|
||||
</a>
|
||||
<button type="button" class="runtime-banner__dismiss crayons-btn crayons-btn--ghost crayons-btn--icon crayons-btn--inverted crayons-btn--s pl-2">
|
||||
<%= inline_svg_tag("x.svg", aria: true, class: "crayons-icon", title: "Dismiss banner: Open with the Forem app") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if FeatureFlag.enabled?(:runtime_banner) %>
|
||||
<div class="Browser-iOS Browser-Android runtime-banner">
|
||||
<div class="runtime-banner__inner">
|
||||
<span>Open with the Forem app</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% unless internal_navigation? %>
|
||||
<% cache("footer-and-signup-modal-#{user_signed_in?}-#{ForemInstance.deployed_at}-#{SiteConfig.admin_action_taken_at&.rfc3339}", expires_in: 24.hours) do %>
|
||||
<%= render "layouts/footer" %>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ Rails.application.routes.draw do
|
|||
delete "/sign_out", to: "devise/sessions#destroy"
|
||||
end
|
||||
|
||||
get "/r/mobile", to: "deep_links#mobile"
|
||||
get "/.well-known/apple-app-site-association", to: "deep_links#aasa"
|
||||
|
||||
# [@forem/delightful] - all routes are nested under this optional scope to
|
||||
# begin supporting i18n.
|
||||
scope "(/locale/:locale)", defaults: { locale: nil } do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
describe('Runtime Banner Deep Linking', () => {
|
||||
const runtimeStub = {
|
||||
onBeforeLoad: (win) => {
|
||||
Object.defineProperty(win.navigator, 'userAgent', {
|
||||
value:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Mobile/15E148 Safari/604.1',
|
||||
});
|
||||
Object.defineProperty(win.navigator, 'platform', { value: 'iPhone' });
|
||||
},
|
||||
};
|
||||
beforeEach(() => {
|
||||
cy.testSetup();
|
||||
cy.visit(`/`, runtimeStub);
|
||||
});
|
||||
|
||||
it('should include the correct deep_link param in the banner link', () => {
|
||||
// When visiting the root path the banner should deep link into it
|
||||
cy.get('.runtime-banner > a')
|
||||
.should('have.attr', 'href')
|
||||
.and('contains', `deep_link%3D%2F`);
|
||||
// NOTE: %3D%2F -> '=/' (URL Encoded)
|
||||
|
||||
// When visiting `/tags` the banner should deep link into `/tags`
|
||||
cy.visit('/tags', runtimeStub).then(() => {
|
||||
cy.get('.runtime-banner > a')
|
||||
.should('have.attr', 'href')
|
||||
.and('contains', `deep_link%3D%2Ftags`);
|
||||
// NOTE: %3D%2Ftags -> '=/tags' (URL Encoded)
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// Also the loading text should not exist anymore
|
||||
cy.get('p').contains('Opening the mobile app...').should('not.exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [{
|
||||
"appID": "R9SWHSQNV8.to.dev.ios",
|
||||
"paths": ["*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +96,16 @@ RSpec.describe URL, type: :lib do
|
|||
end
|
||||
end
|
||||
|
||||
describe ".deep_link" do
|
||||
it "returns the correct URL for the root path" do
|
||||
expect(described_class.deep_link("/")).to eq("https://forem-udl-server.herokuapp.com/?r=https%3A%2F%2Fdev.to%2Fr%2Fmobile%3Fdeep_link%3D%2F")
|
||||
end
|
||||
|
||||
it "returns the correct URL for an explicit path" do
|
||||
expect(described_class.deep_link("/sloan")).to eq("https://forem-udl-server.herokuapp.com/?r=https%3A%2F%2Fdev.to%2Fr%2Fmobile%3Fdeep_link%3D%2Fsloan")
|
||||
end
|
||||
end
|
||||
|
||||
describe ".local_image" do
|
||||
let(:image_name) { "social-media-cover" }
|
||||
let(:image_extension) { ".png" }
|
||||
|
|
|
|||
39
spec/requests/universal_links_spec.rb
Normal file
39
spec/requests/universal_links_spec.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Universal Links (Apple)", type: :request do
|
||||
let(:aasa_route) { "/.well-known/apple-app-site-association" }
|
||||
let(:forem_app_id) { "R9SWHSQNV8.com.forem.app" }
|
||||
let(:dev_app_id) { "R9SWHSQNV8.to.dev.ios" }
|
||||
|
||||
describe "returns a valid Apple App Site Association file" do
|
||||
context "with DEV app backwards compatibility" do
|
||||
it "responds with applinks support for both" do
|
||||
allow(SiteConfig).to receive(:dev_to?).and_return(true)
|
||||
get aasa_route
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
both_app_ids = [forem_app_id, dev_app_id]
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response.dig("applinks", "apps")).to match_array(both_app_ids)
|
||||
json_response.dig("applinks", "details").each do |hash|
|
||||
expect(both_app_ids).to include(hash["appID"])
|
||||
expect(hash["paths"]).to match_array(["/*"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when non-DEV Forem instance" do
|
||||
it "responds with applinks support for Forem app" do
|
||||
get aasa_route
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(json_response.dig("applinks", "apps")).to match_array([forem_app_id])
|
||||
json_response.dig("applinks", "details").each do |hash|
|
||||
expect(hash["appID"]).to eq(forem_app_id)
|
||||
expect(hash["paths"]).to match_array(["/*"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue