diff --git a/app/soapbox/actions/__tests__/alerts.test.ts b/app/soapbox/actions/__tests__/alerts.test.ts deleted file mode 100644 index 5f1f9f4d6..000000000 --- a/app/soapbox/actions/__tests__/alerts.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { AxiosError } from 'axios'; - -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; - -import { dismissAlert, showAlert, showAlertForError } from '../alerts'; - -const buildError = (message: string, status: number) => new AxiosError(message, String(status), undefined, null, { - data: { - error: message, - }, - statusText: String(status), - status, - headers: {}, - config: {}, -}); - -let store: ReturnType; - -beforeEach(() => { - const state = rootState; - store = mockStore(state); -}); - -describe('dismissAlert()', () => { - it('dispatches the proper actions', async() => { - const alert = 'hello world'; - const expectedActions = [ - { type: 'ALERT_DISMISS', alert }, - ]; - await store.dispatch(dismissAlert(alert as any)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - it('dispatches the proper actions', async() => { - const title = 'title'; - const message = 'msg'; - const severity = 'info'; - const expectedActions = [ - { type: 'ALERT_SHOW', title, message, severity }, - ]; - await store.dispatch(showAlert(title, message, severity)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); -}); - -describe('showAlert()', () => { - describe('with a 502 status code', () => { - it('dispatches the proper actions', async() => { - const message = 'The server is down'; - const error = buildError(message, 502); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with a 404 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 404); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with a 410 status code', () => { - it('dispatches the proper actions', async() => { - const error = buildError('', 410); - - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with an accepted status code', () => { - describe('with a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'custom message'; - const error = buildError(message, 200); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('without a message from the server', () => { - it('dispatches the proper actions', async() => { - const message = 'The request has been accepted for processing'; - const error = buildError(message, 202); - - const expectedActions = [ - { type: 'ALERT_SHOW', title: '', message, severity: 'error' }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); - - describe('without a response', () => { - it('dispatches the proper actions', async() => { - const error = new AxiosError(); - - const expectedActions = [ - { - type: 'ALERT_SHOW', - title: { - defaultMessage: 'Oops!', - id: 'alert.unexpected.title', - }, - message: { - defaultMessage: 'An unexpected error occurred.', - id: 'alert.unexpected.message', - }, - severity: 'error', - }, - ]; - await store.dispatch(showAlertForError(error)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/app/soapbox/actions/alerts.ts b/app/soapbox/actions/alerts.ts deleted file mode 100644 index 8f200563a..000000000 --- a/app/soapbox/actions/alerts.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineMessages, MessageDescriptor } from 'react-intl'; - -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!' }, - unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, -}); - -export const ALERT_SHOW = 'ALERT_SHOW'; -export const ALERT_DISMISS = 'ALERT_DISMISS'; -export const ALERT_CLEAR = 'ALERT_CLEAR'; - -const noOp = () => { }; - -function dismissAlert(alert: NotificationObject) { - return { - type: ALERT_DISMISS, - alert, - }; -} - -function showAlert( - title: MessageDescriptor | string = messages.unexpectedTitle, - message: MessageDescriptor | string = messages.unexpectedMessage, - severity: SnackbarActionSeverity = 'info', -) { - return { - type: ALERT_SHOW, - title, - message, - severity, - }; -} - -const showAlertForError = (error: AxiosError) => (dispatch: React.Dispatch, _getState: any) => { - if (error?.response) { - const { data, status, statusText } = error.response; - - if (status === 502) { - return dispatch(showAlert('', 'The server is down', 'error')); - } - - if (status === 404 || status === 410) { - // Skip these errors as they are reflected in the UI - return dispatch(noOp as any); - } - - let message: string | undefined = statusText; - - if (data?.error) { - message = data.error; - } - - if (!message) { - message = httpErrorMessages.find((httpError) => httpError.code === status)?.description; - } - - return dispatch(showAlert('', message, 'error')); - } else { - console.error(error); - return dispatch(showAlert(undefined, undefined, 'error')); - } -}; - -export { - dismissAlert, - showAlert, - showAlertForError, -}; diff --git a/app/soapbox/actions/aliases.ts b/app/soapbox/actions/aliases.ts index dc660d3ea..3a5b61163 100644 --- a/app/soapbox/actions/aliases.ts +++ b/app/soapbox/actions/aliases.ts @@ -6,7 +6,6 @@ import { getFeatures } from 'soapbox/utils/features'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import { patchMeSuccess } from './me'; @@ -80,7 +79,7 @@ const fetchAliasesSuggestions = (q: string) => api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchAliasesSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchAliasesSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index 6b7886898..38efc838e 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -12,7 +12,6 @@ import { getFeatures, parseVersion } from 'soapbox/utils/features'; import { formatBytes, getVideoDuration } from 'soapbox/utils/media'; import resizeImage from 'soapbox/utils/resize-image'; -import { showAlert, showAlertForError } from './alerts'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; import { uploadMedia, fetchMedia, updateMedia } from './media'; @@ -93,7 +92,7 @@ const messages = defineMessages({ editSuccess: { id: 'compose.edit_success', defaultMessage: 'Your post was edited' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); @@ -333,7 +332,7 @@ const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) => const mediaCount = media ? media.size : 0; if (files.length + mediaCount > attachmentLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); + toast.error(messages.uploadErrorLimit); return; } @@ -499,7 +498,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data)); }).catch(error => { if (!isCancel(error)) { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); } }); }, 200, { leading: true, trailing: true }); diff --git a/app/soapbox/actions/events.ts b/app/soapbox/actions/events.ts index 2699b6b19..d4ec49491 100644 --- a/app/soapbox/actions/events.ts +++ b/app/soapbox/actions/events.ts @@ -91,7 +91,7 @@ const messages = defineMessages({ editSuccess: { id: 'compose_event.edit_success', defaultMessage: 'Your event was edited' }, joinSuccess: { id: 'join_event.success', defaultMessage: 'Joined the event' }, joinRequestSuccess: { id: 'join_event.request_success', defaultMessage: 'Requested to join the event' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, authorized: { id: 'compose_event.participation_requests.authorize_success', defaultMessage: 'User accepted' }, rejected: { id: 'compose_event.participation_requests.reject_success', defaultMessage: 'User rejected' }, }); diff --git a/app/soapbox/actions/export-data.ts b/app/soapbox/actions/export-data.ts index cbfaef82e..1ddab9103 100644 --- a/app/soapbox/actions/export-data.ts +++ b/app/soapbox/actions/export-data.ts @@ -4,7 +4,6 @@ import api, { getLinks } from 'soapbox/api'; import { normalizeAccount } from 'soapbox/normalizers'; import toast from 'soapbox/toast'; -import type { SnackbarAction } from './snackbar'; import type { AxiosResponse } from 'axios'; import type { RootState } from 'soapbox/store'; @@ -37,7 +36,7 @@ type ExportDataActions = { | typeof EXPORT_MUTES_SUCCESS | typeof EXPORT_MUTES_FAIL, error?: any, -} | SnackbarAction +} function fileExport(content: string, fileName: string) { const fileToDownload = document.createElement('a'); diff --git a/app/soapbox/actions/import-data.ts b/app/soapbox/actions/import-data.ts index 47e24caa5..90f81e7e7 100644 --- a/app/soapbox/actions/import-data.ts +++ b/app/soapbox/actions/import-data.ts @@ -4,7 +4,6 @@ import toast from 'soapbox/toast'; import api from '../api'; -import type { SnackbarAction } from './snackbar'; import type { RootState } from 'soapbox/store'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; @@ -31,7 +30,7 @@ type ImportDataActions = { | typeof IMPORT_MUTES_FAIL, error?: any, config?: string -} | SnackbarAction +} const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index 554844609..9e43d0f40 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -63,7 +63,7 @@ const REMOTE_INTERACTION_FAIL = 'REMOTE_INTERACTION_FAIL'; const messages = defineMessages({ bookmarkAdded: { id: 'status.bookmarked', defaultMessage: 'Bookmark added.' }, bookmarkRemoved: { id: 'status.unbookmarked', defaultMessage: 'Bookmark removed.' }, - view: { id: 'snackbar.view', defaultMessage: 'View' }, + view: { id: 'toast.view', defaultMessage: 'View' }, }); const reblog = (status: StatusEntity) => diff --git a/app/soapbox/actions/lists.ts b/app/soapbox/actions/lists.ts index bf7dba8ba..216fae669 100644 --- a/app/soapbox/actions/lists.ts +++ b/app/soapbox/actions/lists.ts @@ -1,8 +1,8 @@ +import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; import api from '../api'; -import { showAlertForError } from './alerts'; import { importFetchedAccounts } from './importer'; import type { AxiosError } from 'axios'; @@ -265,7 +265,7 @@ const fetchListSuggestions = (q: string) => (dispatch: AppDispatch, getState: () api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { dispatch(importFetchedAccounts(data)); dispatch(fetchListSuggestionsReady(q, data)); - }).catch(error => dispatch(showAlertForError(error))); + }).catch(error => toast.showAlertForError(error)); }; const fetchListSuggestionsReady = (query: string, accounts: APIEntity[]) => ({ diff --git a/app/soapbox/actions/settings.ts b/app/soapbox/actions/settings.ts index 9c2399ace..a52ba2255 100644 --- a/app/soapbox/actions/settings.ts +++ b/app/soapbox/actions/settings.ts @@ -7,8 +7,6 @@ import { patchMe } from 'soapbox/actions/me'; import toast from 'soapbox/toast'; import { isLoggedIn } from 'soapbox/utils/auth'; -import { showAlertForError } from './alerts'; - import type { AppDispatch, RootState } from 'soapbox/store'; const SETTING_CHANGE = 'SETTING_CHANGE'; @@ -225,7 +223,7 @@ const saveSettingsImmediate = (opts?: SettingOpts) => toast.success(messages.saveSuccess); } }).catch(error => { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); }); }; diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts deleted file mode 100644 index 57d23b64b..000000000 --- a/app/soapbox/actions/snackbar.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ALERT_SHOW } from './alerts'; - -import type { MessageDescriptor } from 'react-intl'; - -export type SnackbarActionSeverity = 'info' | 'success' | 'error'; - -type SnackbarMessage = string | MessageDescriptor; - -export type SnackbarAction = { - type: typeof ALERT_SHOW, - message: SnackbarMessage, - actionLabel?: SnackbarMessage, - actionLink?: string, - action?: () => void, - severity: SnackbarActionSeverity, -}; - -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, - severity, - ...opts, -}); - -export const info = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('info', message, { actionLabel, actionLink }); - -export const success = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('success', message, { actionLabel, actionLink }); - -export const error = (message: SnackbarMessage, actionLabel?: SnackbarMessage, actionLink?: string) => - show('error', message, { actionLabel, actionLink }); - -export default { - info, - success, - error, - show, -}; diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 8872af678..760fb6253 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -4,7 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { blockAccount } from 'soapbox/actions/accounts'; -import { showAlertForError } from 'soapbox/actions/alerts'; import { launchChat } from 'soapbox/actions/chats'; import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; import { editEvent } from 'soapbox/actions/events'; @@ -19,6 +18,7 @@ import StatusActionButton from 'soapbox/components/status-action-button'; import { HStack } from 'soapbox/components/ui'; import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; import { isLocal, isRemote } from 'soapbox/utils/accounts'; import copy from 'soapbox/utils/copy'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts'; @@ -254,7 +254,7 @@ const StatusActionBar: React.FC = ({ const handleEmbed = () => { dispatch(openModal('EMBED', { url: status.get('url'), - onError: (error: any) => dispatch(showAlertForError(error)), + onError: (error: any) => toast.showAlertForError(error), })); }; diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index cccfae997..53a0b684d 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -26,7 +26,6 @@ import PublicLayout from 'soapbox/features/public-layout'; import BundleContainer from 'soapbox/features/ui/containers/bundle-container'; import { ModalContainer, - NotificationsContainer, OnboardingWizard, WaitlistPage, } from 'soapbox/features/ui/util/async-components'; @@ -187,10 +186,6 @@ const SoapboxMount = () => { {renderBody()} - - {(Component) => } - - {Component => } diff --git a/app/soapbox/features/developers/developers-menu.tsx b/app/soapbox/features/developers/developers-menu.tsx index f383f3b04..070c0f95e 100644 --- a/app/soapbox/features/developers/developers-menu.tsx +++ b/app/soapbox/features/developers/developers-menu.tsx @@ -43,7 +43,7 @@ const Developers: React.FC = () => { history.push('/'); }; - const showSnackbar = (event: React.MouseEvent) => { + const showToast = (event: React.MouseEvent) => { event.preventDefault(); toast.success('Hello world!', { @@ -112,11 +112,11 @@ const Developers: React.FC = () => { - + - + diff --git a/app/soapbox/features/developers/settings-store.tsx b/app/soapbox/features/developers/settings-store.tsx index 6342ef1ae..a32f69c96 100644 --- a/app/soapbox/features/developers/settings-store.tsx +++ b/app/soapbox/features/developers/settings-store.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { showAlertForError } from 'soapbox/actions/alerts'; import { patchMe } from 'soapbox/actions/me'; import { FE_NAME, SETTINGS_UPDATE, changeSetting } from 'soapbox/actions/settings'; import List, { ListItem } from 'soapbox/components/list'; @@ -17,6 +16,7 @@ import { } from 'soapbox/components/ui'; import SettingToggle from 'soapbox/features/notifications/components/setting-toggle'; import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; const isJSONValid = (text: any): boolean => { try { @@ -65,7 +65,7 @@ const SettingsStore: React.FC = () => { dispatch({ type: SETTINGS_UPDATE, settings }); setLoading(false); }).catch(error => { - dispatch(showAlertForError(error)); + toast.showAlertForError(error); setLoading(false); }); }; diff --git a/app/soapbox/features/ui/containers/notifications-container.tsx b/app/soapbox/features/ui/containers/notifications-container.tsx deleted file mode 100644 index 2119228bf..000000000 --- a/app/soapbox/features/ui/containers/notifications-container.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 = ( -