{uploadingImage && (
@@ -228,7 +230,6 @@ export const ImageUploader = () => {
) : (
', () => {
await findByText(/some fake error/i);
});
- describe('when rendered in native iOS with imageUpload support', () => {
+ describe('when rendered in native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
- return namespace === 'imageUpload';
+ return namespace === 'imageUpload_disabled';
}),
};
});
- it('should have no a11y violations when native iOS imageUpload support is available', async () => {
+ it('should have no a11y violations when native iOS imageUpload_disabled support is available', async () => {
const { container } = render(
', () => {
});
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
- global.window.webkit = {
- messageHandlers: {
- imageUpload: {
- postMessage: jest.fn(),
- },
- },
- };
+ global.window.ForemMobile = { injectNativeMessage: jest.fn() };
- const { queryByLabelText } = render(
);
+ const { queryByLabelText } = render(
+
,
+ );
const uploadButton = queryByLabelText(/Upload cover image/i);
uploadButton.click();
expect(
- window.webkit.messageHandlers.imageUpload.postMessage,
+ global.window.ForemMobile.injectNativeMessage,
).toHaveBeenCalledTimes(1);
});
describe('when an image is uploaded', () => {
it('successfully uploads an image', async () => {
- const onMainImageUrlChange = jest.fn();
- const { container } = render(
+ const onMainImageUrlChangeSpy = jest.fn();
+ render(
,
);
- const nativeInput = container.querySelector(
- '#native-cover-image-upload-message',
- );
// Fire a change event in the hidden input with JSON payload for success
- const fakeSuccessMessage = `{ "action": "success", "link": "/some-fake-image.jpg" }`;
- fireEvent.change(nativeInput, {
- target: { value: fakeSuccessMessage },
+ const fakeSuccessMessage = JSON.stringify({
+ action: 'success',
+ link: '/some-fake-image.jpg',
+ namespace: 'coverUpload',
});
+ const event = createEvent(
+ 'ForemMobile',
+ document,
+ { detail: fakeSuccessMessage },
+ { EventType: 'CustomEvent' },
+ );
+ fireEvent(document, event);
- expect(onMainImageUrlChange).toHaveBeenCalledTimes(1);
+ expect(onMainImageUrlChangeSpy).toHaveBeenCalledTimes(1);
});
it('displays an upload error when necessary', async () => {
const onMainImageUrlChange = jest.fn();
+ /* eslint-disable no-unused-vars */
const { container, findByText } = render(
,
);
- const nativeInput = container.querySelector(
- '#native-cover-image-upload-message',
- );
+ /* eslint-enable no-unused-vars */
const error = 'oh no!';
// Fire a change event in the hidden input with JSON payload for an error
- const fakeErrorMessage = JSON.stringify({ action: 'error', error });
- fireEvent.change(nativeInput, {
- target: { value: fakeErrorMessage },
+ const fakeErrorMessage = JSON.stringify({
+ action: 'error',
+ error,
+ namespace: 'coverUpload',
});
+ const event = createEvent(
+ 'ForemMobile',
+ document,
+ { detail: fakeErrorMessage },
+ { EventType: 'CustomEvent' },
+ );
+ fireEvent(document, event);
await findByText(error);
@@ -247,21 +256,27 @@ describe('
', () => {
it('displays an uploading message', async () => {
const onMainImageUrlChange = jest.fn();
+ /* eslint-disable no-unused-vars */
const { container, findByText } = render(
,
);
- const nativeInput = container.querySelector(
- '#native-cover-image-upload-message',
- );
+ /* eslint-enable no-unused-vars */
// Fire a change event in the hidden input with JSON payload for an error
- const fakeUploadingMessage = JSON.stringify({ action: 'uploading' });
- fireEvent.change(nativeInput, {
- target: { value: fakeUploadingMessage },
+ const fakeUploadingMessage = JSON.stringify({
+ action: 'uploading',
+ namespace: 'coverUpload',
});
+ const event = createEvent(
+ 'ForemMobile',
+ document,
+ { detail: fakeUploadingMessage },
+ { EventType: 'CustomEvent' },
+ );
+ fireEvent(document, event);
await findByText(/Uploading.../i);
diff --git a/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx b/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
index 3e8b5eabc..37e917a1a 100644
--- a/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
+++ b/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
@@ -3,6 +3,7 @@ import {
render,
fireEvent,
waitForElementToBeRemoved,
+ createEvent,
} from '@testing-library/preact';
import { axe } from 'jest-axe';
import fetch from 'jest-fetch-mock';
@@ -33,11 +34,11 @@ describe('
', () => {
expect(uploadInput.getAttribute('type')).toEqual('file');
});
- describe('when rendered in native iOS with imageUpload support', () => {
+ describe('when rendered in native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
- return namespace === 'imageUpload';
+ return namespace === 'imageUpload_disabled';
}),
};
});
@@ -48,31 +49,32 @@ describe('
', () => {
});
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
- global.window.webkit = {
- messageHandlers: {
- imageUpload: {
- postMessage: jest.fn(),
- },
- },
- };
+ global.window.ForemMobile = { injectNativeMessage: jest.fn() };
const { queryByLabelText } = render(
);
const uploadButton = queryByLabelText(/Upload an image/i);
uploadButton.click();
expect(
- window.webkit.messageHandlers.imageUpload.postMessage,
+ global.window.ForemMobile.injectNativeMessage,
).toHaveBeenCalledTimes(1);
});
it('handles a native bridge message correctly', async () => {
- const { container, findByTitle } = render(
);
- const nativeInput = container.querySelector(
- '#native-image-upload-message',
- );
+ const { container, findByTitle } = render(
); // eslint-disable-line no-unused-vars
// Fire a change event in the hidden input with JSON payload for success
- const fakeSuccessMessage = `{ "action": "success", "link": "/some-fake-image.jpg" }`;
- fireEvent.change(nativeInput, { target: { value: fakeSuccessMessage } });
+ const fakeSuccessMessage = JSON.stringify({
+ action: 'success',
+ link: '/some-fake-image.jpg',
+ namespace: 'imageUpload',
+ });
+ const event = createEvent(
+ 'ForemMobile',
+ document,
+ { detail: fakeSuccessMessage },
+ { EventType: 'CustomEvent' },
+ );
+ fireEvent(document, event);
expect(await findByTitle(/copy markdown for image/i)).toBeDefined();
});
diff --git a/app/javascript/mobile/foremMobile.js b/app/javascript/mobile/foremMobile.js
new file mode 100644
index 000000000..bc4b7461b
--- /dev/null
+++ b/app/javascript/mobile/foremMobile.js
@@ -0,0 +1,88 @@
+/* global Runtime */
+import { request } from '@utilities/http';
+
+export function foremMobileNamespace() {
+ return {
+ retryDelayMs: 700,
+ getUserData() {
+ return document.body.dataset.user;
+ },
+ getInstanceMetadata() {
+ return JSON.stringify({
+ name: document.querySelector("meta[property='forem:name']")['content'],
+ logo: document.querySelector("meta[property='forem:logo']")['content'],
+ domain: document.querySelector("meta[property='forem:domain']")[
+ 'content'
+ ],
+ });
+ },
+ registerDeviceToken(token, appBundle, platform) {
+ request(`/users/devices`, {
+ method: 'POST',
+ body: { token, platform, app_bundle: appBundle },
+ credentials: 'same-origin',
+ })
+ .then((response) => response.json())
+ .then((response) => {
+ if (
+ !isNaN(parseInt(response.id, 10)) &&
+ response.error == undefined
+ ) {
+ // Clear the interval if the registration succeeded
+ clearInterval(window.ForemMobile.deviceRegistrationInterval);
+ window.ForemMobile.retryDelayMs = 700;
+ } else {
+ // Registration failed - throw and log error message
+ throw new Error(response.error);
+ }
+ })
+ .catch((error) => {
+ Honeybadger.notify(error);
+
+ // Increase backoff delay time
+ if (window.ForemMobile.retryDelayMs < 20000) {
+ window.ForemMobile.retryDelayMs =
+ window.ForemMobile.retryDelayMs * 2;
+ }
+
+ // Attempt to register again after delay
+ setTimeout(() => {
+ window.ForemMobile.registerDeviceToken(token, appBundle, platform);
+ }, window.ForemMobile.retryDelayMs);
+ });
+ },
+ unregisterDeviceToken(userId, token, appBundle, platform) {
+ request(`/users/devices/${userId}`, {
+ method: 'DELETE',
+ body: { token, platform, app_bundle: appBundle },
+ credentials: 'same-origin',
+ });
+ },
+ injectJSMessage(message) {
+ const event = new CustomEvent('ForemMobile', { detail: message });
+ document.dispatchEvent(event);
+ },
+ injectNativeMessage(namespace, message) {
+ try {
+ if (Runtime.isNativeIOS(namespace)) {
+ window.webkit.messageHandlers[namespace].postMessage(message);
+ } else if (Runtime.isNativeAndroid(namespace)) {
+ AndroidBridge[`${namespace}Message`](JSON.stringify(message));
+ }
+ } catch (error) {
+ Honeybadger.notify(error);
+ }
+ },
+ userSessionBroadcast() {
+ const currentUser = document.body.dataset.user;
+ if (currentUser) {
+ window.ForemMobile.injectNativeMessage(
+ 'userLogin',
+ JSON.parse(currentUser),
+ );
+ } else {
+ window.ForemMobile.injectNativeMessage('userLogout', {});
+ }
+ },
+ };
+}
diff --git a/app/javascript/packs/base.jsx b/app/javascript/packs/base.jsx
index abeb6641a..0c91bba0a 100644
--- a/app/javascript/packs/base.jsx
+++ b/app/javascript/packs/base.jsx
@@ -1,9 +1,10 @@
+/* global Runtime */
import {
initializeMobileMenu,
setCurrentPageIconLink,
- getInstantClick,
initializeMemberMenu,
} from '../topNavigation/utilities';
+import { waitOnBaseData } from '../utilities/waitOnBaseData';
// Unique ID applied to modals created using window.Forem.showModal
const WINDOW_MODAL_ID = 'window-modal';
@@ -136,11 +137,26 @@ if (memberMenu) {
initializeMemberMenu(memberMenu, menuNavButton);
}
-getInstantClick().then((spa) => {
- spa.on('change', () => {
- initializeNav();
+// Initialize when asset pipeline (sprockets) initializers have executed
+waitOnBaseData()
+ .then(() => {
+ InstantClick.on('change', () => {
+ initializeNav();
+ });
+
+ if (Runtime.currentMedium() === 'ForemWebView') {
+ // Dynamic import of the namespace
+ import('../mobile/foremMobile.js').then((module) => {
+ // Load the namespace
+ window.ForemMobile = module.foremMobileNamespace();
+ // Run the first session
+ window.ForemMobile.userSessionBroadcast();
+ });
+ }
+ })
+ .catch((error) => {
+ Honeybadger.notify(error);
});
-});
initializeNav();
diff --git a/app/javascript/packs/runtimeBanner.jsx b/app/javascript/packs/runtimeBanner.jsx
index 041f14fec..2f8c00903 100644
--- a/app/javascript/packs/runtimeBanner.jsx
+++ b/app/javascript/packs/runtimeBanner.jsx
@@ -1,5 +1,6 @@
import { h, render } from 'preact';
import { RuntimeBanner } from '../runtimeBanner';
+import { waitOnBaseData } from '../utilities/waitOnBaseData';
function loadElement() {
const container = document.getElementById('runtime-banner-container');
@@ -8,25 +9,19 @@ function loadElement() {
}
}
-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();
+waitOnBaseData()
+ .then(() => {
+ window.InstantClick.on('change', () => {
+ loadElement();
+ });
+
+ loadElement();
+ })
+ .catch((error) => {
+ Honeybadger.notify(error);
+ });
diff --git a/app/javascript/utilities/waitOnBaseData.js b/app/javascript/utilities/waitOnBaseData.js
new file mode 100644
index 000000000..d362014c6
--- /dev/null
+++ b/app/javascript/utilities/waitOnBaseData.js
@@ -0,0 +1,20 @@
+/**
+ * A util function to wrap any code that needs to wait until the page has
+ * initialized correctly before executing. This is generally the case for
+ * packs/components that require `/app/assets/initializers` to execute first,
+ * this way you're ensured that global functions/namespaces will be available
+ * (i.e. the Runtime class).
+ *
+ * @returns {Promise} A chainable promise that will fulfill when the page has
+ * loaded correctly and all initializers have run.
+ */
+export function waitOnBaseData() {
+ return new Promise((resolve) => {
+ const waitingForDataLoad = setInterval(() => {
+ if (document.body.getAttribute('data-loaded') === 'true') {
+ clearInterval(waitingForDataLoad);
+ resolve();
+ }
+ }, 100);
+ });
+}
diff --git a/app/models/consumer_app.rb b/app/models/consumer_app.rb
index 39e1a463c..ad16dfaa6 100644
--- a/app/models/consumer_app.rb
+++ b/app/models/consumer_app.rb
@@ -2,7 +2,7 @@ class ConsumerApp < ApplicationRecord
resourcify
FOREM_BUNDLE = "com.forem.app".freeze
- FOREM_APP_PLATFORMS = %w[ios].freeze
+ FOREM_APP_PLATFORMS = %w[ios android].freeze
FOREM_TEAM_ID = "R9SWHSQNV8".freeze
enum platform: { android: Device::ANDROID, ios: Device::IOS }
@@ -35,8 +35,10 @@ class ConsumerApp < ApplicationRecord
# custom PN targets will get their credentials from the auth_key stored in
# the DB (configured by the Forem creator).
def auth_credentials
- if forem_app?
+ if forem_app? && ios?
ApplicationConfig["RPUSH_IOS_PEM"]
+ elsif forem_app? && android?
+ ApplicationConfig["RPUSH_FCM_KEY"]
else
auth_key
end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 4cf1a6a78..7a0596ee1 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -18,12 +18,11 @@
<% end %>
<%= render "layouts/styles", qualifier: "main" %>
- <% unless user_signed_in? %>
- <%= 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", "runtimeBanner", "onboardingRedirectCheck", "contentDisplayPolicy", defer: true %>
+ <% else %>
+ <%= javascript_packs_with_chunks_tag "base", "Search", "runtimeBanner", defer: true %>
<% end %>
<%= yield(:page_meta) %>
diff --git a/cypress/integration/seededFlows/mobileFlows/namespacedForemMobile.spec.js b/cypress/integration/seededFlows/mobileFlows/namespacedForemMobile.spec.js
new file mode 100644
index 000000000..b9882a0a2
--- /dev/null
+++ b/cypress/integration/seededFlows/mobileFlows/namespacedForemMobile.spec.js
@@ -0,0 +1,129 @@
+// This E2E test focuses on ensuring the mobile bridge integration
+describe('Namespaced ForemMobile functions', () => {
+ function waitForBaseDataLoaded() {
+ cy.get('body').should('have.attr', 'data-loaded', 'true');
+ }
+
+ // Make the runtime act as ForemWebView context
+ 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) Mobile/15E148 ForemWebView/1.0',
+ });
+ Object.defineProperty(win.navigator, 'platform', { value: 'iPhone' });
+ },
+ };
+
+ describe('within ForemWebView context', () => {
+ describe('when logged in', () => {
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/adminUser.json').as('user');
+ cy.get('@user').then((user) => {
+ cy.loginUser(user).then(() => {
+ cy.visit('/', runtimeStub);
+ cy.get('body').should('have.attr', 'data-user-status', 'logged-in');
+ waitForBaseDataLoaded();
+ });
+ });
+ });
+
+ it('should load the ForemMobile namespaced functions', () => {
+ cy.window().then((win) => {
+ assert.isNumber(win.ForemMobile.retryDelayMs);
+ assert.isFunction(win.ForemMobile.getUserData);
+ assert.isFunction(win.ForemMobile.getInstanceMetadata);
+ assert.isFunction(win.ForemMobile.registerDeviceToken);
+ assert.isFunction(win.ForemMobile.unregisterDeviceToken);
+ assert.isFunction(win.ForemMobile.injectJSMessage);
+ assert.isFunction(win.ForemMobile.injectNativeMessage);
+ assert.isFunction(win.ForemMobile.userSessionBroadcast);
+ });
+ });
+
+ it('should return the instance metadata JSON when requested', () => {
+ cy.window().then((win) => {
+ var res = JSON.parse(win.ForemMobile.getInstanceMetadata());
+ assert.isObject(res);
+ assert.equal(res.domain, 'localhost:3000');
+ assert.isString(res.logo);
+ assert.isString(res.name);
+ });
+ });
+
+ it('should return user data when logged in', () => {
+ cy.window().then((win) => {
+ cy.fixture('users/adminUser.json').as('user');
+ cy.get('@user').then((expected) => {
+ const actual = JSON.parse(win.ForemMobile.getUserData());
+
+ expect(actual.username).to.equal(expected.username);
+ });
+ });
+ });
+
+ it('should inject messages using CustomEvent', () => {
+ cy.document()
+ .then((doc) => {
+ doc.addEventListener('ForemMobile', cy.stub().as('bridgeEvent'));
+ })
+ .then(() => cy.window())
+ .then((win) => {
+ win.ForemMobile.injectJSMessage({ action: 'test' });
+ });
+
+ // on load the app should have sent an event
+ cy.get('@bridgeEvent')
+ .should('have.been.calledOnce')
+ .its('firstCall.args.0.detail')
+ .should('deep.equal', { action: 'test' });
+ });
+ });
+
+ describe('when logged out', () => {
+ it('should return empty user data when logged out', () => {
+ cy.testSetup();
+ cy.visit('/', runtimeStub);
+ waitForBaseDataLoaded();
+
+ cy.window().then((win) => {
+ assert.isUndefined(win.ForemMobile.getUserData());
+ });
+ });
+
+ it('should inject messages using CustomEvent', () => {
+ cy.document()
+ .then((doc) => {
+ doc.addEventListener('ForemMobile', cy.stub().as('bridgeEvent'));
+ })
+ .then(() => cy.window())
+ .then((win) => {
+ win.ForemMobile.injectJSMessage({ action: 'test' });
+ });
+
+ // on load the app should have sent an event
+ cy.get('@bridgeEvent')
+ .should('have.been.calledOnce')
+ .its('firstCall.args.0.detail')
+ .should('deep.equal', { action: 'test' });
+ });
+ });
+ });
+
+ describe('Non-mobile', () => {
+ it('should not load namespaced functions on other contexts', () => {
+ cy.testSetup();
+ cy.visit('/');
+ waitForBaseDataLoaded();
+
+ cy.get('body')
+ .invoke('attr', 'data-runtime')
+ .should('contain', 'Browser-');
+
+ cy.window().then((win) => {
+ assert.isUndefined(win.ForemMobile);
+ });
+ });
+ });
+});
diff --git a/cypress/integration/seededFlows/loggedOutFlows/runtimeBannerDeepLinking.spec.js b/cypress/integration/seededFlows/mobileFlows/runtimeBannerDeepLinking.spec.js
similarity index 100%
rename from cypress/integration/seededFlows/loggedOutFlows/runtimeBannerDeepLinking.spec.js
rename to cypress/integration/seededFlows/mobileFlows/runtimeBannerDeepLinking.spec.js
diff --git a/cypress/integration/seededFlows/loggedOutFlows/runtimeContext.spec.js b/cypress/integration/seededFlows/mobileFlows/runtimeContext.spec.js
similarity index 100%
rename from cypress/integration/seededFlows/loggedOutFlows/runtimeContext.spec.js
rename to cypress/integration/seededFlows/mobileFlows/runtimeContext.spec.js
diff --git a/spec/models/consumer_app_spec.rb b/spec/models/consumer_app_spec.rb
index e177d1475..55a2deabc 100644
--- a/spec/models/consumer_app_spec.rb
+++ b/spec/models/consumer_app_spec.rb
@@ -41,9 +41,11 @@ RSpec.describe ConsumerApp, type: :model do
platform: ConsumerApp::FOREM_APP_PLATFORMS.sample,
)
allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return("asdf123")
+ allow(ApplicationConfig).to receive(:[]).with("RPUSH_FCM_KEY").and_return("asdf123")
expect(forem_consumer_app.operational?).to be(true)
allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return(nil)
+ allow(ApplicationConfig).to receive(:[]).with("RPUSH_FCM_KEY").and_return(nil)
expect(forem_consumer_app.operational?).to be(false)
end
end
diff --git a/spec/requests/devices_spec.rb b/spec/requests/devices_spec.rb
index 71c40864c..8c6525c60 100644
--- a/spec/requests/devices_spec.rb
+++ b/spec/requests/devices_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe "Devices", type: :request do
it "increases device count" do
post devices_path, params: {
token: "123",
- platform: "Android",
+ platform: "ios",
app_bundle: consumer_app.app_bundle
}
expect(user.devices.count).to eq(1)