mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
add app install button
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
},
|
||||
"app": {
|
||||
"title": "App",
|
||||
"install": {
|
||||
"label": "Install as App"
|
||||
},
|
||||
"checkForUpdate": {
|
||||
"label": "Check for Update"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user