sforkowany z mirror/soapbox
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!1480virtuoso-debug
commit
8e7d3d45a9
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 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', {
|
||||
|
|
|
@ -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));
|
|
@ -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 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()');
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [{
|
||||
|
|
Ładowanie…
Reference in New Issue