diff --git a/app/soapbox/actions/__tests__/auth-test.js b/app/soapbox/actions/__tests__/auth-test.js index a013c700f..0e1e3d2b4 100644 --- a/app/soapbox/actions/__tests__/auth-test.js +++ b/app/soapbox/actions/__tests__/auth-test.js @@ -10,7 +10,7 @@ describe('logOut()', () => { it('creates expected actions', () => { const expectedActions = [ { type: AUTH_LOGGED_OUT }, - { type: ALERT_SHOW, title: 'Successfully logged out.', message: '' }, + { type: ALERT_SHOW, message: 'Logged out.', severity: 'success' }, ]; const store = mockStore(ImmutableMap()); diff --git a/app/soapbox/actions/alerts.js b/app/soapbox/actions/alerts.js index 33791369f..01fdd3ccf 100644 --- a/app/soapbox/actions/alerts.js +++ b/app/soapbox/actions/alerts.js @@ -1,4 +1,3 @@ -//test import { defineMessages } from 'react-intl'; const messages = defineMessages({ @@ -23,11 +22,12 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, severity = 'info') { return { type: ALERT_SHOW, title, message, + severity, }; }; @@ -47,9 +47,9 @@ export function showAlertForError(error) { message = data.error; } - return showAlert(title, message); + return showAlert(title, message, 'error'); } else { console.error(error); - return showAlert(); + return showAlert(undefined, undefined, 'error'); } } diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 09a225bf5..3fd9578a0 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import { fetchMe } from 'soapbox/actions/me'; export const AUTH_APP_CREATED = 'AUTH_APP_CREATED'; @@ -136,7 +136,7 @@ export function logIn(username, password) { if (error.response.data.error === 'mfa_required') { throw error; } else { - dispatch(showAlert('Login failed.', 'Invalid username or password.')); + dispatch(snackbar.error('Invalid username or password.')); } throw error; }); @@ -156,7 +156,7 @@ export function logOut() { token: state.getIn(['auth', 'user', 'access_token']), }); - dispatch(showAlert('Successfully logged out.', '')); + dispatch(snackbar.success('Logged out.')); }; } @@ -172,9 +172,9 @@ export function register(params) { dispatch({ type: AUTH_REGISTER_SUCCESS, token: response.data }); dispatch(authLoggedIn(response.data)); if (needsConfirmation) { - return dispatch(showAlert('', 'Check your email for further instructions.')); + return dispatch(snackbar.info('You must confirm your email.')); } else if (needsApproval) { - return dispatch(showAlert('', 'Your account has been submitted for approval.')); + return dispatch(snackbar.info('Your account is being reviewed.')); } else { return dispatch(fetchMe()); } @@ -232,7 +232,7 @@ export function deleteAccount(password) { if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure dispatch({ type: DELETE_ACCOUNT_SUCCESS, response }); dispatch({ type: AUTH_LOGGED_OUT }); - dispatch(showAlert('Successfully logged out.', '')); + dispatch(snackbar.success('Logged out.')); }).catch(error => { dispatch({ type: DELETE_ACCOUNT_FAIL, error, skipAlert: true }); throw error; diff --git a/app/soapbox/actions/compose.js b/app/soapbox/actions/compose.js index f4a644e87..782dee74a 100644 --- a/app/soapbox/actions/compose.js +++ b/app/soapbox/actions/compose.js @@ -224,7 +224,7 @@ export function uploadCompose(files) { let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); + dispatch(showAlert(undefined, messages.uploadErrorLimit, 'error')); return; } diff --git a/app/soapbox/actions/filters.js b/app/soapbox/actions/filters.js index 3448e391c..e3ad557f5 100644 --- a/app/soapbox/actions/filters.js +++ b/app/soapbox/actions/filters.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; @@ -47,7 +47,7 @@ export function createFilter(phrase, expires_at, context, whole_word, irreversib expires_at, }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); - dispatch(showAlert('', 'Filter added')); + dispatch(snackbar.success('Filter added.')); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); @@ -60,7 +60,7 @@ export function deleteFilter(id) { dispatch({ type: FILTERS_DELETE_REQUEST }); return api(getState).delete('/api/v1/filters/'+id).then(response => { dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); - dispatch(showAlert('', 'Filter deleted')); + dispatch(snackbar.success('Filter deleted.')); }).catch(error => { dispatch({ type: FILTERS_DELETE_FAIL, error }); }); diff --git a/app/soapbox/actions/import_data.js b/app/soapbox/actions/import_data.js index 251d2972d..6a0a3c254 100644 --- a/app/soapbox/actions/import_data.js +++ b/app/soapbox/actions/import_data.js @@ -1,5 +1,5 @@ import api from '../api'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS'; @@ -19,7 +19,7 @@ export function importFollows(params) { return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(showAlert('', 'Followers imported successfully')); + dispatch(snackbar.success('Followers imported successfully')); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); @@ -33,7 +33,7 @@ export function importBlocks(params) { return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(showAlert('', 'Blocks imported successfully')); + dispatch(snackbar.success('Blocks imported successfully')); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); @@ -47,7 +47,7 @@ export function importMutes(params) { return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(showAlert('', 'Mutes imported successfully')); + dispatch(snackbar.success('Mutes imported successfully')); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); diff --git a/app/soapbox/actions/interactions.js b/app/soapbox/actions/interactions.js index 1acfa9c57..b07feac1b 100644 --- a/app/soapbox/actions/interactions.js +++ b/app/soapbox/actions/interactions.js @@ -1,6 +1,6 @@ import api from '../api'; import { importFetchedAccounts, importFetchedStatus } from './importer'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export const REBLOG_REQUEST = 'REBLOG_REQUEST'; export const REBLOG_SUCCESS = 'REBLOG_SUCCESS'; @@ -211,7 +211,7 @@ export function bookmark(status) { api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function(response) { dispatch(importFetchedStatus(response.data)); dispatch(bookmarkSuccess(status, response.data)); - dispatch(showAlert('', 'Bookmark added')); + dispatch(snackbar.success('Bookmark added')); }).catch(function(error) { dispatch(bookmarkFail(status, error)); }); @@ -225,7 +225,7 @@ export function unbookmark(status) { api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unbookmarkSuccess(status, response.data)); - dispatch(showAlert('', 'Bookmark removed')); + dispatch(snackbar.success('Bookmark removed')); }).catch(error => { dispatch(unbookmarkFail(status, error)); }); diff --git a/app/soapbox/actions/snackbar.js b/app/soapbox/actions/snackbar.js new file mode 100644 index 000000000..e6f0a6595 --- /dev/null +++ b/app/soapbox/actions/snackbar.js @@ -0,0 +1,25 @@ +import { ALERT_SHOW } from './alerts'; + +const show = (severity, message) => ({ + type: ALERT_SHOW, + message, + severity, +}); + +export function info(message) { + return show('info', message); +}; + +export function success(message) { + return show('success', message); +}; + +export function error(message) { + return show('error', message); +}; + +export default { + info, + success, + error, +}; diff --git a/app/soapbox/features/auth_login/components/password_reset.js b/app/soapbox/features/auth_login/components/password_reset.js index efc1c816c..7dc363a2f 100644 --- a/app/soapbox/features/auth_login/components/password_reset.js +++ b/app/soapbox/features/auth_login/components/password_reset.js @@ -4,7 +4,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { resetPassword } from 'soapbox/actions/auth'; import { SimpleForm, FieldsGroup, TextInput } from 'soapbox/features/forms'; import { Redirect } from 'react-router-dom'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; export default @connect() class PasswordReset extends ImmutablePureComponent { @@ -20,7 +20,7 @@ class PasswordReset extends ImmutablePureComponent { this.setState({ isLoading: true }); dispatch(resetPassword(nicknameOrEmail)).then(() => { this.setState({ isLoading: false, success: true }); - dispatch(showAlert('Password reset received. Check your email for further instructions.', '')); + dispatch(snackbar.info('Check your email for confirmation.')); }).catch(error => { this.setState({ isLoading: false }); }); diff --git a/app/soapbox/features/edit_profile/index.js b/app/soapbox/features/edit_profile/index.js index 41a32419b..75941885d 100644 --- a/app/soapbox/features/edit_profile/index.js +++ b/app/soapbox/features/edit_profile/index.js @@ -4,7 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import Column from '../ui/components/column'; import { SimpleForm, @@ -124,7 +124,7 @@ class EditProfile extends ImmutablePureComponent { const { dispatch } = this.props; dispatch(patchMe(this.getFormdata())).then(() => { this.setState({ isLoading: false }); - dispatch(showAlert('', 'Profile saved!')); + dispatch(snackbar.success('Profile saved!')); }).catch((error) => { this.setState({ isLoading: false }); }); diff --git a/app/soapbox/features/filters/index.js b/app/soapbox/features/filters/index.js index 60b406a82..1573263d8 100644 --- a/app/soapbox/features/filters/index.js +++ b/app/soapbox/features/filters/index.js @@ -14,7 +14,7 @@ import { SelectDropdown, Checkbox, } from 'soapbox/features/forms'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import Icon from 'soapbox/components/icon'; import ColumnSubheading from '../ui/components/column_subheading'; @@ -114,7 +114,7 @@ class Filters extends ImmutablePureComponent { dispatch(createFilter(phrase, expires_at, context, whole_word, irreversible)).then(response => { return dispatch(fetchFilters()); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.create_error))); + dispatch(snackbar.error(intl.formatMessage(messages.create_error))); }); } @@ -123,7 +123,7 @@ class Filters extends ImmutablePureComponent { dispatch(deleteFilter(e.currentTarget.dataset.value)).then(response => { return dispatch(fetchFilters()); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.delete_error))); + dispatch(snackbar.error(intl.formatMessage(messages.delete_error))); }); } diff --git a/app/soapbox/features/security/index.js b/app/soapbox/features/security/index.js index b13305dd1..f45416518 100644 --- a/app/soapbox/features/security/index.js +++ b/app/soapbox/features/security/index.js @@ -20,7 +20,7 @@ import { deleteAccount, } from 'soapbox/actions/auth'; import { fetchUserMfaSettings } from '../../actions/mfa'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import { changeSetting, getSettings } from 'soapbox/actions/settings'; /* @@ -119,10 +119,10 @@ class ChangeEmailForm extends ImmutablePureComponent { this.setState({ isLoading: true }); return dispatch(changeEmail(email, password)).then(() => { this.setState({ email: '', password: '' }); // TODO: Maybe redirect user - dispatch(showAlert('', intl.formatMessage(messages.updateEmailSuccess))); + dispatch(snackbar.success(intl.formatMessage(messages.updateEmailSuccess))); }).catch(error => { this.setState({ password: '' }); - dispatch(showAlert('', intl.formatMessage(messages.updateEmailFail))); + dispatch(snackbar.error(intl.formatMessage(messages.updateEmailFail))); }).then(() => { this.setState({ isLoading: false }); }); @@ -193,10 +193,10 @@ class ChangePasswordForm extends ImmutablePureComponent { this.setState({ isLoading: true }); return dispatch(changePassword(oldPassword, newPassword, confirmation)).then(() => { this.clearForm(); // TODO: Maybe redirect user - dispatch(showAlert('', intl.formatMessage(messages.updatePasswordSuccess))); + dispatch(snackbar.success(intl.formatMessage(messages.updatePasswordSuccess))); }).catch(error => { this.clearForm(); - dispatch(showAlert('', intl.formatMessage(messages.updatePasswordFail))); + dispatch(snackbar.error(intl.formatMessage(messages.updatePasswordFail))); }).then(() => { this.setState({ isLoading: false }); }); @@ -374,10 +374,10 @@ class DeactivateAccount extends ImmutablePureComponent { this.setState({ isLoading: true }); return dispatch(deleteAccount(password)).then(() => { //this.setState({ email: '', password: '' }); // TODO: Maybe redirect user - dispatch(showAlert('', intl.formatMessage(messages.deleteAccountSuccess))); + dispatch(snackbar.success(intl.formatMessage(messages.deleteAccountSuccess))); }).catch(error => { this.setState({ password: '' }); - dispatch(showAlert('', intl.formatMessage(messages.deleteAccountFail))); + dispatch(snackbar.error(intl.formatMessage(messages.deleteAccountFail))); }).then(() => { this.setState({ isLoading: false }); }); diff --git a/app/soapbox/features/security/mfa_form.js b/app/soapbox/features/security/mfa_form.js index 572579d53..3f9074f50 100644 --- a/app/soapbox/features/security/mfa_form.js +++ b/app/soapbox/features/security/mfa_form.js @@ -10,7 +10,7 @@ import ColumnSubheading from '../ui/components/column_subheading'; import LoadingIndicator from 'soapbox/components/loading_indicator'; import Button from 'soapbox/components/button'; import { changeSetting, getSettings } from 'soapbox/actions/settings'; -import { showAlert } from 'soapbox/actions/alerts'; +import snackbar from 'soapbox/actions/snackbar'; import { SimpleForm, SimpleInput, @@ -129,7 +129,7 @@ class DisableOtpForm extends ImmutablePureComponent { this.context.router.history.push('../auth/edit'); dispatch(changeSetting(['otpEnabled'], false)); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.disableFail))); + dispatch(snackbar.error(intl.formatMessage(messages.disableFail))); }); } @@ -180,7 +180,7 @@ class EnableOtpForm extends ImmutablePureComponent { dispatch(fetchBackupCodes()).then(response => { this.setState({ backupCodes: response.data.codes }); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.codesFail))); + dispatch(snackbar.error(intl.formatMessage(messages.codesFail))); }); } @@ -261,7 +261,7 @@ class OtpConfirmForm extends ImmutablePureComponent { dispatch(fetchToptSetup()).then(response => { this.setState({ qrCodeURI: response.data.provisioning_uri, confirm_key: response.data.key }); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.qrFail))); + dispatch(snackbar.error(intl.formatMessage(messages.qrFail))); }); } @@ -276,7 +276,7 @@ class OtpConfirmForm extends ImmutablePureComponent { dispatch(confirmToptSetup(code, password)).then(response => { dispatch(changeSetting(['otpEnabled'], true)); }).catch(error => { - dispatch(showAlert('', intl.formatMessage(messages.confirmFail))); + dispatch(snackbar.error(intl.formatMessage(messages.confirmFail))); }); } diff --git a/app/soapbox/features/ui/containers/notifications_container.js b/app/soapbox/features/ui/containers/notifications_container.js index b60a0216f..0bb26ecf9 100644 --- a/app/soapbox/features/ui/containers/notifications_container.js +++ b/app/soapbox/features/ui/containers/notifications_container.js @@ -4,6 +4,14 @@ import { NotificationStack } from 'react-notification'; import { dismissAlert } from '../../../actions/alerts'; import { getAlerts } from '../../../selectors'; +const defaultBarStyleFactory = (index, style, notification) => { + return Object.assign( + {}, + style, + { bottom: `${14 + index * 12 + index * 42}px` } + ); +}; + const mapStateToProps = (state, { intl }) => { const notifications = getAlerts(state); @@ -23,6 +31,8 @@ const mapDispatchToProps = (dispatch) => { onDismiss: alert => { dispatch(dismissAlert(alert)); }, + barStyleFactory: defaultBarStyleFactory, + activeBarStyleFactory: defaultBarStyleFactory, }; }; diff --git a/app/soapbox/reducers/alerts.js b/app/soapbox/reducers/alerts.js index 089d920c3..9a0a7ccaf 100644 --- a/app/soapbox/reducers/alerts.js +++ b/app/soapbox/reducers/alerts.js @@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) { key: state.size > 0 ? state.last().get('key') + 1 : 0, title: action.title, message: action.message, + severity: action.severity || 'info', })); case ALERT_DISMISS: return state.filterNot(item => item.get('key') === action.alert.key); diff --git a/app/soapbox/selectors/index.js b/app/soapbox/selectors/index.js index bbf0b54c5..30ebcee3f 100644 --- a/app/soapbox/selectors/index.js +++ b/app/soapbox/selectors/index.js @@ -124,10 +124,9 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { message: item.get('message'), title: item.get('title'), key: item.get('key'), + className: `snackbar snackbar--${item.get('severity', 'info')}`, + activeClassName: 'snackbar--active', dismissAfter: 5000, - barStyle: { - zIndex: 200, - }, }); }); diff --git a/app/styles/application.scss b/app/styles/application.scss index 06e2921d7..2e00c036b 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -70,6 +70,7 @@ @import 'components/profile_hover_card'; @import 'components/filters'; @import 'components/mfa_form'; +@import 'components/snackbar'; // Holiday @import 'holiday/halloween'; diff --git a/app/styles/components/snackbar.scss b/app/styles/components/snackbar.scss new file mode 100644 index 000000000..980ec44c0 --- /dev/null +++ b/app/styles/components/snackbar.scss @@ -0,0 +1,42 @@ +.snackbar { + font-size: 16px !important; + padding: 10px 20px 10px 14px !important; + z-index: 9999 !important; + display: flex; + align-items: center; + justify-content: center; + + &::before { + font-family: ForkAwesome; + font-size: 20px; + margin-right: 8px; + } + + &--info { + background-color: #19759e !important; + + &::before { + content: ''; + } + } + + &--success { + background-color: #199e5a !important; + + &::before { + content: ''; + } + } + + &--error { + background-color: #9e1919 !important; + + &::before { + content: ''; + } + } + + .notification-bar-wrapper { + transform: translateY(1px); + } +}