add app install button

This commit is contained in:
David Lechner
2021-01-28 20:21:20 -06:00
parent e59bfd4474
commit 252c741180
8 changed files with 228 additions and 6 deletions
+51
View File
@@ -4,6 +4,7 @@
// Actions for the app in general.
import { Action } from 'redux';
import { BeforeInstallPromptEvent } from '../utils/dom';
/** App action types. */
export enum AppActionType {
@@ -13,6 +14,14 @@ export enum AppActionType {
CheckForUpdate = 'app.action.checkForUpdate',
/** Indicates that checking for update finished. */
DidCheckForUpdate = 'app.action.didCheckForUpdate',
/* Indicates the browser wants to prompt the use to install the app. */
DidBeforeInstallPrompt = 'app.action.didBeforeInstallPrompt',
/* Requests to prompt the user to install the app. */
InstallPrompt = 'app.action.installPrompt',
/* Indicates that the user responded to the install prompt. */
DidInstallPrompt = 'app.action.didInstallPrompt',
/* Indicates that the app was installed. */
DidInstall = 'app.action.didInstallPrompt',
/** The app has just ben started. */
DidStart = 'app.action.didStart',
/** Open settings dialog. */
@@ -61,6 +70,44 @@ export function didCheckForUpdate(updateFound: boolean): AppDidCheckForUpdateAct
return { type: AppActionType.DidCheckForUpdate, updateFound };
}
/* Action that indicates the browser wants to prompt the use to install the app. */
export type AppDidBeforeInstallPromptAction = Action<AppActionType.DidBeforeInstallPrompt> & {
event: BeforeInstallPromptEvent;
};
/* Action that indicates the browser wants to prompt the use to install the app. */
export function didBeforeInstallPrompt(
event: BeforeInstallPromptEvent,
): AppDidBeforeInstallPromptAction {
return { type: AppActionType.DidBeforeInstallPrompt, event };
}
/* Action that requests to prompt the user to install the app. */
export type AppInstallPromptAction = Action<AppActionType.InstallPrompt> & {
event: BeforeInstallPromptEvent;
};
/* Action that requests to prompt the user to install the app. */
export function installPrompt(event: BeforeInstallPromptEvent): AppInstallPromptAction {
return { type: AppActionType.InstallPrompt, event };
}
/* Action that indicates that the user responded to the install prompt. */
export type AppDidInstallPromptAction = Action<AppActionType.DidInstallPrompt>;
/* Action that indicates that the user responded to the install prompt. */
export function didInstallPrompt(): AppDidInstallPromptAction {
return { type: AppActionType.DidInstallPrompt };
}
/* Action that indicates app was installed. */
export type AppDidInstallAction = Action<AppActionType.DidInstall>;
/* Action that indicates app was installed. */
export function didInstall(): AppDidInstallAction {
return { type: AppActionType.DidInstall };
}
/** Action that indicates the app has just started. */
export type AppDidStartAction = Action<AppActionType.DidStart>;
@@ -122,6 +169,10 @@ export type AppAction =
| AppReloadAction
| AppCheckForUpdatesAction
| AppDidCheckForUpdateAction
| AppDidBeforeInstallPromptAction
| AppInstallPromptAction
| AppDidInstallPromptAction
| AppDidInstallAction
| AppDidStartAction
| AppOpenSettingsAction
| AppCloseSettingsAction
+28 -1
View File
@@ -19,7 +19,13 @@ import { WithI18nProps, withI18n } from '@shopify/react-i18n';
import React from 'react';
import { connect } from 'react-redux';
import { Action, Dispatch } from '../actions';
import { checkForUpdate, closeSettings, openAboutDialog, reload } from '../actions/app';
import {
checkForUpdate,
closeSettings,
installPrompt,
openAboutDialog,
reload,
} from '../actions/app';
import { setBoolean, toggleBoolean } from '../actions/settings';
import { RootState } from '../reducers';
import { pseudolocalize } from '../settings/i18n';
@@ -31,6 +37,7 @@ import {
tooltipDelay,
} from '../settings/ui';
import { SettingId } from '../settings/user';
import { BeforeInstallPromptEvent } from '../utils/dom';
import { isMacOS } from '../utils/os';
import AboutDialog from './AboutDialog';
import ExternalLinkIcon from './ExternalLinkIcon';
@@ -45,6 +52,8 @@ type StateProps = {
serviceWorker: ServiceWorkerRegistration | null;
checkingForUpdate: boolean;
updateAvailable: boolean;
beforeInstallPrompt: BeforeInstallPromptEvent | null;
promptingInstall: boolean;
};
type DispatchProps = {
@@ -56,6 +65,7 @@ type DispatchProps = {
onToggleDocs: () => void;
onCheckForUpdate: (registration: ServiceWorkerRegistration) => void;
onReload: (registration: ServiceWorkerRegistration) => void;
onInstallPrompt: (event: BeforeInstallPromptEvent) => void;
};
type SettingsProps = StateProps & DispatchProps & WithI18nProps;
@@ -71,6 +81,8 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
flashCurrentProgram,
checkingForUpdate,
updateAvailable,
beforeInstallPrompt,
promptingInstall,
onClose,
onShowDocsChanged,
onDarkModeChanged,
@@ -78,6 +90,7 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
onAbout,
onCheckForUpdate,
onReload,
onInstallPrompt: onInstall,
i18n,
} = this.props;
return (
@@ -219,6 +232,17 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
vertical={true}
alignText="left"
>
{beforeInstallPrompt && (
<Button
icon="add"
onClick={() => onInstall(beforeInstallPrompt)}
loading={promptingInstall}
>
{i18n.translate(
SettingsStringId.AppInstallLabel,
)}
</Button>
)}
{serviceWorker && !updateAvailable && (
<Button
icon="refresh"
@@ -293,6 +317,8 @@ const mapStateToProps = (state: RootState): StateProps => ({
serviceWorker: state.app.serviceWorker,
checkingForUpdate: state.app.checkingForUpdate,
updateAvailable: state.app.updateAvailable,
beforeInstallPrompt: state.app.beforeInstallPrompt,
promptingInstall: state.app.promptingInstall,
});
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
@@ -307,6 +333,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
onToggleDocs: (): Action => dispatch(toggleBoolean(SettingId.ShowDocs)),
onCheckForUpdate: (registration) => dispatch(checkForUpdate(registration)),
onReload: (registration) => dispatch(reload(registration)),
onInstallPrompt: (event) => dispatch(installPrompt(event)),
});
export default connect(
+3
View File
@@ -39,6 +39,9 @@
},
"app": {
"title": "App",
"install": {
"label": "Install as App"
},
"checkForUpdate": {
"label": "Check for Update"
},
+1
View File
@@ -20,6 +20,7 @@ export enum SettingsStringId {
HelpChatLabel = 'settings.help.chat.label',
HelpBugsLabel = 'settings.help.bugs.label',
AppTitle = 'settings.app.title',
AppInstallLabel = 'settings.app.install.label',
AppCheckForUpdateLabel = 'settings.app.checkForUpdate.label',
AppRestartLabel = 'settings.app.restart.label',
AppAboutLabel = 'settings.app.about.label',
+30
View File
@@ -7,6 +7,7 @@ import { Reducer, combineReducers } from 'redux';
import { Action } from '../actions';
import { AppActionType } from '../actions/app';
import { ServiceWorkerActionType } from '../actions/service-worker';
import { BeforeInstallPromptEvent } from '../utils/dom';
export interface AppState {
readonly showSettings: boolean;
@@ -15,6 +16,8 @@ export interface AppState {
readonly serviceWorker: ServiceWorkerRegistration | null;
readonly checkingForUpdate: boolean;
readonly updateAvailable: boolean;
readonly beforeInstallPrompt: BeforeInstallPromptEvent;
readonly promptingInstall: boolean;
}
const showSettings: Reducer<boolean, Action> = (state = false, action) => {
@@ -88,6 +91,31 @@ const updateAvailable: Reducer<boolean, Action> = (state = false, action) => {
}
};
const beforeInstallPrompt: Reducer<BeforeInstallPromptEvent | null, Action> = (
state = null,
action,
) => {
switch (action.type) {
case AppActionType.DidBeforeInstallPrompt:
return action.event;
case AppActionType.DidInstall:
return null;
default:
return state;
}
};
const promptingInstall: Reducer<boolean, Action> = (state = false, action) => {
switch (action.type) {
case AppActionType.InstallPrompt:
return true;
case AppActionType.DidInstallPrompt:
return false;
default:
return state;
}
};
export default combineReducers({
showSettings,
showAboutDialog,
@@ -95,4 +123,6 @@ export default combineReducers({
serviceWorker,
checkingForUpdate,
updateAvailable,
beforeInstallPrompt,
promptingInstall,
});
+51 -4
View File
@@ -2,9 +2,40 @@
// Copyright (c) 2021 The Pybricks Authors
import { AsyncSaga, delay } from '../../test';
import { checkForUpdate, didCheckForUpdate, reload } from '../actions/app';
import {
checkForUpdate,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstallPrompt,
installPrompt,
reload,
} from '../actions/app';
import { BeforeInstallPromptEvent } from '../utils/dom';
import app from './app';
test('monitorAppInstalled', async () => {
const saga = new AsyncSaga(app);
window.dispatchEvent(new Event('appinstalled'));
const action = await saga.take();
expect(action).toStrictEqual(didInstallPrompt());
await saga.end();
});
test('monitorBeforeInstallPrompt', async () => {
const saga = new AsyncSaga(app);
const event = new Event('beforeinstallprompt') as BeforeInstallPromptEvent;
window.dispatchEvent(event);
const action = await saga.take();
expect(action).toStrictEqual(didBeforeInstallPrompt(event));
await saga.end();
});
test('reload', async () => {
const saga = new AsyncSaga(app);
@@ -22,9 +53,6 @@ test('reload', async () => {
saga.put(reload(registration as ServiceWorkerRegistration));
// yield to allow generators to complete
await delay(0);
expect(registration.unregister).toHaveBeenCalled();
expect(location.reload).toHaveBeenCalled();
@@ -52,3 +80,22 @@ test('checkForUpdates', async () => {
await saga.end();
});
test('installPrompt', async () => {
const saga = new AsyncSaga(app);
// mock registration as if service worker was register on app startup
const event: Partial<BeforeInstallPromptEvent> = {
prompt: jest.fn(),
userChoice: Promise.resolve({ outcome: 'accepted', platform: 'web' }),
};
saga.put(installPrompt(event as BeforeInstallPromptEvent));
expect(event.prompt).toHaveBeenCalled();
const action = await saga.take();
expect(action).toStrictEqual(didInstallPrompt());
await saga.end();
});
+51 -1
View File
@@ -1,13 +1,54 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { call, put, takeEvery } from 'typed-redux-saga/macro';
import { eventChannel } from 'redux-saga';
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
import {
AppActionType,
AppCheckForUpdatesAction,
AppInstallPromptAction,
AppReloadAction,
didBeforeInstallPrompt,
didCheckForUpdate,
didInstall,
didInstallPrompt,
} from '../actions/app';
import { BeforeInstallPromptEvent } from '../utils/dom';
function* monitorAppInstalled(): Generator {
const chan = eventChannel<Event>((emit) => {
const listener = (e: Event) => {
emit(e);
};
// chromium-only event
window.addEventListener('appinstalled', listener);
// istanbul ignore next: currently we don't ever stop monitoring
return () => window.removeEventListener('appinstalled', listener);
});
while (true) {
yield* take(chan);
yield* put(didInstall());
}
}
function* monitorBeforeInstallPrompt(): Generator {
const chan = eventChannel<BeforeInstallPromptEvent>((emit) => {
const listener = (e: BeforeInstallPromptEvent) => {
emit(e);
};
// @ts-expect-error: chromium-only event
window.addEventListener('beforeinstallprompt', listener);
// istanbul ignore next: currently we don't ever stop monitoring
// @ts-expect-error: chromium-only event
return () => window.removeEventListener('beforeinstallprompt', listener);
});
while (true) {
const event = yield* take(chan);
yield* put(didBeforeInstallPrompt(event));
}
}
function* reload(action: AppReloadAction): Generator {
yield* call(() => action.registration.unregister());
@@ -20,7 +61,16 @@ function* checkForUpdate(action: AppCheckForUpdatesAction): Generator {
yield* put(didCheckForUpdate(updateFound));
}
function* installPrompt(action: AppInstallPromptAction): 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(AppActionType.Reload, reload);
yield* takeEvery(AppActionType.CheckForUpdate, checkForUpdate);
yield* takeEvery(AppActionType.InstallPrompt, installPrompt);
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Chromium-only API
// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent
export interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}