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 = ( +