diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts index a521f3ef35..4fd293e252 100644 --- a/app/javascript/mastodon/actions/alerts.ts +++ b/app/javascript/mastodon/actions/alerts.ts @@ -1,14 +1,11 @@ import { defineMessages } from 'react-intl'; -import type { MessageDescriptor } from 'react-intl'; + +import { createAction } from '@reduxjs/toolkit'; import { AxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; -interface Alert { - title: string | MessageDescriptor; - message: string | MessageDescriptor; - values?: Record; -} +import type { Alert } from 'mastodon/models/alert'; interface ApiErrorResponse { error?: string; @@ -30,24 +27,13 @@ const messages = defineMessages({ }, }); -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; -export const ALERT_NOOP = 'ALERT_NOOP'; +export const dismissAlert = createAction<{ key: number }>('alerts/dismiss'); -export const dismissAlert = (alert: Alert) => ({ - type: ALERT_DISMISS, - alert, -}); +export const clearAlerts = createAction('alerts/clear'); -export const clearAlert = () => ({ - type: ALERT_CLEAR, -}); +export const showAlert = createAction>('alerts/show'); -export const showAlert = (alert: Alert) => ({ - type: ALERT_SHOW, - alert, -}); +const ignoreAlert = createAction('alerts/ignore'); export const showAlertForError = (error: unknown, skipNotFound = false) => { if (error instanceof AxiosError && error.response) { @@ -56,7 +42,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - return { type: ALERT_NOOP }; + return ignoreAlert(); } // Rate limit errors @@ -76,9 +62,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => { }); } - // An aborted request, e.g. due to reloading the browser window, it not really error + // An aborted request, e.g. due to reloading the browser window, is not really an error if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) { - return { type: ALERT_NOOP }; + return ignoreAlert(); } console.error(error); diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx new file mode 100644 index 0000000000..26749fa103 --- /dev/null +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -0,0 +1,105 @@ +import { useState, useEffect } from 'react'; + +import { useIntl } from 'react-intl'; +import type { IntlShape } from 'react-intl'; + +import classNames from 'classnames'; + +import { dismissAlert } from 'mastodon/actions/alerts'; +import type { + Alert, + TranslatableString, + TranslatableValues, +} from 'mastodon/models/alert'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +const formatIfNeeded = ( + intl: IntlShape, + message: TranslatableString, + values?: TranslatableValues, +) => { + if (typeof message === 'object') { + return intl.formatMessage(message, values); + } + + return message; +}; + +const Alert: React.FC<{ + alert: Alert; + dismissAfter: number; +}> = ({ + alert: { key, title, message, values, action, onClick }, + dismissAfter, +}) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const [active, setActive] = useState(false); + + useEffect(() => { + const setActiveTimeout = setTimeout(() => { + setActive(true); + }, 1); + + return () => { + clearTimeout(setActiveTimeout); + }; + }, []); + + useEffect(() => { + const dismissTimeout = setTimeout(() => { + setActive(false); + + // Allow CSS transition to finish before removing from the DOM + setTimeout(() => { + dispatch(dismissAlert({ key })); + }, 500); + }, dismissAfter); + + return () => { + clearTimeout(dismissTimeout); + }; + }, [dispatch, setActive, key, dismissAfter]); + + return ( +
+
+ {title && ( + + {formatIfNeeded(intl, title, values)} + + )} + + + {formatIfNeeded(intl, message, values)} + + + {action && ( + + )} +
+
+ ); +}; + +export const AlertsController: React.FC = () => { + const alerts = useAppSelector((state) => state.alerts); + + if (alerts.length === 0) { + return null; + } + + return ( +
+ {alerts.map((alert, idx) => ( + + ))} +
+ ); +}; diff --git a/app/javascript/mastodon/features/standalone/compose/index.jsx b/app/javascript/mastodon/features/standalone/compose/index.jsx index 241d9aadfd..3aff78ffee 100644 --- a/app/javascript/mastodon/features/standalone/compose/index.jsx +++ b/app/javascript/mastodon/features/standalone/compose/index.jsx @@ -1,12 +1,12 @@ +import { AlertsController } from 'mastodon/components/alerts_controller'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container'; import ModalContainer from 'mastodon/features/ui/containers/modal_container'; -import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container'; const Compose = () => ( <> - + diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js deleted file mode 100644 index b8aa9bc461..0000000000 --- a/app/javascript/mastodon/features/ui/containers/notifications_container.js +++ /dev/null @@ -1,20 +0,0 @@ -import { injectIntl } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { NotificationStack } from 'react-notification'; - -import { dismissAlert } from 'mastodon/actions/alerts'; -import { getAlerts } from 'mastodon/selectors'; - -const mapStateToProps = (state, { intl }) => ({ - notifications: getAlerts(state, { intl }), -}); - -const mapDispatchToProps = (dispatch) => ({ - onDismiss (alert) { - dispatch(dismissAlert(alert)); - }, -}); - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 79a6a364e1..d5ff6d148c 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -15,6 +15,7 @@ import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { fetchNotifications } from 'mastodon/actions/notification_groups'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; +import { AlertsController } from 'mastodon/components/alerts_controller'; import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -33,7 +34,6 @@ import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; -import NotificationsContainer from './containers/notifications_container'; import { Compose, Status, @@ -607,7 +607,7 @@ class UI extends PureComponent { {layout !== 'mobile' && } - + {!disableHoverCards && } diff --git a/app/javascript/mastodon/models/alert.ts b/app/javascript/mastodon/models/alert.ts new file mode 100644 index 0000000000..bc492eff3c --- /dev/null +++ b/app/javascript/mastodon/models/alert.ts @@ -0,0 +1,14 @@ +import type { MessageDescriptor } from 'react-intl'; + +export type TranslatableString = string | MessageDescriptor; + +export type TranslatableValues = Record; + +export interface Alert { + key: number; + title?: TranslatableString; + message: TranslatableString; + action?: TranslatableString; + values?: TranslatableValues; + onClick?: () => void; +} diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js deleted file mode 100644 index 1ca9b62a02..0000000000 --- a/app/javascript/mastodon/reducers/alerts.js +++ /dev/null @@ -1,30 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { - ALERT_SHOW, - ALERT_DISMISS, - ALERT_CLEAR, -} from '../actions/alerts'; - -const initialState = ImmutableList([]); - -let id = 0; - -const addAlert = (state, alert) => - state.push({ - key: id++, - ...alert, - }); - -export default function alerts(state = initialState, action) { - switch(action.type) { - case ALERT_SHOW: - return addAlert(state, action.alert); - case ALERT_DISMISS: - return state.filterNot(item => item.key === action.alert.key); - case ALERT_CLEAR: - return state.clear(); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/alerts.ts b/app/javascript/mastodon/reducers/alerts.ts new file mode 100644 index 0000000000..30108744ae --- /dev/null +++ b/app/javascript/mastodon/reducers/alerts.ts @@ -0,0 +1,24 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { showAlert, dismissAlert, clearAlerts } from 'mastodon/actions/alerts'; +import type { Alert } from 'mastodon/models/alert'; + +const initialState: Alert[] = []; + +let id = 0; + +export const alertsReducer = createReducer(initialState, (builder) => { + builder + .addCase(showAlert, (state, { payload }) => { + state.push({ + key: id++, + ...payload, + }); + }) + .addCase(dismissAlert, (state, { payload: { key } }) => { + return state.filter((item) => item.key !== key); + }) + .addCase(clearAlerts, () => { + return []; + }); +}); diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 2d181b6754..08ec2e58a4 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -5,7 +5,7 @@ import { combineReducers } from 'redux-immutable'; import { accountsReducer } from './accounts'; import accounts_map from './accounts_map'; -import alerts from './alerts'; +import { alertsReducer } from './alerts'; import announcements from './announcements'; import { composeReducer } from './compose'; import contexts from './contexts'; @@ -45,7 +45,7 @@ const reducers = { dropdownMenu: dropdownMenuReducer, timelines, meta, - alerts, + alerts: alertsReducer, loadingBar: loadingBarReducer, modal: modalReducer, user_lists, diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index dfd6f19893..9e6daf45fa 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -60,28 +60,6 @@ export const makeGetPictureInPicture = () => { })); }; -const ALERT_DEFAULTS = { - dismissAfter: 5000, - style: false, -}; - -const formatIfNeeded = (intl, message, values) => { - if (typeof message === 'object') { - return intl.formatMessage(message, values); - } - - return message; -}; - -export const getAlerts = createSelector([state => state.get('alerts'), (_, { intl }) => intl], (alerts, intl) => - alerts.map(item => ({ - ...ALERT_DEFAULTS, - ...item, - action: formatIfNeeded(intl, item.action, item.values), - title: formatIfNeeded(intl, item.title, item.values), - message: formatIfNeeded(intl, item.message, item.values), - })).toArray()); - export const makeGetNotification = () => createSelector([ (_, base) => base, (state, _, accountId) => state.getIn(['accounts', accountId]), diff --git a/app/javascript/mastodon/store/middlewares/errors.ts b/app/javascript/mastodon/store/middlewares/errors.ts index 3ad3844d5b..b9efe9f2b4 100644 --- a/app/javascript/mastodon/store/middlewares/errors.ts +++ b/app/javascript/mastodon/store/middlewares/errors.ts @@ -12,19 +12,21 @@ import type { AsyncThunkRejectValue } from '../typed_functions'; const defaultFailSuffix = 'FAIL'; const isFailedAction = new RegExp(`${defaultFailSuffix}$`, 'g'); -interface ActionWithMaybeAlertParams extends Action, AsyncThunkRejectValue {} - interface RejectedAction extends Action { payload: AsyncThunkRejectValue; } +interface ActionWithMaybeAlertParams extends Action, AsyncThunkRejectValue { + payload?: AsyncThunkRejectValue; +} + function isRejectedActionWithPayload( action: unknown, ): action is RejectedAction { return isAsyncThunkAction(action) && isRejectedWithValue(action); } -function isActionWithmaybeAlertParams( +function isActionWithMaybeAlertParams( action: unknown, ): action is ActionWithMaybeAlertParams { return isAction(action); @@ -40,11 +42,12 @@ export const errorsMiddleware: Middleware<{}, RootState> = showAlertForError(action.payload.error, action.payload.skipNotFound), ); } else if ( - isActionWithmaybeAlertParams(action) && - !action.skipAlert && + isActionWithMaybeAlertParams(action) && + !(action.payload?.skipAlert || action.skipAlert) && action.type.match(isFailedAction) ) { - dispatch(showAlertForError(action.error, action.skipNotFound)); + const { error, skipNotFound } = action.payload ?? action; + dispatch(showAlertForError(error, skipNotFound)); } return next(action); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 800cb473f7..7e045a0d8a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9732,6 +9732,9 @@ noscript { } .notification-bar-action { + display: inline-block; + border: 0; + background: transparent; text-transform: uppercase; margin-inline-start: 10px; cursor: pointer; diff --git a/package.json b/package.json index c6b76527c6..38c8a3abb1 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,6 @@ "react-immutable-pure-component": "^2.2.2", "react-intl": "^7.0.0", "react-motion": "^0.5.2", - "react-notification": "^6.8.5", "react-overlays": "^5.2.1", "react-redux": "^9.0.4", "react-redux-loading-bar": "^5.0.8", diff --git a/yarn.lock b/yarn.lock index ef3ef8f7e9..66e69f563b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2851,7 +2851,6 @@ __metadata: react-immutable-pure-component: "npm:^2.2.2" react-intl: "npm:^7.0.0" react-motion: "npm:^0.5.2" - react-notification: "npm:^6.8.5" react-overlays: "npm:^5.2.1" react-redux: "npm:^9.0.4" react-redux-loading-bar: "npm:^5.0.8" @@ -14791,17 +14790,6 @@ __metadata: languageName: node linkType: hard -"react-notification@npm:^6.8.5": - version: 6.8.5 - resolution: "react-notification@npm:6.8.5" - dependencies: - prop-types: "npm:^15.6.2" - peerDependencies: - react: ^0.14.0 || ^15.0.0 || ^16.0.0 - checksum: 10c0/14ffb71a5b18301830699b814d1de2421f4f43f31df5b95efd95cd47548a0d7597ec58abc16a12191958cad398495eba9274193af3294863e2864d32ea79f2c6 - languageName: node - linkType: hard - "react-overlays@npm:^5.2.1": version: 5.2.1 resolution: "react-overlays@npm:5.2.1"