From a7ca33ad96d4ff8ae3b714d7dfbaebc962a86c27 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Jul 2023 20:01:08 +0200 Subject: [PATCH] Add toast with option to open post after publishing in web UI (#25564) --- app/javascript/mastodon/actions/alerts.js | 64 +++++++++---------- app/javascript/mastodon/actions/compose.js | 18 ++++-- .../containers/column_settings_container.js | 4 +- .../ui/containers/notifications_container.js | 37 +++++------ app/javascript/mastodon/locales/en.json | 2 + app/javascript/mastodon/reducers/alerts.js | 19 +++--- app/javascript/mastodon/selectors/index.js | 28 +++----- .../styles/mastodon/components.scss | 59 +++++++++++++++++ 8 files changed, 146 insertions(+), 85 deletions(-) diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index 0220b0af5..051a9675b 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -12,52 +12,48 @@ export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_CLEAR = 'ALERT_CLEAR'; export const ALERT_NOOP = 'ALERT_NOOP'; -export function dismissAlert(alert) { - return { - type: ALERT_DISMISS, - alert, - }; -} +export const dismissAlert = alert => ({ + type: ALERT_DISMISS, + alert, +}); -export function clearAlert() { - return { - type: ALERT_CLEAR, - }; -} +export const clearAlert = () => ({ + type: ALERT_CLEAR, +}); -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { - return { - type: ALERT_SHOW, - title, - message, - message_values, - }; -} +export const showAlert = alert => ({ + type: ALERT_SHOW, + alert, +}); -export function showAlertForError(error, skipNotFound = false) { +export const showAlertForError = (error, skipNotFound = false) => { if (error.response) { const { data, status, statusText, headers } = error.response; + // Skip these errors as they are reflected in the UI if (skipNotFound && (status === 404 || status === 410)) { - // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + // Rate limit errors if (status === 429 && headers['x-ratelimit-reset']) { - const reset_date = new Date(headers['x-ratelimit-reset']); - return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + return showAlert({ + title: messages.rateLimitedTitle, + message: messages.rateLimitedMessage, + values: { 'retry_time': new Date(headers['x-ratelimit-reset']) }, + }); } - let message = statusText; - let title = `${status}`; - - if (data.error) { - message = data.error; - } - - return showAlert(title, message); - } else { - console.error(error); - return showAlert(); + return showAlert({ + title: `${status}`, + message: data.error || statusText, + }); } + + console.error(error); + + return showAlert({ + title: messages.unexpectedTitle, + message: messages.unexpectedMessage, + }); } diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 99610ac31..260fb43f0 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -82,6 +82,8 @@ export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, + open: { id: 'compose.published.open', defaultMessage: 'Open' }, + published: { id: 'compose.published.body', defaultMessage: 'Post published.' }, }); export const ensureComposeIsVisible = (getState, routerHistory) => { @@ -240,6 +242,13 @@ export function submitCompose(routerHistory) { insertIfOnline('public'); insertIfOnline(`account:${response.data.account.id}`); } + + dispatch(showAlert({ + message: messages.published, + action: messages.open, + dismissAfter: 10000, + onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`), + })); }).catch(function (error) { dispatch(submitComposeFail(error)); }); @@ -269,18 +278,19 @@ export function submitComposeFail(error) { export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; - const media = getState().getIn(['compose', 'media_attachments']); - const pending = getState().getIn(['compose', 'pending_media_attachments']); + const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); + let total = Array.from(files).reduce((a, v) => a + v.size, 0); if (files.length + media.size + pending > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); + dispatch(showAlert({ message: messages.uploadErrorLimit })); return; } if (getState().getIn(['compose', 'poll'])) { - dispatch(showAlert(undefined, messages.uploadErrorPoll)); + dispatch(showAlert({ message: messages.uploadErrorPoll })); return; } diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b63796a8b..1e62ed9a5 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -32,7 +32,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (permission === 'granted') { dispatch(changePushNotifications(path.slice(1), checked)); } else { - dispatch(showAlert(undefined, messages.permissionDenied)); + dispatch(showAlert({ message: messages.permissionDenied })); } })); } else { @@ -47,7 +47,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (permission === 'granted') { dispatch(changeSetting(['notifications', ...path], checked)); } else { - dispatch(showAlert(undefined, messages.permissionDenied)); + dispatch(showAlert({ message: messages.permissionDenied })); } })); } else { diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js index c1d19f710..3d60cfdad 100644 --- a/app/javascript/mastodon/features/ui/containers/notifications_container.js +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -7,26 +7,27 @@ import { NotificationStack } from 'react-notification'; import { dismissAlert } from '../../../actions/alerts'; import { getAlerts } from '../../../selectors'; -const mapStateToProps = (state, { intl }) => { - const notifications = getAlerts(state); +const formatIfNeeded = (intl, message, values) => { + if (typeof message === 'object') { + return intl.formatMessage(message, values); + } - notifications.forEach(notification => ['title', 'message'].forEach(key => { - const value = notification[key]; - - if (typeof value === 'object') { - notification[key] = intl.formatMessage(value, notification[`${key}_values`]); - } - })); - - return { notifications }; + return message; }; -const mapDispatchToProps = (dispatch) => { - return { - onDismiss: alert => { - dispatch(dismissAlert(alert)); - }, - }; -}; +const mapStateToProps = (state, { intl }) => ({ + notifications: getAlerts(state).map(alert => ({ + ...alert, + action: formatIfNeeded(intl, alert.action, alert.values), + title: formatIfNeeded(intl, alert.title, alert.values), + message: formatIfNeeded(intl, alert.message, alert.values), + })), +}); + +const mapDispatchToProps = (dispatch) => ({ + onDismiss (alert) { + dispatch(dismissAlert(alert)); + }, +}); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack)); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 2afac7e7e..8705e6cd6 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -135,6 +135,8 @@ "community.column_settings.remote_only": "Remote only", "compose.language.change": "Change language", "compose.language.search": "Search languages...", + "compose.published.body": "Post published.", + "compose.published.open": "Open", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.", diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js index bd49d748f..1ca9b62a0 100644 --- a/app/javascript/mastodon/reducers/alerts.js +++ b/app/javascript/mastodon/reducers/alerts.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import { ALERT_SHOW, @@ -8,17 +8,20 @@ import { 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 state.push(ImmutableMap({ - key: state.size > 0 ? state.last().get('key') + 1 : 0, - title: action.title, - message: action.message, - message_values: action.message_values, - })); + return addAlert(state, action.alert); case ALERT_DISMISS: - return state.filterNot(item => item.get('key') === action.alert.key); + return state.filterNot(item => item.key === action.alert.key); case ALERT_CLEAR: return state.clear(); default: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index f92e7fe48..0968fb090 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -84,26 +84,16 @@ export const makeGetPictureInPicture = () => { })); }; -const getAlertsBase = state => state.get('alerts'); +const ALERT_DEFAULTS = { + dismissAfter: 5000, + style: false, +}; -export const getAlerts = createSelector([getAlertsBase], (base) => { - let arr = []; - - base.forEach(item => { - arr.push({ - message: item.get('message'), - message_values: item.get('message_values'), - title: item.get('title'), - key: item.get('key'), - dismissAfter: 5000, - barStyle: { - zIndex: 200, - }, - }); - }); - - return arr; -}); +export const getAlerts = createSelector(state => state.get('alerts'), alerts => + alerts.map(item => ({ + ...ALERT_DEFAULTS, + ...item, + })).toArray()); export const makeGetNotification = () => createSelector([ (_, base) => base, diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5e261e1ee..434a2f542 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9077,3 +9077,62 @@ noscript { } } } + +.notification-list { + position: fixed; + bottom: 2rem; + inset-inline-start: 0; + z-index: 999; + display: flex; + flex-direction: column; + gap: 4px; +} + +.notification-bar { + flex: 0 0 auto; + position: relative; + inset-inline-start: -100%; + width: auto; + padding: 15px; + margin: 0; + color: $primary-text-color; + background: rgba($black, 0.85); + backdrop-filter: blur(8px); + border: 1px solid rgba(lighten($ui-base-color, 4%), 0.85); + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba($base-shadow-color, 0.25), + 0 4px 6px -4px rgba($base-shadow-color, 0.25); + cursor: default; + transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1); + transform: translateZ(0); + font-size: 15px; + line-height: 21px; + + &.notification-bar-active { + inset-inline-start: 1rem; + } +} + +.notification-bar-title { + margin-inline-end: 5px; +} + +.notification-bar-title, +.notification-bar-action { + font-weight: 700; +} + +.notification-bar-action { + text-transform: uppercase; + margin-inline-start: 10px; + cursor: pointer; + color: $highlight-text-color; + border-radius: 4px; + padding: 0 4px; + + &:hover, + &:focus, + &:active { + background: rgba($ui-base-color, 0.85); + } +}