diff --git a/app/assets/images/logo-forem-app.svg b/app/assets/images/logo-forem-app.svg new file mode 100644 index 000000000..2d00f3890 --- /dev/null +++ b/app/assets/images/logo-forem-app.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/javascripts/initializePage.js b/app/assets/javascripts/initializePage.js index ce7384343..17d8e4a2c 100644 --- a/app/assets/javascripts/initializePage.js +++ b/app/assets/javascripts/initializePage.js @@ -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(); diff --git a/app/assets/javascripts/initializers/initializeRuntimeBanner.js b/app/assets/javascripts/initializers/initializeRuntimeBanner.js new file mode 100644 index 000000000..e62610de1 --- /dev/null +++ b/app/assets/javascripts/initializers/initializeRuntimeBanner.js @@ -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 = '/'; + } +} diff --git a/app/assets/stylesheets/runtime-banner.scss b/app/assets/stylesheets/runtime-banner.scss index ce0487951..0e25113cb 100644 --- a/app/assets/stylesheets/runtime-banner.scss +++ b/app/assets/stylesheets/runtime-banner.scss @@ -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; } } diff --git a/app/controllers/deep_links_controller.rb b/app/controllers/deep_links_controller.rb new file mode 100644 index 000000000..efcc72c8b --- /dev/null +++ b/app/controllers/deep_links_controller.rb @@ -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 diff --git a/app/lib/url.rb b/app/lib/url.rb index af1f6f283..f8d21106c 100644 --- a/app/lib/url.rb +++ b/app/lib/url.rb @@ -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 diff --git a/app/views/deep_links/mobile.html.erb b/app/views/deep_links/mobile.html.erb new file mode 100644 index 000000000..1f8cce0ec --- /dev/null +++ b/app/views/deep_links/mobile.html.erb @@ -0,0 +1,48 @@ + + + + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d0750abe4..ae98ffb96 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -104,15 +104,21 @@ <% end %> <%= yield %> + <% if FeatureFlag.enabled?(:runtime_banner) || ENV['E2E'] == 'true' %> +
+
+ + <%= inline_svg_tag("logo-forem-app.svg", aria_hidden: true, class: "crayons-icon crayons-icon--default", width: 32, height: 32) %> + Open with the Forem app... + + +
+
+ <% end %> -<% if FeatureFlag.enabled?(:runtime_banner) %> -
-
- Open with the Forem app -
-
-<% 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" %> diff --git a/config/routes.rb b/config/routes.rb index f6c94a846..17641921e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js b/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js new file mode 100644 index 000000000..9b3de507f --- /dev/null +++ b/cypress/integration/loggedOutFlows/runtimeBannerDeepLinking.spec.js @@ -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'); + }); + }); +}); diff --git a/public/apple-app-site-association b/public/apple-app-site-association deleted file mode 100644 index 69f3076b7..000000000 --- a/public/apple-app-site-association +++ /dev/null @@ -1,9 +0,0 @@ -{ - "applinks": { - "apps": [], - "details": [{ - "appID": "R9SWHSQNV8.to.dev.ios", - "paths": ["*"] - }] - } -} \ No newline at end of file diff --git a/spec/lib/url_spec.rb b/spec/lib/url_spec.rb index dd4d4a8ab..797d0b329 100644 --- a/spec/lib/url_spec.rb +++ b/spec/lib/url_spec.rb @@ -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" } diff --git a/spec/requests/universal_links_spec.rb b/spec/requests/universal_links_spec.rb new file mode 100644 index 000000000..0455c88be --- /dev/null +++ b/spec/requests/universal_links_spec.rb @@ -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