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
This commit is contained in:
Ondřej Hruška
2017-07-21 20:33:16 +02:00
committed by GitHub
parent 0efd7e7406
commit 604654ccb4
20 changed files with 514 additions and 157 deletions

View File

@@ -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,
};
};

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}