ForemMobile namespaced functions (#15212)
* window.ForemMobile namespaced functions * Fix broken Buildkite * Wait for data-loaded before using Runtime in pack * Bring back sprockets base * Better error handling * Smal fixes to ConsumerApp to support Android platform * Fix typo * utilities/waitForDataLoaded.js * Update app/javascript/utilities/waitForDataLoaded.js Co-authored-by: Nick Taylor <nick@forem.com> * Review feedback * Fix failing spec * Cleanup promise * Remove old utility file * Add Cypress check for namespaced availability * More specs * Refactor to rely on ForemMobile for native bridge messaging * Fix spec/requests/api/v0/instances_spec.rb * Fix devices spec * Remove changes to /api/instance * Refactor to dynamic import * Fix typo * Fixed custom event detail payload for tests. * mock ForemMobile function instead of webkit call directly * failing jest test debug * Another attempt * Fixed broken test that was missing an onMainImageUrlChange prop on the component. * Fixed mobile bridging E2E tests. * Move Cypress tests to mobileFlows * Add JSDocs + cypress spec for injectJSMessage when logged out * Cleanup + Disable native image upload until AppStore approval Co-authored-by: Nick Taylor <nick@forem.com> Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
parent
c384755bae
commit
75a722bd6e
18 changed files with 408 additions and 179 deletions
|
|
@ -473,19 +473,9 @@ function initializePodcastPlayback() {
|
|||
}
|
||||
}
|
||||
|
||||
function handlePodcastMessages(mutation) {
|
||||
if (mutation.type !== 'attributes') {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = {};
|
||||
try {
|
||||
var messageData = getById('audiocontent').dataset.podcast;
|
||||
message = JSON.parse(messageData);
|
||||
} catch (e) {
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
return;
|
||||
}
|
||||
function handlePodcastMessages(event) {
|
||||
const message = JSON.parse(event.detail);
|
||||
if (message.namespace !== 'podcast') { return }
|
||||
|
||||
var currentState = currentAudioState();
|
||||
switch (message.action) {
|
||||
|
|
@ -499,41 +489,24 @@ function initializePodcastPlayback() {
|
|||
updateProgress(currentState.currentTime, currentState.duration, 100);
|
||||
break;
|
||||
default:
|
||||
console.log('Unrecognized podcast message: ', message); // eslint-disable-line no-console
|
||||
console.log('Unrecognized message: ', message); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
saveMediaState(currentState);
|
||||
}
|
||||
|
||||
function addMutationObserver() {
|
||||
var mutationObserver = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
handlePodcastMessages(mutation);
|
||||
});
|
||||
});
|
||||
mutationObserver.observe(getById('audiocontent'), { attributes: true });
|
||||
}
|
||||
|
||||
// When Runtime.podcastMessage is undefined we need to execute web logic
|
||||
function initRuntime() {
|
||||
if (Runtime.isNativeIOS('podcast')) {
|
||||
deviceType = 'iOS';
|
||||
Runtime.podcastMessage = function (message) {
|
||||
try {
|
||||
window.webkit.messageHandlers.podcast.postMessage(message);
|
||||
} catch (err) {
|
||||
console.log(err.message); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
} else if (Runtime.isNativeAndroid('podcastMessage')) {
|
||||
deviceType = 'Android';
|
||||
Runtime.podcastMessage = function (message) {
|
||||
try {
|
||||
AndroidBridge.podcastMessage(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
console.log(err.message); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (deviceType !== 'web') {
|
||||
Runtime.podcastMessage = (msg) => {
|
||||
window.ForemMobile.injectNativeMessage('podcast', msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +533,7 @@ function initializePodcastPlayback() {
|
|||
updateProgressListener(audio),
|
||||
false,
|
||||
);
|
||||
addMutationObserver();
|
||||
document.addEventListener('ForemMobile', handlePodcastMessages);
|
||||
}, 500);
|
||||
applyOnclickToPodcastBar(audio);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,17 +122,9 @@ function initializeVideoPlayback() {
|
|||
videoPlayerEvent(true);
|
||||
}
|
||||
|
||||
function handleVideoMessages(mutation) {
|
||||
if (mutation.type !== 'attributes') {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = {};
|
||||
try {
|
||||
var messageData = getById('video-player-source').dataset.message;
|
||||
message = JSON.parse(messageData);
|
||||
} catch (e) {
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
function handleVideoMessages(event) {
|
||||
const message = JSON.parse(event.detail);
|
||||
if (message.namespace !== 'video') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +143,7 @@ function initializeVideoPlayback() {
|
|||
currentTime = message.currentTime;
|
||||
break;
|
||||
default:
|
||||
console.log('Unrecognized video message: ', message); // eslint-disable-line no-console
|
||||
console.log('Unrecognized message: ', message); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,40 +153,25 @@ function initializeVideoPlayback() {
|
|||
|
||||
if (Runtime.isNativeIOS('video')) {
|
||||
deviceType = 'iOS';
|
||||
Runtime.videoMessage = function (message) {
|
||||
try {
|
||||
window.webkit.messageHandlers.video.postMessage(message);
|
||||
} catch (err) {
|
||||
console.log(err.message); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
} else if (Runtime.isNativeAndroid('videoMessage')) {
|
||||
deviceType = 'Android';
|
||||
Runtime.videoMessage = function (message) {
|
||||
try {
|
||||
AndroidBridge.videoMessage(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
console.log(err.message); // eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// jwplayer is initialized and no further interaction is needed
|
||||
initWebPlayer(seconds, metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
Runtime.videoMessage = (msg) => {
|
||||
window.ForemMobile.injectNativeMessage('video', msg);
|
||||
};
|
||||
|
||||
var playerElement = getById(`video-player-${metadata.id}`);
|
||||
playerElement.addEventListener('click', requestFocus);
|
||||
|
||||
playerElement.classList.add('native');
|
||||
getById('play-butt').classList.add('active');
|
||||
|
||||
var mutationObserver = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
handleVideoMessages(mutation);
|
||||
});
|
||||
});
|
||||
mutationObserver.observe(videoSource, { attributes: true });
|
||||
document.addEventListener('ForemMobile', handleVideoMessages);
|
||||
|
||||
currentTime = `${seconds}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class DevicesController < ApplicationController
|
|||
user: current_user,
|
||||
token: params[:token],
|
||||
platform: params[:platform],
|
||||
consumer_app: ConsumerApp.find_by(app_bundle: params[:app_bundle])
|
||||
consumer_app: consumer_app
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -57,7 +57,11 @@ class DevicesController < ApplicationController
|
|||
user_id: params[:id],
|
||||
token: params[:token],
|
||||
platform: params[:platform],
|
||||
consumer_app: ConsumerApp.find_by(app_bundle: params[:app_bundle])
|
||||
consumer_app: consumer_app
|
||||
}
|
||||
end
|
||||
|
||||
def consumer_app
|
||||
ConsumerApp.find_by(app_bundle: params[:app_bundle], platform: params[:platform])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ const NativeIosImageUpload = ({
|
|||
extraProps,
|
||||
uploadLabel,
|
||||
isUploadingImage,
|
||||
handleNativeMessage,
|
||||
}) => (
|
||||
<Fragment>
|
||||
{isUploadingImage ? null : (
|
||||
|
|
@ -26,12 +25,6 @@ const NativeIosImageUpload = ({
|
|||
{uploadLabel}
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
id="native-cover-image-upload-message"
|
||||
value=""
|
||||
onChange={handleNativeMessage}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
|
|
@ -98,20 +91,27 @@ export class ArticleCoverImage extends Component {
|
|||
};
|
||||
|
||||
useNativeUpload = () => {
|
||||
return Runtime.isNativeIOS('imageUpload');
|
||||
// This namespace is not implemented in the native side. This allows us to
|
||||
// deploy our refactor and wait until our iOS app is approved by AppStore
|
||||
// review. The old web implementation will be the fallback until then.
|
||||
return Runtime.isNativeIOS('imageUpload_disabled');
|
||||
};
|
||||
|
||||
initNativeImagePicker = (e) => {
|
||||
e.preventDefault();
|
||||
window.webkit.messageHandlers.imageUpload.postMessage({
|
||||
id: 'native-cover-image-upload-message',
|
||||
window.ForemMobile?.injectNativeMessage('coverUpload', {
|
||||
action: 'coverImageUpload',
|
||||
ratio: `${100.0 / 42.0}`,
|
||||
});
|
||||
};
|
||||
|
||||
handleNativeMessage = (e) => {
|
||||
const message = JSON.parse(e.target.value);
|
||||
const message = JSON.parse(e.detail);
|
||||
if (message.namespace !== 'coverUpload') {
|
||||
return;
|
||||
}
|
||||
|
||||
/* eslint-disable no-case-declarations */
|
||||
switch (message.action) {
|
||||
case 'uploading':
|
||||
this.setState({ uploadingImage: true });
|
||||
|
|
@ -125,10 +125,14 @@ export class ArticleCoverImage extends Component {
|
|||
});
|
||||
break;
|
||||
case 'success':
|
||||
this.props.onMainImageUrlChange({ links: [message.link] });
|
||||
const { onMainImageUrlChange } = this.props;
|
||||
onMainImageUrlChange({
|
||||
links: [message.link],
|
||||
});
|
||||
this.setState({ uploadingImage: false });
|
||||
break;
|
||||
}
|
||||
/* eslint-enable no-case-declarations */
|
||||
};
|
||||
|
||||
triggerMainImageRemoval = (e) => {
|
||||
|
|
@ -169,6 +173,9 @@ export class ArticleCoverImage extends Component {
|
|||
}
|
||||
: {};
|
||||
|
||||
// Native Bridge messages come through ForemMobile events
|
||||
document.addEventListener('ForemMobile', this.handleNativeMessage);
|
||||
|
||||
return (
|
||||
<DragAndDropZone
|
||||
onDragOver={onDragOver}
|
||||
|
|
@ -198,7 +205,6 @@ export class ArticleCoverImage extends Component {
|
|||
isUploadingImage={uploadingImage}
|
||||
extraProps={extraProps}
|
||||
uploadLabel={uploadLabel}
|
||||
handleNativeMessage={this.handleNativeMessage}
|
||||
/>
|
||||
) : (
|
||||
<StandardImageUpload
|
||||
|
|
|
|||
|
|
@ -66,11 +66,7 @@ function imageUploaderReducer(state, action) {
|
|||
}
|
||||
}
|
||||
|
||||
const NativeIosImageUpload = ({
|
||||
uploadingImage,
|
||||
extraProps,
|
||||
handleNativeMessage,
|
||||
}) => (
|
||||
const NativeIosImageUpload = ({ uploadingImage, extraProps }) => (
|
||||
<Fragment>
|
||||
{!uploadingImage && (
|
||||
<Button
|
||||
|
|
@ -83,12 +79,6 @@ const NativeIosImageUpload = ({
|
|||
Upload image
|
||||
</Button>
|
||||
)}
|
||||
<input
|
||||
type="hidden"
|
||||
id="native-image-upload-message"
|
||||
value=""
|
||||
onChange={handleNativeMessage}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
|
|
@ -177,7 +167,10 @@ export const ImageUploader = () => {
|
|||
}
|
||||
|
||||
function handleNativeMessage(e) {
|
||||
const message = JSON.parse(e.target.value);
|
||||
const message = JSON.parse(e.detail);
|
||||
if (message.namespace !== 'imageUpload') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case 'uploading':
|
||||
|
|
@ -202,8 +195,8 @@ export const ImageUploader = () => {
|
|||
|
||||
function initNativeImagePicker(e) {
|
||||
e.preventDefault();
|
||||
window.webkit.messageHandlers.imageUpload.postMessage({
|
||||
id: 'native-image-upload-message',
|
||||
window.ForemMobile?.injectNativeMessage('imageUpload', {
|
||||
action: 'imageUpload',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -211,11 +204,20 @@ export const ImageUploader = () => {
|
|||
// image picker for image upload we want to add the aria-label attr and the
|
||||
// onClick event to the UI button. This event will kick off the native UX.
|
||||
// The props are unwrapped (using spread operator) in the button below
|
||||
const useNativeUpload = Runtime.isNativeIOS('imageUpload');
|
||||
//
|
||||
//
|
||||
//
|
||||
// This namespace is not implemented in the native side. This allows us to
|
||||
// deploy our refactor and wait until our iOS app is approved by AppStore
|
||||
// review. The old web implementation will be the fallback until then.
|
||||
const useNativeUpload = Runtime.isNativeIOS('imageUpload_disabled');
|
||||
const extraProps = useNativeUpload
|
||||
? { onClick: initNativeImagePicker, 'aria-label': 'Upload an image' }
|
||||
: { tabIndex: -1 };
|
||||
|
||||
// Native Bridge messages come through ForemMobile events
|
||||
document.addEventListener('ForemMobile', handleNativeMessage);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{uploadingImage && (
|
||||
|
|
@ -228,7 +230,6 @@ export const ImageUploader = () => {
|
|||
<NativeIosImageUpload
|
||||
extraProps={extraProps}
|
||||
uploadingImage={uploadingImage}
|
||||
handleNativeMessage={handleNativeMessage}
|
||||
/>
|
||||
) : (
|
||||
<StandardImageUpload
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
render,
|
||||
fireEvent,
|
||||
waitForElementToBeRemoved,
|
||||
createEvent,
|
||||
} from '@testing-library/preact';
|
||||
import { axe } from 'jest-axe';
|
||||
import fetch from 'jest-fetch-mock';
|
||||
|
|
@ -154,16 +155,16 @@ describe('<ArticleCoverImage />', () => {
|
|||
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(
|
||||
<ArticleCoverImage
|
||||
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
|
||||
|
|
@ -182,63 +183,71 @@ describe('<ArticleCoverImage />', () => {
|
|||
});
|
||||
|
||||
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(<ArticleCoverImage mainImage="" />);
|
||||
const { queryByLabelText } = render(
|
||||
<ArticleCoverImage mainImage="" onMainImageUrlChange={jest.fn()} />,
|
||||
);
|
||||
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(
|
||||
<ArticleCoverImage
|
||||
mainImage=""
|
||||
onMainImageUrlChange={onMainImageUrlChange}
|
||||
onMainImageUrlChange={onMainImageUrlChangeSpy}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ArticleCoverImage
|
||||
mainImage=""
|
||||
onMainImageUrlChange={onMainImageUrlChange}
|
||||
/>,
|
||||
);
|
||||
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('<ArticleCoverImage />', () => {
|
|||
|
||||
it('displays an uploading message', async () => {
|
||||
const onMainImageUrlChange = jest.fn();
|
||||
/* eslint-disable no-unused-vars */
|
||||
const { container, findByText } = render(
|
||||
<ArticleCoverImage
|
||||
mainImage=""
|
||||
onMainImageUrlChange={onMainImageUrlChange}
|
||||
/>,
|
||||
);
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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('<ImageUploader />', () => {
|
|||
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('<ImageUploader />', () => {
|
|||
});
|
||||
|
||||
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(<ImageUploader />);
|
||||
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(<ImageUploader />);
|
||||
const nativeInput = container.querySelector(
|
||||
'#native-image-upload-message',
|
||||
);
|
||||
const { container, findByTitle } = render(<ImageUploader />); // 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();
|
||||
});
|
||||
|
|
|
|||
88
app/javascript/mobile/foremMobile.js
Normal file
88
app/javascript/mobile/foremMobile.js
Normal file
|
|
@ -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', {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
20
app/javascript/utilities/waitOnBaseData.js
Normal file
20
app/javascript/utilities/waitOnBaseData.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,12 +18,11 @@
|
|||
<% end %>
|
||||
<meta name="environment" content="<%= Rails.env %>">
|
||||
<%= 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) %>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue