diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts
index bbb492e42..8f200563a 100644
--- a/app/soapbox/actions/alerts.ts
+++ b/app/soapbox/actions/alerts.ts
@@ -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,
diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts
index c2fd5f32d..57d23b64b 100644
--- a/app/soapbox/actions/snackbar.ts
+++ b/app/soapbox/actions/snackbar.ts
@@ -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,
diff --git a/app/soapbox/actions/sw.ts b/app/soapbox/actions/sw.ts
new file mode 100644
index 000000000..c12dc83e8
--- /dev/null
+++ b/app/soapbox/actions/sw.ts
@@ -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,
+};
diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx
index 22c832233..e87d858d1 100644
--- a/app/soapbox/containers/soapbox.tsx
+++ b/app/soapbox/containers/soapbox.tsx
@@ -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', {
diff --git a/app/soapbox/features/ui/containers/notifications_container.js b/app/soapbox/features/ui/containers/notifications_container.js
deleted file mode 100644
index b7d10c9e4..000000000
--- a/app/soapbox/features/ui/containers/notifications_container.js
+++ /dev/null
@@ -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) => (
-
-
-
-);
-
-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 = (
-
- {notification.actionLabel}
-
- );
- }
- });
-
- 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));
diff --git a/app/soapbox/features/ui/containers/notifications_container.tsx b/app/soapbox/features/ui/containers/notifications_container.tsx
new file mode 100644
index 000000000..eafd8efe4
--- /dev/null
+++ b/app/soapbox/features/ui/containers/notifications_container.tsx
@@ -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 = (
+
+ ) 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 (
+
+
+
+ );
+};
+
+export default SnackbarContainer;
diff --git a/app/soapbox/main.tsx b/app/soapbox/main.tsx
index 9505ae07a..84ea3739a 100644
--- a/app/soapbox/main.tsx
+++ b/app/soapbox/main.tsx
@@ -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()');
});
diff --git a/app/soapbox/reducers/__tests__/meta.test.ts b/app/soapbox/reducers/__tests__/meta.test.ts
index 92ee4e6ff..d318b4a30 100644
--- a/app/soapbox/reducers/__tests__/meta.test.ts
+++ b/app/soapbox/reducers/__tests__/meta.test.ts
@@ -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);
+ });
});
});
diff --git a/app/soapbox/reducers/alerts.ts b/app/soapbox/reducers/alerts.ts
index 9221cd0db..b81c01b7f 100644
--- a/app/soapbox/reducers/alerts.ts
+++ b/app/soapbox/reducers/alerts.ts
@@ -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;
type Alert = ReturnType;
type State = ImmutableList;
-// 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(), action: An
return state;
}
}
+
+export {
+ Alert,
+};
diff --git a/app/soapbox/reducers/meta.ts b/app/soapbox/reducers/meta.ts
index f793372c8..fdce05e3e 100644
--- a/app/soapbox/reducers/meta.ts
+++ b/app/soapbox/reducers/meta.ts
@@ -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;
}
diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts
index 4b91c8033..c1ab55532 100644
--- a/app/soapbox/selectors/index.ts
+++ b/app/soapbox/selectors/index.ts
@@ -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;
-
-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,
diff --git a/webpack/production.js b/webpack/production.js
index 18666a109..c90decd4e 100644
--- a/webpack/production.js
+++ b/webpack/production.js
@@ -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: [{