diff --git a/src/app/actions.ts b/src/app/actions.ts index 5863bd77..db3fd750 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -4,7 +4,6 @@ // Actions for the app in general. import { createAction } from '../actions'; -import { BeforeInstallPromptEvent } from '../utils/dom'; /** Creates an action that requests the app to reload. */ export const reload = createAction((registration: ServiceWorkerRegistration) => ({ @@ -27,23 +26,22 @@ export const didCheckForUpdate = createAction((updateFound: boolean) => ({ })); /* Action that indicates the browser wants to prompt the use to install the app. */ -export const didBeforeInstallPrompt = createAction( - (event: BeforeInstallPromptEvent) => ({ - type: 'app.action.didBeforeInstallPrompt', - event, - }), -); +export const appDidReceiveBeforeInstallPrompt = createAction(() => ({ + type: 'app.action.didBeforeInstallPrompt', +})); /* Action that requests to prompt the user to install the app. */ -export const installPrompt = createAction((event: BeforeInstallPromptEvent) => ({ - type: 'app.action.installPrompt', - event, +export const appShowInstallPrompt = createAction(() => ({ + type: 'app.action.showInstallPrompt', })); /* Action that indicates that the user responded to the install prompt. */ -export const didInstallPrompt = createAction(() => ({ - type: 'app.action.didInstallPrompt', -})); +export const appDidResolveInstallPrompt = createAction( + (result: { outcome: 'accepted' | 'dismissed'; platform: string }) => ({ + type: 'app.action.didResolveInstallPrompt', + result, + }), +); /* Action that indicates app was installed. */ export const didInstall = createAction(() => ({ diff --git a/src/app/reducers.test.ts b/src/app/reducers.test.ts index 10577b02..21657d1b 100644 --- a/src/app/reducers.test.ts +++ b/src/app/reducers.test.ts @@ -6,14 +6,13 @@ import { serviceWorkerDidSucceed, serviceWorkerDidUpdate, } from '../service-worker/actions'; -import { BeforeInstallPromptEvent } from '../utils/dom'; import { + appDidReceiveBeforeInstallPrompt, + appDidResolveInstallPrompt, + appShowInstallPrompt, checkForUpdate, - didBeforeInstallPrompt, didCheckForUpdate, didInstall, - didInstallPrompt, - installPrompt, } from './actions'; import reducers from './reducers'; @@ -22,8 +21,8 @@ type State = ReturnType; test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { - "beforeInstallPrompt": null, "checkingForUpdate": false, + "hasUnresolvedInstallPrompt": false, "promptingInstall": false, "readyForOfflineUse": false, "serviceWorker": null, @@ -78,29 +77,49 @@ test('updateAvailable', () => { ).toBe(true); }); -test('beforeInstallPrompt', () => { - const event = {} as BeforeInstallPromptEvent; - expect( - reducers({ beforeInstallPrompt: null } as State, didBeforeInstallPrompt(event)) - .beforeInstallPrompt, - ).toBe(event); - expect( - reducers({ beforeInstallPrompt: event } as State, didInstall()) - .beforeInstallPrompt, - ).toBe(null); +describe('hasUnresolvedInstallPrompt', () => { + it('should be true after BeforeInstallPromptEvent is received', () => { + expect( + reducers( + { hasUnresolvedInstallPrompt: false } as State, + appDidReceiveBeforeInstallPrompt(), + ).hasUnresolvedInstallPrompt, + ).toBe(true); + }); + + it('should be false after the app was successfully installed', () => { + expect( + reducers({ hasUnresolvedInstallPrompt: true } as State, didInstall()) + .hasUnresolvedInstallPrompt, + ).toBe(false); + }); }); -test('promptingInstall', () => { - expect( - reducers( - { promptingInstall: false } as State, - installPrompt({} as BeforeInstallPromptEvent), - ).promptingInstall, - ).toBe(true); - expect( - reducers({ promptingInstall: true } as State, didInstallPrompt()) - .promptingInstall, - ).toBe(false); +describe('promptingInstall', () => { + it('should be true when action requesting to show install prompt is seen', () => { + expect( + reducers({ promptingInstall: false } as State, appShowInstallPrompt()) + .promptingInstall, + ).toBe(true); + }); + + it('should be false when user accepts the prompt', () => { + expect( + reducers( + { promptingInstall: true } as State, + appDidResolveInstallPrompt({ outcome: 'accepted', platform: 'web' }), + ).promptingInstall, + ).toBe(false); + }); + + it('should be false when user dismisses the prompt', () => { + expect( + reducers( + { promptingInstall: true } as State, + appDidResolveInstallPrompt({ outcome: 'dismissed', platform: 'web' }), + ).promptingInstall, + ).toBe(false); + }); }); test('readyForOfflineUse', () => { diff --git a/src/app/reducers.ts b/src/app/reducers.ts index ec876d69..870d5e85 100644 --- a/src/app/reducers.ts +++ b/src/app/reducers.ts @@ -8,14 +8,13 @@ import { serviceWorkerDidSucceed, serviceWorkerDidUpdate, } from '../service-worker/actions'; -import { BeforeInstallPromptEvent } from '../utils/dom'; import { + appDidReceiveBeforeInstallPrompt, + appDidResolveInstallPrompt, + appShowInstallPrompt, checkForUpdate, - didBeforeInstallPrompt, didCheckForUpdate, didInstall, - didInstallPrompt, - installPrompt, } from './actions'; const serviceWorker: Reducer = ( @@ -57,27 +56,29 @@ const updateAvailable: Reducer = (state = false, action) => { return state; }; -const beforeInstallPrompt: Reducer = ( - state = null, - action, -) => { - if (didBeforeInstallPrompt.matches(action)) { - return action.event; +/** + * Indicates that the app has received the BeforeInstallPromptEvent but the + * app has not been installed yet. + */ +const hasUnresolvedInstallPrompt: Reducer = (state = false, action) => { + if (appDidReceiveBeforeInstallPrompt.matches(action)) { + return true; } if (didInstall.matches(action)) { - return null; + return false; } return state; }; +/** Indicates that the browser install prompt is active. */ const promptingInstall: Reducer = (state = false, action) => { - if (installPrompt.matches(action)) { + if (appShowInstallPrompt.matches(action)) { return true; } - if (didInstallPrompt.matches(action)) { + if (appDidResolveInstallPrompt.matches(action)) { return false; } @@ -96,7 +97,7 @@ export default combineReducers({ serviceWorker, checkingForUpdate, updateAvailable, - beforeInstallPrompt, + hasUnresolvedInstallPrompt, promptingInstall, readyForOfflineUse, }); diff --git a/src/app/sagas.test.ts b/src/app/sagas.test.ts index 6e2b8080..d5733e95 100644 --- a/src/app/sagas.test.ts +++ b/src/app/sagas.test.ts @@ -1,15 +1,16 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors +import { createEvent, fireEvent } from '@testing-library/dom'; import { AsyncSaga, delay } from '../../test'; import { BeforeInstallPromptEvent } from '../utils/dom'; import { + appDidReceiveBeforeInstallPrompt, + appDidResolveInstallPrompt, + appShowInstallPrompt, checkForUpdate, - didBeforeInstallPrompt, didCheckForUpdate, didInstall, - didInstallPrompt, - installPrompt, reload, } from './actions'; import app from './sagas'; @@ -28,11 +29,10 @@ test('monitorAppInstalled', async () => { test('monitorBeforeInstallPrompt', async () => { const saga = new AsyncSaga(app); - const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent; - window.dispatchEvent(event); + fireEvent(window, createEvent('beforeinstallprompt', window)); const action = await saga.take(); - expect(action).toStrictEqual(didBeforeInstallPrompt(event)); + expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt()); await saga.end(); }); @@ -82,21 +82,30 @@ test('checkForUpdates', async () => { await saga.end(); }); -test('installPrompt', async () => { +test('appShowInstallPrompt', async () => { + const userChoice = { + outcome: <'accepted' | 'dismissed'>'accepted', + platform: 'web', + }; + const saga = new AsyncSaga(app); // mock registration as if service worker was register on app startup - const event: Partial = { - prompt: jest.fn(), - userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }), - }; + const event = createEvent('beforeinstallprompt', window); + Object.assign(event, >{ + prompt: () => Promise.resolve(undefined), + userChoice: Promise.resolve(userChoice), + }); - saga.put(installPrompt(event as BeforeInstallPromptEvent)); - - expect(event.prompt).toHaveBeenCalled(); + fireEvent(window, event); const action = await saga.take(); - expect(action).toStrictEqual(didInstallPrompt()); + expect(action).toStrictEqual(appDidReceiveBeforeInstallPrompt()); + + saga.put(appShowInstallPrompt()); + + const action2 = await saga.take(); + expect(action2).toStrictEqual(appDidResolveInstallPrompt(userChoice)); await saga.end(); }); diff --git a/src/app/sagas.ts b/src/app/sagas.ts index 8bc82484..c8c3188e 100644 --- a/src/app/sagas.ts +++ b/src/app/sagas.ts @@ -5,12 +5,12 @@ import { eventChannel } from 'redux-saga'; import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro'; import { BeforeInstallPromptEvent } from '../utils/dom'; import { + appDidReceiveBeforeInstallPrompt, + appDidResolveInstallPrompt, + appShowInstallPrompt, checkForUpdate, - didBeforeInstallPrompt, didCheckForUpdate, didInstall, - didInstallPrompt, - installPrompt, reload, } from './actions'; @@ -31,6 +31,15 @@ function* monitorAppInstalled(): Generator { } } +function* handleBeforeInstallPromptEvent(event: BeforeInstallPromptEvent): Generator { + // wait for user to request to install the app - may never happen + yield* take(appShowInstallPrompt); + + yield* call(() => event.prompt()); + const choice = yield* call(() => event.userChoice); + yield* put(appDidResolveInstallPrompt(choice)); +} + function* monitorBeforeInstallPrompt(): Generator { const chan = eventChannel((emit) => { const listener = (e: BeforeInstallPromptEvent) => { @@ -43,9 +52,12 @@ function* monitorBeforeInstallPrompt(): Generator { return () => window.removeEventListener('beforeinstallprompt', listener); }); + // in theory, the before install prompt event should only happen once, so + // we don't bother canceling the forked task when another event is received while (true) { const event = yield* take(chan); - yield* put(didBeforeInstallPrompt(event)); + yield* fork(handleBeforeInstallPromptEvent, event); + yield* put(appDidReceiveBeforeInstallPrompt()); } } @@ -60,16 +72,9 @@ function* handleCheckForUpdate(action: ReturnType): Gener yield* put(didCheckForUpdate(updateFound)); } -function* handleInstallPrompt(action: ReturnType): Generator { - yield* call(() => action.event.prompt()); - yield* call(() => action.event.userChoice); - yield* put(didInstallPrompt()); -} - export default function* app(): Generator { yield* fork(monitorAppInstalled); yield* fork(monitorBeforeInstallPrompt); yield* takeEvery(reload, handleReload); yield* takeEvery(checkForUpdate, handleCheckForUpdate); - yield* takeEvery(installPrompt, handleInstallPrompt); } diff --git a/src/settings/SettingsDrawer.tsx b/src/settings/SettingsDrawer.tsx index 7e21e08c..9590c693 100644 --- a/src/settings/SettingsDrawer.tsx +++ b/src/settings/SettingsDrawer.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { AnchorButton, @@ -21,7 +21,7 @@ import { useI18n } from '@shopify/react-i18n'; import React, { useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import AboutDialog from '../about/AboutDialog'; -import { checkForUpdate, installPrompt, reload } from '../app/actions'; +import { appShowInstallPrompt, checkForUpdate, reload } from '../app/actions'; import { pybricksBugReportsUrl, pybricksGitterUrl, @@ -53,7 +53,9 @@ const SettingsDrawer: React.FunctionComponent = (props) => { const serviceWorker = useSelector((s) => s.app.serviceWorker); const checkingForUpdate = useSelector((s) => s.app.checkingForUpdate); const updateAvailable = useSelector((s) => s.app.updateAvailable); - const beforeInstallPrompt = useSelector((s) => s.app.beforeInstallPrompt); + const hasUnresolvedInstallPrompt = useSelector( + (s) => s.app.hasUnresolvedInstallPrompt, + ); const promptingInstall = useSelector((s) => s.app.promptingInstall); const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse); const hubName = useSelector((s) => s.settings.hubName); @@ -279,12 +281,10 @@ const SettingsDrawer: React.FunctionComponent = (props) => { } > - {beforeInstallPrompt && ( + {hasUnresolvedInstallPrompt && (