Merge branch 'sw-skipwaiting' into 'develop'
ServiceWorker: display a toast to skipWaiting and reload tabs when new ServiceWorker is ready See merge request soapbox-pub/soapbox-fe!1480
This commit is contained in:
commit
8e7d3d45a9
12 changed files with 178 additions and 43 deletions
|
@ -5,6 +5,7 @@ import { httpErrorMessages } from 'soapbox/utils/errors';
|
||||||
import type { SnackbarActionSeverity } from './snackbar';
|
import type { SnackbarActionSeverity } from './snackbar';
|
||||||
import type { AnyAction } from '@reduxjs/toolkit';
|
import type { AnyAction } from '@reduxjs/toolkit';
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
|
import type { NotificationObject } from 'react-notification';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||||
|
@ -17,7 +18,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR';
|
||||||
|
|
||||||
const noOp = () => { };
|
const noOp = () => { };
|
||||||
|
|
||||||
function dismissAlert(alert: any) {
|
function dismissAlert(alert: NotificationObject) {
|
||||||
return {
|
return {
|
||||||
type: ALERT_DISMISS,
|
type: ALERT_DISMISS,
|
||||||
alert,
|
alert,
|
||||||
|
|
|
@ -2,34 +2,45 @@ import { ALERT_SHOW } from './alerts';
|
||||||
|
|
||||||
import type { MessageDescriptor } from 'react-intl';
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
export type SnackbarActionSeverity = 'info' | 'success' | 'error'
|
export type SnackbarActionSeverity = 'info' | 'success' | 'error';
|
||||||
|
|
||||||
type SnackbarMessage = string | MessageDescriptor
|
type SnackbarMessage = string | MessageDescriptor;
|
||||||
|
|
||||||
export type SnackbarAction = {
|
export type SnackbarAction = {
|
||||||
type: typeof ALERT_SHOW
|
type: typeof ALERT_SHOW,
|
||||||
message: SnackbarMessage
|
message: SnackbarMessage,
|
||||||
actionLabel?: SnackbarMessage
|
actionLabel?: SnackbarMessage,
|
||||||
actionLink?: string
|
actionLink?: string,
|
||||||
severity: SnackbarActionSeverity
|
action?: () => void,
|
||||||
}
|
severity: SnackbarActionSeverity,
|
||||||
|
};
|
||||||
|
|
||||||
export const show = (severity: SnackbarActionSeverity, message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string): SnackbarAction => ({
|
type SnackbarOpts = {
|
||||||
|
actionLabel?: SnackbarMessage,
|
||||||
|
actionLink?: string,
|
||||||
|
action?: () => void,
|
||||||
|
dismissAfter?: number | false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const show = (
|
||||||
|
severity: SnackbarActionSeverity,
|
||||||
|
message: SnackbarMessage,
|
||||||
|
opts?: SnackbarOpts,
|
||||||
|
): SnackbarAction => ({
|
||||||
type: ALERT_SHOW,
|
type: ALERT_SHOW,
|
||||||
message,
|
message,
|
||||||
actionLabel,
|
|
||||||
actionLink,
|
|
||||||
severity,
|
severity,
|
||||||
|
...opts,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
||||||
show('info', message, actionLabel, actionLink);
|
show('info', message, { actionLabel, actionLink });
|
||||||
|
|
||||||
export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
||||||
show('success', message, actionLabel, actionLink);
|
show('success', message, { actionLabel, actionLink });
|
||||||
|
|
||||||
export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
|
||||||
show('error', message, actionLabel, actionLink);
|
show('error', message, { actionLabel, actionLink });
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
info,
|
info,
|
||||||
|
|
15
app/soapbox/actions/sw.ts
Normal file
15
app/soapbox/actions/sw.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
|
/** Sets the ServiceWorker updating state. */
|
||||||
|
const SW_UPDATING = 'SW_UPDATING';
|
||||||
|
|
||||||
|
/** Dispatch when the ServiceWorker is being updated to display a loading screen. */
|
||||||
|
const setSwUpdating = (isUpdating: boolean): AnyAction => ({
|
||||||
|
type: SW_UPDATING,
|
||||||
|
isUpdating,
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
SW_UPDATING,
|
||||||
|
setSwUpdating,
|
||||||
|
};
|
|
@ -78,6 +78,7 @@ const SoapboxMount = () => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
|
const swUpdating = useAppSelector(state => state.meta.swUpdating);
|
||||||
|
|
||||||
const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en';
|
const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en';
|
||||||
|
|
||||||
|
@ -120,6 +121,7 @@ const SoapboxMount = () => {
|
||||||
me && !account,
|
me && !account,
|
||||||
!isLoaded,
|
!isLoaded,
|
||||||
localeLoading,
|
localeLoading,
|
||||||
|
swUpdating,
|
||||||
].some(Boolean);
|
].some(Boolean);
|
||||||
|
|
||||||
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base h-full', {
|
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base h-full', {
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,84 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, MessageDescriptor } from 'react-intl';
|
||||||
|
import { NotificationStack, NotificationObject, StyleFactoryFn } from 'react-notification';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { dismissAlert } from 'soapbox/actions/alerts';
|
||||||
|
import { Button } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Alert } from 'soapbox/reducers/alerts';
|
||||||
|
|
||||||
|
/** Portal for snackbar alerts. */
|
||||||
|
const SnackbarContainer: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const alerts = useAppSelector(state => state.alerts);
|
||||||
|
|
||||||
|
/** Apply i18n to the message if it's an object. */
|
||||||
|
const maybeFormatMessage = (message: MessageDescriptor | string): string => {
|
||||||
|
switch (typeof message) {
|
||||||
|
case 'string': return message;
|
||||||
|
case 'object': return intl.formatMessage(message);
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Convert a reducer Alert into a react-notification object. */
|
||||||
|
const buildAlert = (item: Alert): NotificationObject => {
|
||||||
|
// Backwards-compatibility
|
||||||
|
if (item.actionLink) {
|
||||||
|
item = item.set('action', () => history.push(item.actionLink));
|
||||||
|
}
|
||||||
|
|
||||||
|
const alert: NotificationObject = {
|
||||||
|
message: maybeFormatMessage(item.message),
|
||||||
|
title: maybeFormatMessage(item.title),
|
||||||
|
key: item.key,
|
||||||
|
className: `notification-bar-${item.severity}`,
|
||||||
|
activeClassName: 'snackbar--active',
|
||||||
|
dismissAfter: item.dismissAfter,
|
||||||
|
style: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.action && item.actionLabel) {
|
||||||
|
// HACK: it's a JSX.Element instead of a string!
|
||||||
|
// react-notification displays it just fine.
|
||||||
|
alert.action = (
|
||||||
|
<Button theme='ghost' size='sm' onClick={item.action} text={maybeFormatMessage(item.actionLabel)} />
|
||||||
|
) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
return alert;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDismiss = (alert: NotificationObject) => {
|
||||||
|
dispatch(dismissAlert(alert));
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultBarStyleFactory: StyleFactoryFn = (index, style, _notification) => {
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
style,
|
||||||
|
{ bottom: `${14 + index * 12 + index * 42}px` },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications = alerts.toArray().map(buildAlert);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role='assertive' data-testid='toast' className='z-1000 fixed inset-0 flex items-end px-4 py-6 pointer-events-none pt-16 lg:pt-20 sm:items-start'>
|
||||||
|
<NotificationStack
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onClick={onDismiss}
|
||||||
|
barStyleFactory={defaultBarStyleFactory}
|
||||||
|
activeBarStyleFactory={defaultBarStyleFactory}
|
||||||
|
notifications={notifications}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SnackbarContainer;
|
|
@ -4,8 +4,12 @@ import './precheck';
|
||||||
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
|
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import { setSwUpdating } from 'soapbox/actions/sw';
|
||||||
import * as BuildConfig from 'soapbox/build_config';
|
import * as BuildConfig from 'soapbox/build_config';
|
||||||
|
import { store } from 'soapbox/store';
|
||||||
import { printConsoleWarning } from 'soapbox/utils/console';
|
import { printConsoleWarning } from 'soapbox/utils/console';
|
||||||
|
|
||||||
import { default as Soapbox } from './containers/soapbox';
|
import { default as Soapbox } from './containers/soapbox';
|
||||||
|
@ -13,6 +17,11 @@ import * as monitoring from './monitoring';
|
||||||
import * as perf from './performance';
|
import * as perf from './performance';
|
||||||
import ready from './ready';
|
import ready from './ready';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
update: { id: 'sw.update', defaultMessage: 'Update' },
|
||||||
|
updateText: { id: 'sw.update_text', defaultMessage: 'An update is available.' },
|
||||||
|
});
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
perf.start('main()');
|
perf.start('main()');
|
||||||
|
|
||||||
|
@ -31,7 +40,22 @@ function main() {
|
||||||
|
|
||||||
if (BuildConfig.NODE_ENV === 'production') {
|
if (BuildConfig.NODE_ENV === 'production') {
|
||||||
// avoid offline in dev mode because it's harder to debug
|
// avoid offline in dev mode because it's harder to debug
|
||||||
OfflinePluginRuntime.install();
|
// https://github.com/NekR/offline-plugin/pull/201#issuecomment-285133572
|
||||||
|
OfflinePluginRuntime.install({
|
||||||
|
onUpdateReady: function() {
|
||||||
|
store.dispatch(snackbar.show('info', messages.updateText, {
|
||||||
|
actionLabel: messages.update,
|
||||||
|
action: () => {
|
||||||
|
store.dispatch(setSwUpdating(true));
|
||||||
|
OfflinePluginRuntime.applyUpdate();
|
||||||
|
},
|
||||||
|
dismissAfter: false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onUpdated: function() {
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
perf.stop('main()');
|
perf.stop('main()');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { Record as ImmutableRecord } from 'immutable';
|
import { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import { SW_UPDATING, setSwUpdating } from 'soapbox/actions/sw';
|
||||||
|
|
||||||
import reducer from '../meta';
|
import reducer from '../meta';
|
||||||
|
|
||||||
describe('meta reducer', () => {
|
describe('meta reducer', () => {
|
||||||
|
@ -7,5 +9,13 @@ describe('meta reducer', () => {
|
||||||
const result = reducer(undefined, {});
|
const result = reducer(undefined, {});
|
||||||
expect(ImmutableRecord.isRecord(result)).toBe(true);
|
expect(ImmutableRecord.isRecord(result)).toBe(true);
|
||||||
expect(result.instance_fetch_failed).toBe(false);
|
expect(result.instance_fetch_failed).toBe(false);
|
||||||
|
expect(result.swUpdating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(SW_UPDATING, () => {
|
||||||
|
it('sets swUpdating to the provided value', () => {
|
||||||
|
const result = reducer(undefined, setSwUpdating(true));
|
||||||
|
expect(result.swUpdating).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,6 +13,8 @@ const AlertRecord = ImmutableRecord({
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
actionLabel: '',
|
actionLabel: '',
|
||||||
actionLink: '',
|
actionLink: '',
|
||||||
|
action: () => {},
|
||||||
|
dismissAfter: 6000 as number | false,
|
||||||
});
|
});
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
@ -21,20 +23,20 @@ type PlainAlert = Record<string, any>;
|
||||||
type Alert = ReturnType<typeof AlertRecord>;
|
type Alert = ReturnType<typeof AlertRecord>;
|
||||||
type State = ImmutableList<Alert>;
|
type State = ImmutableList<Alert>;
|
||||||
|
|
||||||
// Get next key based on last alert
|
/** Get next key based on last alert. */
|
||||||
const getNextKey = (state: State): number => {
|
const getNextKey = (state: State): number => {
|
||||||
const last = state.last();
|
const last = state.last();
|
||||||
return last ? last.key + 1 : 0;
|
return last ? last.key + 1 : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import the alert
|
/** Import the alert. */
|
||||||
const importAlert = (state: State, alert: PlainAlert): State => {
|
const importAlert = (state: State, alert: PlainAlert): State => {
|
||||||
const key = getNextKey(state);
|
const key = getNextKey(state);
|
||||||
const record = AlertRecord({ ...alert, key });
|
const record = AlertRecord({ ...alert, key });
|
||||||
return state.push(record);
|
return state.push(record);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete an alert by its key
|
/** Delete an alert by its key. */
|
||||||
const deleteAlert = (state: State, alert: PlainAlert): State => {
|
const deleteAlert = (state: State, alert: PlainAlert): State => {
|
||||||
return state.filterNot(item => item.key === alert.key);
|
return state.filterNot(item => item.key === alert.key);
|
||||||
};
|
};
|
||||||
|
@ -51,3 +53,7 @@ export default function alerts(state: State = ImmutableList<Alert>(), action: An
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Alert,
|
||||||
|
};
|
||||||
|
|
|
@ -3,11 +3,15 @@
|
||||||
import { Record as ImmutableRecord } from 'immutable';
|
import { Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
import { fetchInstance } from 'soapbox/actions/instance';
|
import { fetchInstance } from 'soapbox/actions/instance';
|
||||||
|
import { SW_UPDATING } from 'soapbox/actions/sw';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
/** Whether /api/v1/instance 404'd (and we should display the external auth form). */
|
||||||
instance_fetch_failed: false,
|
instance_fetch_failed: false,
|
||||||
|
/** Whether the ServiceWorker is currently updating (and we should display a loading screen). */
|
||||||
|
swUpdating: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function meta(state = ReducerRecord(), action: AnyAction) {
|
export default function meta(state = ReducerRecord(), action: AnyAction) {
|
||||||
|
@ -17,6 +21,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) {
|
||||||
return state.set('instance_fetch_failed', true);
|
return state.set('instance_fetch_failed', true);
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
|
case SW_UPDATING:
|
||||||
|
return state.set('swUpdating', action.isUpdating);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,30 +178,6 @@ export const makeGetStatus = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAlertsBase = (state: RootState) => state.alerts;
|
|
||||||
|
|
||||||
const buildAlert = (item: any) => {
|
|
||||||
return {
|
|
||||||
message: item.message,
|
|
||||||
title: item.title,
|
|
||||||
actionLabel: item.actionLabel,
|
|
||||||
actionLink: item.actionLink,
|
|
||||||
key: item.key,
|
|
||||||
className: `notification-bar-${item.severity}`,
|
|
||||||
activeClassName: 'snackbar--active',
|
|
||||||
dismissAfter: 6000,
|
|
||||||
style: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Alert = ReturnType<typeof buildAlert>;
|
|
||||||
|
|
||||||
export const getAlerts = createSelector([getAlertsBase], (base): Alert[] => {
|
|
||||||
const arr: Alert[] = [];
|
|
||||||
base.forEach(item => arr.push(buildAlert(item)));
|
|
||||||
return arr;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const makeGetNotification = () => {
|
export const makeGetNotification = () => {
|
||||||
return createSelector([
|
return createSelector([
|
||||||
(_state: RootState, notification: Notification) => notification,
|
(_state: RootState, notification: Notification) => notification,
|
||||||
|
|
Binary file not shown.
Loading…
Reference in a new issue