diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 55f35fa4bd0..a949752fb43 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -33,6 +33,11 @@ class Api::V1::NotificationsController < Api::BaseController render_empty end + def destroy_multiple + current_account.notifications.where(id: params[:ids]).destroy_all + render_empty + end + private def load_notifications diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/container.js b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js new file mode 100644 index 00000000000..bf079e3c448 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js @@ -0,0 +1,56 @@ +/* + +`` +========================= + +This container connects ``s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Our imports // +import NotificationPurgeButtons from './notification_purge_buttons'; +import { + deleteMarkedNotifications, + enterNotificationClearingMode, +} from '../../../../mastodon/actions/notifications'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We only need to provide a dispatch for +deleting notifications. + +*/ + +const mapDispatchToProps = dispatch => ({ + onEnterCleaningMode(yes) { + dispatch(enterNotificationClearingMode(yes)); + }, + + onDeleteMarkedNotifications() { + dispatch(deleteMarkedNotifications()); + }, +}); + +const mapStateToProps = state => ({ + active: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons); diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js b/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js new file mode 100644 index 00000000000..e41572256f5 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js @@ -0,0 +1,100 @@ +/** + * Buttons widget for controlling the notification clearing mode. + * In idle state, the cleaning mode button is shown. When the mode is active, + * a Confirm and Abort buttons are shown in its place. + */ + + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + enter : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, + accept : { id: 'notification_purge.confirm', defaultMessage: 'Dismiss selected notifications' }, + abort : { id: 'notification_purge.abort', defaultMessage: 'Leave cleaning mode' }, +}); + +@injectIntl +export default class NotificationPurgeButtons extends ImmutablePureComponent { + + static propTypes = { + // Nukes all marked notifications + onDeleteMarkedNotifications : PropTypes.func.isRequired, + // Enables or disables the mode + // and also clears the marked status of all notifications + onEnterCleaningMode : PropTypes.func.isRequired, + // Active state, changed via onStateChange() + active: PropTypes.bool.isRequired, + // i18n + intl: PropTypes.object.isRequired, + }; + + onEnterBtnClick = () => { + this.props.onEnterCleaningMode(true); + } + + onAcceptBtnClick = () => { + this.props.onDeleteMarkedNotifications(); + } + + onAbortBtnClick = () => { + this.props.onEnterCleaningMode(false); + } + + render () { + const { intl, active } = this.props; + + const msgEnter = intl.formatMessage(messages.enter); + const msgAccept = intl.formatMessage(messages.accept); + const msgAbort = intl.formatMessage(messages.abort); + + let enterButton, acceptButton, abortButton; + + if (active) { + acceptButton = ( + + ); + abortButton = ( + + ); + } else { + enterButton = ( + + ); + } + + return ( +
+ {acceptButton}{abortButton}{enterButton} +
+ ); + } + +} diff --git a/app/javascript/glitch/components/notification/container.js b/app/javascript/glitch/components/notification/container.js index bed086172a4..7d2590684c3 100644 --- a/app/javascript/glitch/components/notification/container.js +++ b/app/javascript/glitch/components/notification/container.js @@ -24,7 +24,6 @@ import { makeGetNotification } from '../../../mastodon/selectors'; // Our imports // import Notification from '.'; -import { deleteNotification } from '../../../mastodon/actions/notifications'; // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * @@ -53,21 +52,4 @@ const makeMapStateToProps = () => { // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -/* - -Dispatch mapping: ------------------ - -The `mapDispatchToProps()` function maps dispatches to our store to the -various props of our component. We only need to provide a dispatch for -deleting notifications. - -*/ - -const mapDispatchToProps = dispatch => ({ - onDeleteNotification (id) { - dispatch(deleteNotification(id)); - }, -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); +export default connect(makeMapStateToProps)(Notification); diff --git a/app/javascript/glitch/components/notification/follow.js b/app/javascript/glitch/components/notification/follow.js index 26396478b6b..0e0065eb17d 100644 --- a/app/javascript/glitch/components/notification/follow.js +++ b/app/javascript/glitch/components/notification/follow.js @@ -36,7 +36,7 @@ Imports: import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import escapeTextContentForBrowser from 'escape-html'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -45,55 +45,28 @@ import emojify from '../../../mastodon/emoji'; import Permalink from '../../../mastodon/components/permalink'; import AccountContainer from '../../../mastodon/containers/account_container'; +// Our imports // +import NotificationOverlayContainer from '../notification/overlay/container'; + // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * /* -Inital setup: -------------- - -The `messages` constant is used to define any messages that we need -from inside props. - -*/ - -const messages = defineMessages({ - deleteNotification : - { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, -}); - -/* - Implementation: --------------- */ -@injectIntl export default class NotificationFollow extends ImmutablePureComponent { static propTypes = { id : PropTypes.number.isRequired, - onDeleteNotification : PropTypes.func.isRequired, account : ImmutablePropTypes.map.isRequired, - intl : PropTypes.object.isRequired, + notification : ImmutablePropTypes.map.isRequired, }; /* -### `handleNotificationDeleteClick()` - -This function just calls our `onDeleteNotification()` prop with the -notification's `id`. - -*/ - - handleNotificationDeleteClick = () => { - this.props.onDeleteNotification(this.props.id); - } - -/* - ### `render()` This actually renders the component. @@ -101,26 +74,7 @@ This actually renders the component. */ render () { - const { account, intl } = this.props; - -/* - -`dismiss` creates the notification dismissal button. Its title is given -by `dismissTitle`. - -*/ - - const dismissTitle = intl.formatMessage(messages.deleteNotification); - const dismiss = ( - - ); + const { account, notification } = this.props; /* @@ -149,6 +103,7 @@ We can now render our component. return (
+
@@ -159,8 +114,6 @@ We can now render our component. defaultMessage='{name} followed you' values={{ name: link }} /> - - {dismiss}
diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js index 556d5aea8dc..b2e55aad510 100644 --- a/app/javascript/glitch/components/notification/index.js +++ b/app/javascript/glitch/components/notification/index.js @@ -2,7 +2,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import PropTypes from 'prop-types'; // Mastodon imports // @@ -15,7 +14,6 @@ export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, settings: ImmutablePropTypes.map.isRequired, - onDeleteNotification: PropTypes.func.isRequired, }; renderFollow (notification) { @@ -23,7 +21,7 @@ export default class Notification extends ImmutablePureComponent { ); } @@ -32,7 +30,7 @@ export default class Notification extends ImmutablePureComponent { return ( ); @@ -45,7 +43,7 @@ export default class Notification extends ImmutablePureComponent { account={notification.get('account')} prepend='favourite' muted - notificationId={notification.get('id')} + notification={notification} withDismiss /> ); @@ -58,7 +56,7 @@ export default class Notification extends ImmutablePureComponent { account={notification.get('account')} prepend='reblog' muted - notificationId={notification.get('id')} + notification={notification} withDismiss /> ); diff --git a/app/javascript/glitch/components/notification/overlay/container.js b/app/javascript/glitch/components/notification/overlay/container.js new file mode 100644 index 00000000000..019b78d0b29 --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/container.js @@ -0,0 +1,49 @@ +/* + +`` +========================= + +This container connects ``s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Our imports // +import NotificationOverlay from './notification_overlay'; +import { markNotificationForDelete } from '../../../../mastodon/actions/notifications'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We only need to provide a dispatch for +deleting notifications. + +*/ + +const mapDispatchToProps = dispatch => ({ + onMarkForDelete(id, yes) { + dispatch(markNotificationForDelete(id, yes)); + }, +}); + +const mapStateToProps = state => ({ + revealed: state.getIn(['notifications', 'cleaningMode']), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/glitch/components/notification/overlay/notification_overlay.js b/app/javascript/glitch/components/notification/overlay/notification_overlay.js new file mode 100644 index 00000000000..73eda718f95 --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/notification_overlay.js @@ -0,0 +1,59 @@ +/** + * Notification overlay + */ + + +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports // + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +const messages = defineMessages({ + markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, +}); + +@injectIntl +export default class NotificationOverlay extends ImmutablePureComponent { + + static propTypes = { + notification : ImmutablePropTypes.map.isRequired, + onMarkForDelete : PropTypes.func.isRequired, + revealed : PropTypes.bool.isRequired, + intl : PropTypes.object.isRequired, + }; + + onToggleMark = () => { + const mark = !this.props.notification.get('markedForDelete'); + const id = this.props.notification.get('id'); + this.props.onMarkForDelete(id, mark); + } + + render () { + const { notification, revealed, intl } = this.props; + + const active = notification.get('markedForDelete'); + const label = intl.formatMessage(messages.markForDeletion); + + return ( +
+ +
+ ); + } + +} diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js index df0904a7c8c..7c73002c109 100644 --- a/app/javascript/glitch/components/status/action_bar.js +++ b/app/javascript/glitch/components/status/action_bar.js @@ -24,7 +24,6 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, }); @injectIntl @@ -36,7 +35,6 @@ export default class StatusActionBar extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, - notificationId: PropTypes.number, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -46,7 +44,6 @@ export default class StatusActionBar extends ImmutablePureComponent { onBlock: PropTypes.func, onReport: PropTypes.func, onMuteConversation: PropTypes.func, - onDeleteNotification: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -100,10 +97,6 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onMuteConversation(this.props.status); } - handleNotificationDeleteClick = () => { - this.props.onDeleteNotification(this.props.notificationId); - } - render () { const { status, me, intl, withDismiss } = this.props; const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; @@ -120,7 +113,6 @@ export default class StatusActionBar extends ImmutablePureComponent { if (withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); - menu.push({ text: intl.formatMessage(messages.deleteNotification), action: this.handleNotificationDeleteClick }); menu.push(null); } diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js index c45b2e0ec78..1d572e0e715 100644 --- a/app/javascript/glitch/components/status/container.js +++ b/app/javascript/glitch/components/status/container.js @@ -50,7 +50,6 @@ import { } from '../../../mastodon/actions/statuses'; import { initReport } from '../../../mastodon/actions/reports'; import { openModal } from '../../../mastodon/actions/modal'; -import { deleteNotification } from '../../../mastodon/actions/notifications'; // Our imports // import Status from '.'; @@ -245,10 +244,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(muteStatus(status.get('id'))); } }, - - onDeleteNotification (id) { - dispatch(deleteNotification(id)); - }, }); export default injectIntl( diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js index 4a91b5aa37b..dc06250ec79 100644 --- a/app/javascript/glitch/components/status/index.js +++ b/app/javascript/glitch/components/status/index.js @@ -47,6 +47,7 @@ import StatusContent from './content'; import StatusActionBar from './action_bar'; import StatusGallery from './gallery'; import StatusPlayer from './player'; +import NotificationOverlayContainer from '../notification/overlay/container'; /* * * * */ @@ -158,6 +159,7 @@ export default class Status extends ImmutablePureComponent { status : ImmutablePropTypes.map, account : ImmutablePropTypes.map, settings : ImmutablePropTypes.map, + notification : ImmutablePropTypes.map, me : PropTypes.number, onFavourite : PropTypes.func, onReblog : PropTypes.func, @@ -170,7 +172,6 @@ export default class Status extends ImmutablePureComponent { onReport : PropTypes.func, onOpenMedia : PropTypes.func, onOpenVideo : PropTypes.func, - onDeleteNotification : PropTypes.func, reblogModal : PropTypes.bool, deleteModal : PropTypes.bool, autoPlayGif : PropTypes.bool, @@ -178,7 +179,6 @@ export default class Status extends ImmutablePureComponent { collapse : PropTypes.bool, prepend : PropTypes.string, withDismiss : PropTypes.bool, - notificationId : PropTypes.number, intersectionObserverWrapper : PropTypes.object, }; @@ -186,6 +186,7 @@ export default class Status extends ImmutablePureComponent { isExpanded : null, isIntersecting : true, isHidden : false, + markedForDelete : false, } /* @@ -212,10 +213,12 @@ to remember to specify it here. 'autoPlayGif', 'muted', 'collapse', + 'notification', ] updateOnStates = [ 'isExpanded', + 'markedForDelete', ] /* @@ -523,6 +526,10 @@ applicable. } } + markNotifForDelete = () => { + this.setState({ 'markedForDelete' : !this.state.markedForDelete }); + } + /* #### `render()`. @@ -551,6 +558,7 @@ this operation are further explained in the code below. onOpenVideo, onOpenMedia, autoPlayGif, + notification, ...other } = this.props; const { isExpanded, isIntersecting, isHidden } = this.state; @@ -678,6 +686,8 @@ collapsed. isExpanded === false ? ' collapsed' : '' }${ isExpanded === false && background ? ' has-background' : '' + }${ + this.state.markedForDelete ? ' marked-for-delete' : '' }` } style={{ @@ -689,13 +699,17 @@ collapsed. }} ref={handleRef} > + {notification ? ( + + ) : null} {prepend && account ? ( ) : null} { - this.props.onDeleteNotification(this.props.notificationId); - } - /* #### ``. @@ -159,19 +146,7 @@ the `` inside of an
- {dismiss} ); } diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json index 80fdc3a3944..d202d9c3398 100644 --- a/app/javascript/glitch/locales/en.json +++ b/app/javascript/glitch/locales/en.json @@ -28,5 +28,5 @@ "settings.wide_view": "Wide view (Desktop mode only)", "status.collapse": "Collapse", "status.uncollapse": "Uncollapse", - "status.dismiss_notification": "Dismiss notification" + "notification.markForDeletion": "Mark for deletion" } diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index b2a0f7ac34c..fca26516a2b 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -6,7 +6,15 @@ import { defineMessages } from 'react-intl'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; -export const NOTIFICATION_DELETE_SUCCESS = 'NOTIFICATION_DELETE_SUCCESS'; +// tracking the notif cleaning request +export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; +export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS'; +export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL'; +export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes +// Unmark notifications (when the cleaning mode is left) +export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE'; +// Mark one for delete +export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; @@ -190,17 +198,61 @@ export function scrollTopNotifications(top) { }; }; -export function deleteNotification(id) { +export function deleteMarkedNotifications() { return (dispatch, getState) => { - api(getState).delete(`/api/v1/notifications/${id}`).then(() => { - dispatch(deleteNotificationSuccess(id)); + dispatch(deleteMarkedNotificationsRequest()); + + let ids = []; + getState().getIn(['notifications', 'items']).forEach((n) => { + if (n.get('markedForDelete')) { + ids.push(n.get('id')); + } + }); + + if (ids.length === 0) { + dispatch(enterNotificationClearingMode(false)); + return; + } + + api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => { + dispatch(deleteMarkedNotificationsSuccess()); + dispatch(expandNotifications()); // Load more (to fill the empty space) + }).catch(error => { + console.error(error); + dispatch(deleteMarkedNotificationsFail(error)); }); }; }; -export function deleteNotificationSuccess(id) { +export function enterNotificationClearingMode(yes) { return { - type: NOTIFICATION_DELETE_SUCCESS, - id: id, + type: NOTIFICATIONS_ENTER_CLEARING_MODE, + yes: yes, + }; +}; + +export function deleteMarkedNotificationsRequest() { + return { + type: NOTIFICATIONS_DELETE_MARKED_REQUEST, + }; +}; + +export function deleteMarkedNotificationsFail() { + return { + type: NOTIFICATIONS_DELETE_MARKED_FAIL, + }; +}; + +export function markNotificationForDelete(id, yes) { + return { + type: NOTIFICATION_MARK_FOR_DELETE, + id: id, + yes: yes, + }; +}; + +export function deleteMarkedNotificationsSuccess() { + return { + type: NOTIFICATIONS_DELETE_MARKED_SUCCESS, }; }; diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index 3cbb745c5aa..0dd31e13740 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -34,7 +34,12 @@ export default class Column extends React.PureComponent { const { children } = this.props; return ( -
+
{children}
); diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index e9f041be625..0459042061c 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -1,8 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +// Glitch imports +import NotificationPurgeButtonsContainer from '../../glitch/components/column/notif_cleaning_widget/container'; + +const messages = defineMessages({ + titleNotifClearing: { id: 'column.notifications_clearing', defaultMessage: 'Dismiss selected notifications:' }, + titleNotifClearingShort: { id: 'column.notifications_clearing_short', defaultMessage: 'Dismiss selected:' }, +}); + +@injectIntl export default class ColumnHeader extends React.PureComponent { static contextTypes = { @@ -13,13 +23,17 @@ export default class ColumnHeader extends React.PureComponent { title: PropTypes.node.isRequired, icon: PropTypes.string.isRequired, active: PropTypes.bool, + localSettings : ImmutablePropTypes.map, multiColumn: PropTypes.bool, showBackButton: PropTypes.bool, + notifCleaning: PropTypes.bool, // true only for the notification column + notifCleaningActive: PropTypes.bool, children: PropTypes.node, pinned: PropTypes.bool, onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, + intl: PropTypes.object.isRequired, }; state = { @@ -58,9 +72,16 @@ export default class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton } = this.props; + const { intl, icon, active, children, pinned, onPin, multiColumn, showBackButton, notifCleaning, localSettings } = this.props; const { collapsed, animating } = this.state; + let title = this.props.title; + if (notifCleaning && this.props.notifCleaningActive) { + title = intl.formatMessage(localSettings.getIn(['stretch']) ? + messages.titleNotifClearing : + messages.titleNotifClearingShort); + } + const wrapperClassName = classNames('column-header__wrapper', { 'active': active, }); @@ -130,6 +151,7 @@ export default class ColumnHeader extends React.PureComponent { {title}
+ {notifCleaning ? () : null} {backButton} {collapseButton}
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 39fb4b26d04..6a262a59e0b 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -4,7 +4,10 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; +import { + expandNotifications, + scrollTopNotifications, +} from '../../actions/notifications'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from '../../../glitch/components/notification/container'; import { ScrollContainer } from 'react-router-scroll'; @@ -26,9 +29,11 @@ const getNotifications = createSelector([ const mapStateToProps = state => ({ notifications: getNotifications(state), + localSettings: state.get('local_settings'), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, hasMore: !!state.getIn(['notifications', 'next']), + notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); @connect(mapStateToProps) @@ -45,6 +50,8 @@ export default class Notifications extends React.PureComponent { isUnread: PropTypes.bool, multiColumn: PropTypes.bool, hasMore: PropTypes.bool, + localSettings: ImmutablePropTypes.map, + notifCleaningActive: PropTypes.bool, }; static defaultProps = { @@ -164,7 +171,9 @@ export default class Notifications extends React.PureComponent { this.scrollableArea = scrollableArea; return ( - + diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index da5fcde8403..dd81653d6ac 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -8,7 +8,11 @@ import { NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, - NOTIFICATION_DELETE_SUCCESS, + NOTIFICATIONS_DELETE_MARKED_REQUEST, + NOTIFICATIONS_DELETE_MARKED_SUCCESS, + NOTIFICATION_MARK_FOR_DELETE, + NOTIFICATIONS_DELETE_MARKED_FAIL, + NOTIFICATIONS_ENTER_CLEARING_MODE, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -21,12 +25,14 @@ const initialState = ImmutableMap({ unread: 0, loaded: false, isLoading: true, + cleaningMode: false, }); const notificationToMap = notification => ImmutableMap({ id: notification.id, type: notification.type, account: notification.account.id, + markedForDelete: false, status: notification.status ? notification.status.id : null, }); @@ -93,17 +99,34 @@ const deleteByStatus = (state, statusId) => { return state.update('items', list => list.filterNot(item => item.get('status') === statusId)); }; -const deleteById = (state, notificationId) => { - return state.update('items', list => list.filterNot(item => item.get('id') === notificationId)); +const markForDelete = (state, notificationId, yes) => { + return state.update('items', list => list.map(item => { + if(item.get('id') === notificationId) { + return item.set('markedForDelete', yes); + } else { + return item; + } + })); +}; + +const unmarkAllForDelete = (state) => { + return state.update('items', list => list.map(item => item.set('markedForDelete', false))); +}; + +const deleteMarkedNotifs = (state) => { + return state.update('items', list => list.filterNot(item => item.get('markedForDelete'))); }; export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST: + case NOTIFICATIONS_DELETE_MARKED_REQUEST: + return state.set('isLoading', true); + case NOTIFICATIONS_DELETE_MARKED_FAIL: case NOTIFICATIONS_REFRESH_FAIL: case NOTIFICATIONS_EXPAND_FAIL: - return state.set('isLoading', true); + return state.set('isLoading', false); case NOTIFICATIONS_SCROLL_TOP: return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: @@ -118,8 +141,15 @@ export default function notifications(state = initialState, action) { return state.set('items', ImmutableList()).set('next', null); case TIMELINE_DELETE: return deleteByStatus(state, action.id); - case NOTIFICATION_DELETE_SUCCESS: - return deleteById(state, action.id); + case NOTIFICATION_MARK_FOR_DELETE: + return markForDelete(state, action.id, action.yes); + case NOTIFICATIONS_DELETE_MARKED_SUCCESS: + return deleteMarkedNotifs(state).set('isLoading', false).set('cleaningMode', false); + case NOTIFICATIONS_ENTER_CLEARING_MODE: + const st = state.set('cleaningMode', action.yes); + if (!action.yes) + return unmarkAllForDelete(st); + else return st; default: return state; } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 0cd08298539..dbdf286a9dd 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -451,6 +451,63 @@ cursor: pointer; } +.notification__dismiss-overlay { + position: absolute; + left: 0; top: 0; right: 0; bottom: 0; + + $c1: #00000A; + $c2: #222228; + background: linear-gradient(to right, + rgba($c1, 0.1), + rgba($c1, 0.2) 60%, + rgba($c2, 1) 90%, + rgba($c2, 1)); + + z-index: 999; + align-items: center; + justify-content: flex-end; + cursor: pointer; + + display: none; + + &.show { + display: flex; + } + + // make it brighter + &.active { + $c: #222931; + background: linear-gradient(to right, + rgba($c, 0.1), + rgba($c, 0.2) 60%, + rgba($c, 1) 90%, + rgba($c, 1)); + } + + &:focus { + outline: 0 !important; + } +} + +.notification__dismiss-overlay__ckbox { + border: 2px solid #9baec8; + border-radius: 2px; + width: 30px; + height: 30px; + margin-right: 20px; + font-size: 20px; + color: #c3dcfd; + text-shadow: 0 0 5px black; + display: flex; + justify-content: center; + align-items: center; + + :focus & { + outline: rgb(77, 144, 254) auto 10px; + outline: -webkit-focus-ring-color auto 10px; + } +} + // --- Extra clickable area in the status gutter --- .ui.wide { @mixin xtraspaces-full { @@ -627,24 +684,14 @@ position: absolute; } -.status__prepend-dismiss-button { - border: 0; - background: transparent; - position: absolute; - right: -3px; - opacity: 0; - transition: opacity 0.1s ease-in-out; +.notification-follow { + position: relative; - i.fa { - color: crimson; - } + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); - .notification__message:hover & { - opacity: 1; - } - - .notification-follow & { - right: 6px; + .account { + border-bottom: 0 none; } } @@ -2408,6 +2455,17 @@ button.icon-button.active i.fa-retweet { } } +.column-header__notif-cleaning-buttons { + display: flex; + align-items: stretch; + + button { + @extend .column-header__button; + padding-left: 12px; + padding-right: 12px; + } +} + .column-header__collapsible { max-height: 70vh; overflow: hidden; diff --git a/config/routes.rb b/config/routes.rb index fb2051aad0b..ac505edc621 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -182,6 +182,7 @@ Rails.application.routes.draw do collection do post :clear post :dismiss + delete :destroy_multiple end end