diff options
author | Ondřej Hruška <ondra@ondrovo.com> | 2017-07-21 20:33:16 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-07-21 20:33:16 +0200 |
commit | 604654ccb417ffdc9b48d876bea76c8bec14f360 (patch) | |
tree | 1fe2c98677aa5328c8366a37114325b625399ace | |
parent | 0efd7e740602dd684712563b7ad0b41c23d86d69 (diff) |
New notification cleaning mode (#89)
This PR adds a new notification cleaning mode, super perfectly tuned for accessibility, and removes the previous notification cleaning functionality as it's now redundant. * w.i.p. notif clearing mode * Better CSS for selected notification and shorter text if Stretch is off * wip for rebase ~ * all working in notif clearing mode, except the actual removal * bulk delete route for piggo * cleaning + refactor. endpoint gives 422 for some reason * formatting * use the right route * fix broken destroy_multiple * load more notifs after succ cleaning * satisfy eslint * Removed CSS for the old notif delete button * Tabindex=0 is mandatory In order to make it possible to tab to this element you must have tab index = 0. Removing this violates WCAG and makes it impossible to use the interface without good eyesight and a mouse. So nobody with certain mobility impairments, vision impairments, or brain injuries would be able to use this feature if you don't have tabindex=0 * Corrected aria-label Previous label implied a different behavior from what actually happens * aria role localization & made the overlay behave like a checkbox * checkboxes css and better contrast * color tuning for the notif overlay * fanceh checkboxes etc and nice backgrounds * SHUT UP TRAVIS
20 files changed, 513 insertions, 156 deletions
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 55f35fa4b..a949752fb 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 000000000..bf079e3c4 --- /dev/null +++ b/app/javascript/glitch/components/column/notif_cleaning_widget/container.js @@ -0,0 +1,56 @@ +/* + +`<NotificationPurgeButtonsContainer>` +========================= + +This container connects `<NotificationPurgeButtons>`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 000000000..e41572256 --- /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 = ( + <button + className='active' + aria-label={msgAccept} + title={msgAccept} + onClick={this.onAcceptBtnClick} + > + <i className='fa fa-check' /> + </button> + ); + abortButton = ( + <button + className='active' + aria-label={msgAbort} + title={msgAbort} + onClick={this.onAbortBtnClick} + > + <i className='fa fa-times' /> + </button> + ); + } else { + enterButton = ( + <button + aria-label={msgEnter} + title={msgEnter} + onClick={this.onEnterBtnClick} + > + <i className='fa fa-eraser' /> + </button> + ); + } + + return ( + <div className='column-header__notif-cleaning-buttons'> + {acceptButton}{abortButton}{enterButton} + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/notification/container.js b/app/javascript/glitch/components/notification/container.js index bed086172..7d2590684 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 26396478b..0e0065eb1 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,22 +45,10 @@ import emojify from '../../../mastodon/emoji'; import Permalink from '../../../mastodon/components/permalink'; import AccountContainer from '../../../mastodon/containers/account_container'; -// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - -/* - -Inital setup: -------------- - -The `messages` constant is used to define any messages that we need -from inside props. - -*/ +// Our imports // +import NotificationOverlayContainer from '../notification/overlay/container'; -const messages = defineMessages({ - deleteNotification : - { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, -}); +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * /* @@ -69,31 +57,16 @@ 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 = ( - <button - aria-label={dismissTitle} - title={dismissTitle} - onClick={this.handleNotificationDeleteClick} - className='status__prepend-dismiss-button' - > - <i className='fa fa-eraser' /> - </button> - ); + const { account, notification } = this.props; /* @@ -149,6 +103,7 @@ We can now render our component. return ( <div className='notification notification-follow'> + <NotificationOverlayContainer notification={notification} /> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <i className='fa fa-fw fa-user-plus' /> @@ -159,8 +114,6 @@ We can now render our component. defaultMessage='{name} followed you' values={{ name: link }} /> - - {dismiss} </div> <AccountContainer id={account.get('id')} withNote={false} /> diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js index 556d5aea8..b2e55aad5 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 { <NotificationFollow id={notification.get('id')} account={notification.get('account')} - onDeleteNotification={this.props.onDeleteNotification} + notification={notification} /> ); } @@ -32,7 +30,7 @@ export default class Notification extends ImmutablePureComponent { return ( <StatusContainer id={notification.get('status')} - notificationId={notification.get('id')} + notification={notification} withDismiss /> ); @@ -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 000000000..019b78d0b --- /dev/null +++ b/app/javascript/glitch/components/notification/overlay/container.js @@ -0,0 +1,49 @@ +/* + +`<NotificationOverlayContainer>` +========================= + +This container connects `<NotificationOverlay>`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 000000000..73eda718f --- /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 ( + <div + aria-label={label} + role='checkbox' + aria-checked={active} + tabIndex={0} + className={`notification__dismiss-overlay ${active ? 'active' : ''} ${revealed ? 'show' : ''}`} + onClick={this.onToggleMark} + > + <div className='notification__dismiss-overlay__ckbox' aria-hidden='true' title={label}> + {active ? (<i className='fa fa-check' />) : ''} + </div> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js index df0904a7c..7c73002c1 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 c45b2e0ec..1d572e0e7 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 4a91b5aa3..dc06250ec 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 ? ( + <NotificationOverlayContainer + notification={notification} + /> + ) : null} {prepend && account ? ( <StatusPrepend type={prepend} account={account} parseClick={parseClick} notificationId={this.props.notificationId} - onDeleteNotification={this.props.onDeleteNotification} /> ) : null} <StatusHeader diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js index d9b04b5ec..6213e4c8d 100644 --- a/app/javascript/glitch/components/status/prepend.js +++ b/app/javascript/glitch/components/status/prepend.js @@ -23,17 +23,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import escapeTextContentForBrowser from 'escape-html'; -import { defineMessages, injectIntl } from 'react-intl'; import { FormattedMessage } from 'react-intl'; // Mastodon imports // import emojify from '../../../mastodon/emoji'; - -const messages = defineMessages({ - deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, -}); - /* * * * */ /* @@ -59,7 +53,6 @@ element. */ -@injectIntl export default class StatusPrepend extends React.PureComponent { static propTypes = { @@ -67,8 +60,6 @@ export default class StatusPrepend extends React.PureComponent { account: ImmutablePropTypes.map.isRequired, parseClick: PropTypes.func.isRequired, notificationId: PropTypes.number, - onDeleteNotification: PropTypes.func, - intl: PropTypes.object.isRequired, }; /* @@ -87,10 +78,6 @@ an account link is clicked. parseClick(e, `/accounts/${+account.get('id')}`); } - handleNotificationDeleteClick = () => { - this.props.onDeleteNotification(this.props.notificationId); - } - /* #### `<Message>`. @@ -159,19 +146,7 @@ the `<Message>` inside of an <aside>. render () { const { Message } = this; - const { type, intl } = this.props; - - const dismissTitle = intl.formatMessage(messages.deleteNotification); - const dismiss = this.props.notificationId ? ( - <button - aria-label={dismissTitle} - title={dismissTitle} - onClick={this.handleNotificationDeleteClick} - className='status__prepend-dismiss-button' - > - <i className='fa fa-eraser' /> - </button> - ) : null; + const { type } = this.props; return !type ? null : ( <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}> @@ -183,7 +158,6 @@ the `<Message>` inside of an <aside>. /> </div> <Message /> - {dismiss} </aside> ); } diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json index 80fdc3a39..d202d9c33 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 b2a0f7ac3..fca26516a 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: NOTIFICATIONS_ENTER_CLEARING_MODE, + yes: yes, + }; +}; + +export function deleteMarkedNotificationsRequest() { return { - type: NOTIFICATION_DELETE_SUCCESS, + 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 3cbb745c5..0dd31e137 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 ( - <div role='region' className='column' ref={this.setRef} onWheel={this.handleWheel}> + <div + role='region' + className='column' + ref={this.setRef} + onWheel={this.handleWheel} + > {children} </div> ); diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index e9f041be6..045904206 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} <div className='column-header__buttons'> + {notifCleaning ? (<NotificationPurgeButtonsContainer />) : null} {backButton} {collapseButton} </div> diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 39fb4b26d..6a262a59e 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 ( - <Column ref={this.setColumnRef}> + <Column + ref={this.setColumnRef} + > <ColumnHeader icon='bell' active={isUnread} @@ -174,6 +183,9 @@ export default class Notifications extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + localSettings={this.props.localSettings} + notifCleaning + notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text > <ColumnSettingsContainer /> </ColumnHeader> diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index da5fcde84..dd81653d6 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 0cd082985..dbdf286a9 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; - - i.fa { - color: crimson; - } +.notification-follow { + position: relative; - .notification__message:hover & { - opacity: 1; - } + // same like Status + border-bottom: 1px solid lighten($ui-base-color, 8%); - .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 fb2051aad..ac505edc6 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 |