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
virtuoso-debug
Alex Gleason 2022-06-27 14:48:42 +00:00
commit 8e7d3d45a9
12 zmienionych plików z 179 dodań i 104 usunięć

Wyświetl plik

@ -5,6 +5,7 @@ import { httpErrorMessages } from 'soapbox/utils/errors';
import type { SnackbarActionSeverity } from './snackbar';
import type { AnyAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
import type { NotificationObject } from 'react-notification';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
@ -17,7 +18,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR';
const noOp = () => { };
function dismissAlert(alert: any) {
function dismissAlert(alert: NotificationObject) {
return {
type: ALERT_DISMISS,
alert,

Wyświetl plik

@ -2,34 +2,45 @@ import { ALERT_SHOW } from './alerts';
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 = {
type: typeof ALERT_SHOW
message: SnackbarMessage
actionLabel?: SnackbarMessage
actionLink?: string
severity: SnackbarActionSeverity
}
type: typeof ALERT_SHOW,
message: SnackbarMessage,
actionLabel?: SnackbarMessage,
actionLink?: string,
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,
message,
actionLabel,
actionLink,
severity,
...opts,
});
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) =>
show('success', message, actionLabel, actionLink);
show('success', message, { actionLabel, actionLink });
export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) =>
show('error', message, actionLabel, actionLink);
show('error', message, { actionLabel, actionLink });
export default {
info,

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -78,6 +78,7 @@ const SoapboxMount = () => {
const settings = useSettings();
const soapboxConfig = useSoapboxConfig();
const features = useFeatures();
const swUpdating = useAppSelector(state => state.meta.swUpdating);
const locale = validLocale(settings.get('locale')) ? settings.get('locale') : 'en';
@ -120,6 +121,7 @@ const SoapboxMount = () => {
me && !account,
!isLoaded,
localeLoading,
swUpdating,
].some(Boolean);
const bodyClass = classNames('bg-white dark:bg-slate-900 text-base h-full', {

Wyświetl plik

@ -1,61 +0,0 @@
import React from 'react';
import { injectIntl } from 'react-intl';
import { NotificationStack } from 'react-notification';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { dismissAlert } from '../../../actions/alerts';
import { getAlerts } from '../../../selectors';
const CustomNotificationStack = (props) => (
<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 {...props} />
</div>
);
const defaultBarStyleFactory = (index, style, notification) => {
return Object.assign(
{},
style,
{ bottom: `${14 + index * 12 + index * 42}px` },
);
};
const mapStateToProps = (state, { intl }) => {
const notifications = getAlerts(state);
notifications.forEach(notification => {
['title', 'message', 'actionLabel'].forEach(key => {
const value = notification[key];
if (typeof value === 'object') {
notification[key] = intl.formatMessage(value);
}
});
if (notification.actionLabel) {
notification.action = (
<Link to={notification.actionLink}>
{notification.actionLabel}
</Link>
);
}
});
return { notifications, linkComponent: Link };
};
const mapDispatchToProps = (dispatch) => {
const onDismiss = alert => {
dispatch(dismissAlert(alert));
};
return {
onDismiss,
onClick: onDismiss,
barStyleFactory: defaultBarStyleFactory,
activeBarStyleFactory: defaultBarStyleFactory,
};
};
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(CustomNotificationStack));

Wyświetl plik

@ -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;

Wyświetl plik

@ -4,8 +4,12 @@ import './precheck';
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
import React from 'react';
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 { store } from 'soapbox/store';
import { printConsoleWarning } from 'soapbox/utils/console';
import { default as Soapbox } from './containers/soapbox';
@ -13,6 +17,11 @@ import * as monitoring from './monitoring';
import * as perf from './performance';
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() {
perf.start('main()');
@ -31,7 +40,22 @@ function main() {
if (BuildConfig.NODE_ENV === 'production') {
// 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()');
});

Wyświetl plik

@ -1,5 +1,7 @@
import { Record as ImmutableRecord } from 'immutable';
import { SW_UPDATING, setSwUpdating } from 'soapbox/actions/sw';
import reducer from '../meta';
describe('meta reducer', () => {
@ -7,5 +9,13 @@ describe('meta reducer', () => {
const result = reducer(undefined, {});
expect(ImmutableRecord.isRecord(result)).toBe(true);
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);
});
});
});

Wyświetl plik

@ -13,6 +13,8 @@ const AlertRecord = ImmutableRecord({
severity: 'info',
actionLabel: '',
actionLink: '',
action: () => {},
dismissAfter: 6000 as number | false,
});
import type { AnyAction } from 'redux';
@ -21,20 +23,20 @@ type PlainAlert = Record<string, any>;
type Alert = ReturnType<typeof AlertRecord>;
type State = ImmutableList<Alert>;
// Get next key based on last alert
/** Get next key based on last alert. */
const getNextKey = (state: State): number => {
const last = state.last();
return last ? last.key + 1 : 0;
};
// Import the alert
/** Import the alert. */
const importAlert = (state: State, alert: PlainAlert): State => {
const key = getNextKey(state);
const record = AlertRecord({ ...alert, key });
return state.push(record);
};
// Delete an alert by its key
/** Delete an alert by its key. */
const deleteAlert = (state: State, alert: PlainAlert): State => {
return state.filterNot(item => item.key === alert.key);
};
@ -51,3 +53,7 @@ export default function alerts(state: State = ImmutableList<Alert>(), action: An
return state;
}
}
export {
Alert,
};

Wyświetl plik

@ -3,11 +3,15 @@
import { Record as ImmutableRecord } from 'immutable';
import { fetchInstance } from 'soapbox/actions/instance';
import { SW_UPDATING } from 'soapbox/actions/sw';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({
/** Whether /api/v1/instance 404'd (and we should display the external auth form). */
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) {
@ -17,6 +21,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) {
return state.set('instance_fetch_failed', true);
}
return state;
case SW_UPDATING:
return state.set('swUpdating', action.isUpdating);
default:
return state;
}

Wyświetl plik

@ -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 = () => {
return createSelector([
(_state: RootState, notification: Notification) => notification,

Wyświetl plik

@ -85,6 +85,7 @@ module.exports = merge(sharedConfig, {
ServiceWorker: {
cacheName: 'soapbox',
entry: join(__dirname, '../app/soapbox/service_worker/entry.ts'),
events: true,
minify: true,
},
cacheMaps: [{