Improved notifications cleaning UI with set operations (#109)

* added notification cleaning drawer

* bugfix

* fully implemented set operations for notif cleaning

* i18n for notif cleaning drawer & improved logic slightly. Also added a confirm dialog

* - notif dismiss "overlay" now shoves the notif aside to avoid overlap
- added focus ring to header buttons
- removed notif overlay entirely from DOM if mode is disabled

* removed comment

* CSS tuning - inconsistent division lines fix
This commit is contained in:
Ondřej Hruška
2017-07-30 18:36:28 +02:00
committed by beatrix
parent 9aaf3218d2
commit 6ff084dbbb
14 changed files with 279 additions and 162 deletions

View File

@@ -10,6 +10,7 @@ export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
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_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
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';
@@ -210,13 +211,11 @@ export function deleteMarkedNotifications() {
});
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));
@@ -231,6 +230,13 @@ export function enterNotificationClearingMode(yes) {
};
};
export function markAllNotifications(yes) {
return {
type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
yes: yes, // true, false or null. null = invert
};
};
export function deleteMarkedNotificationsRequest() {
return {
type: NOTIFICATIONS_DELETE_MARKED_REQUEST,

View File

@@ -7,6 +7,7 @@ export default class Column extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
extraClasses: PropTypes.string,
};
scrollTop () {
@@ -40,10 +41,10 @@ export default class Column extends React.PureComponent {
}
render () {
const { children } = this.props;
const { children, extraClasses } = this.props;
return (
<div role='region' className='column' ref={this.setRef}>
<div role='region' className={`column ${extraClasses || ''}`} ref={this.setRef}>
{children}
</div>
);

View File

@@ -8,8 +8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
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:' },
enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
});
@injectIntl
@@ -28,6 +27,7 @@ export default class ColumnHeader extends React.PureComponent {
showBackButton: PropTypes.bool,
notifCleaning: PropTypes.bool, // true only for the notification column
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
children: PropTypes.node,
pinned: PropTypes.bool,
onPin: PropTypes.func,
@@ -39,6 +39,7 @@ export default class ColumnHeader extends React.PureComponent {
state = {
collapsed: true,
animating: false,
animatingNCD: false,
};
handleToggleClick = (e) => {
@@ -71,16 +72,21 @@ export default class ColumnHeader extends React.PureComponent {
this.setState({ animating: false });
}
handleTransitionEndNCD = () => {
this.setState({ animatingNCD: false });
}
onEnterCleaningMode = () => {
this.setState({ animatingNCD: true });
this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
}
render () {
const { intl, icon, active, children, pinned, onPin, multiColumn, showBackButton, notifCleaning, localSettings } = this.props;
const { collapsed, animating } = this.state;
const { intl, icon, active, children, pinned, onPin, multiColumn, showBackButton, notifCleaning, notifCleaningActive } = this.props;
const { collapsed, animating, animatingNCD } = 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,
@@ -99,8 +105,20 @@ export default class ColumnHeader extends React.PureComponent {
'active': !collapsed,
});
const notifCleaningButtonClassName = classNames('column-header__button', {
'active': notifCleaningActive,
});
const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
'collapsed': !notifCleaningActive,
'animating': animatingNCD,
});
let extraContent, pinButton, moveButtons, backButton, collapseButton;
//*glitch
const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
@@ -149,14 +167,30 @@ export default class ColumnHeader extends React.PureComponent {
<div role='button heading' tabIndex='0' className={buttonClassName} onClick={this.handleTitleClick}>
<i className={`fa fa-fw fa-${icon} column-header__icon`} />
{title}
<div className='column-header__buttons'>
{notifCleaning ? (<NotificationPurgeButtonsContainer />) : null}
{backButton}
{ notifCleaning ? (
<button
aria-label={msgEnterNotifCleaning}
title={msgEnterNotifCleaning}
onClick={this.onEnterCleaningMode}
className={notifCleaningButtonClassName}
>
<i className='fa fa-eraser' />
</button>
) : null}
{collapseButton}
</div>
</div>
{ notifCleaning ? (
<div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
<div className='column-header__collapsible-inner nopad-drawer'>
{(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
</div>
</div>
) : null}
<div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}

View File

@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import {
enterNotificationClearingMode,
expandNotifications,
scrollTopNotifications,
} from '../../actions/notifications';
@@ -36,7 +37,15 @@ const mapStateToProps = state => ({
notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
});
@connect(mapStateToProps)
/* glitch */
const mapDispatchToProps = dispatch => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
dispatch,
});
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class Notifications extends React.PureComponent {
@@ -52,6 +61,7 @@ export default class Notifications extends React.PureComponent {
hasMore: PropTypes.bool,
localSettings: ImmutablePropTypes.map,
notifCleaningActive: PropTypes.bool,
onEnterCleaningMode: PropTypes.func,
};
static defaultProps = {
@@ -173,6 +183,7 @@ export default class Notifications extends React.PureComponent {
return (
<Column
ref={this.setColumnRef}
extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
>
<ColumnHeader
icon='bell'
@@ -186,6 +197,7 @@ export default class Notifications extends React.PureComponent {
localSettings={this.props.localSettings}
notifCleaning
notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text
onEnterCleaningMode={this.props.onEnterCleaningMode}
>
<ColumnSettingsContainer />
</ColumnHeader>

View File

@@ -13,6 +13,7 @@ import {
NOTIFICATION_MARK_FOR_DELETE,
NOTIFICATIONS_DELETE_MARKED_FAIL,
NOTIFICATIONS_ENTER_CLEARING_MODE,
NOTIFICATIONS_MARK_ALL_FOR_DELETE,
} from '../actions/notifications';
import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
import { TIMELINE_DELETE } from '../actions/timelines';
@@ -26,13 +27,15 @@ const initialState = ImmutableMap({
loaded: false,
isLoading: true,
cleaningMode: false,
// notification removal mark of new notifs loaded whilst cleaningMode is true.
markNewForDelete: false,
});
const notificationToMap = notification => ImmutableMap({
const notificationToMap = (state, notification) => ImmutableMap({
id: notification.id,
type: notification.type,
account: notification.account.id,
markedForDelete: false,
markedForDelete: state.get('markNewForDelete'),
status: notification.status ? notification.status.id : null,
});
@@ -48,7 +51,7 @@ const normalizeNotification = (state, notification) => {
list = list.take(20);
}
return list.unshift(notificationToMap(notification));
return list.unshift(notificationToMap(state, notification));
});
};
@@ -57,7 +60,7 @@ const normalizeNotifications = (state, notifications, next) => {
const loaded = state.get('loaded');
notifications.forEach((n, i) => {
items = items.set(i, notificationToMap(n));
items = items.set(i, notificationToMap(state, n));
});
if (state.get('next') === null) {
@@ -74,7 +77,7 @@ const appendNormalizedNotifications = (state, notifications, next) => {
let items = ImmutableList();
notifications.forEach((n, i) => {
items = items.set(i, notificationToMap(n));
items = items.set(i, notificationToMap(state, n));
});
return state
@@ -109,6 +112,16 @@ const markForDelete = (state, notificationId, yes) => {
}));
};
const markAllForDelete = (state, yes) => {
return state.update('items', list => list.map(item => {
if(yes !== null) {
return item.set('markedForDelete', yes);
} else {
return item.set('markedForDelete', !item.get('markedForDelete'));
}
}));
};
const unmarkAllForDelete = (state) => {
return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
};
@@ -118,6 +131,8 @@ const deleteMarkedNotifs = (state) => {
};
export default function notifications(state = initialState, action) {
let st;
switch(action.type) {
case NOTIFICATIONS_REFRESH_REQUEST:
case NOTIFICATIONS_EXPAND_REQUEST:
@@ -141,15 +156,31 @@ 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_MARK_FOR_DELETE:
return markForDelete(state, action.id, action.yes);
case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
return deleteMarkedNotifs(state).set('isLoading', false).set('cleaningMode', false);
return deleteMarkedNotifs(state).set('isLoading', false);
case NOTIFICATIONS_ENTER_CLEARING_MODE:
const st = state.set('cleaningMode', action.yes);
if (!action.yes)
return unmarkAllForDelete(st);
else return st;
st = state.set('cleaningMode', action.yes);
if (!action.yes) {
return unmarkAllForDelete(st).set('markNewForDelete', false);
} else {
return st;
}
case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
st = state;
if (action.yes === null) {
// Toggle - this is a bit confusing, as it toggles the all-none mode
//st = st.set('markNewForDelete', !st.get('markNewForDelete'));
} else {
st = st.set('markNewForDelete', action.yes);
}
return markAllForDelete(st, action.yes);
default:
return state;
}