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

@ -24,7 +24,10 @@ import NotificationPurgeButtons from './notification_purge_buttons';
import {
deleteMarkedNotifications,
enterNotificationClearingMode,
markAllNotifications,
} from '../../../../mastodon/actions/notifications';
import { defineMessages, injectIntl } from 'react-intl';
import { openModal } from '../../../../mastodon/actions/modal';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
@ -39,18 +42,39 @@ deleting notifications.
*/
const mapDispatchToProps = dispatch => ({
const messages = defineMessages({
clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
onDeleteMarkedNotifications() {
dispatch(deleteMarkedNotifications());
onDeleteMarked() {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(deleteMarkedNotifications()),
}));
},
onMarkAll() {
dispatch(markAllNotifications(true));
},
onMarkNone() {
dispatch(markAllNotifications(false));
},
onInvert() {
dispatch(markAllNotifications(null));
},
});
const mapStateToProps = state => ({
active: state.getIn(['notifications', 'cleaningMode']),
markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons);
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));

View File

@ -16,83 +16,45 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
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' },
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
});
@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
onDeleteMarked : PropTypes.func.isRequired,
onMarkAll : PropTypes.func.isRequired,
onMarkNone : PropTypes.func.isRequired,
onInvert : PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
markNewForDelete: PropTypes.bool,
};
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>
);
}
const { intl, markNewForDelete } = this.props;
//className='active'
return (
<div className='column-header__notif-cleaning-buttons'>
{acceptButton}{abortButton}{enterButton}
<button onClick={this.props.onMarkAll} className={markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnAll)}
</button>
<button onClick={this.props.onMarkNone} className={!markNewForDelete ? 'active' : ''}>
<b></b><br />{intl.formatMessage(messages.btnNone)}
</button>
<button onClick={this.props.onInvert}>
<b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
</button>
<button onClick={this.props.onDeleteMarked}>
<i className='fa fa-trash' /><br />{intl.formatMessage(messages.btnApply)}
</button>
</div>
);
}

View File

@ -45,6 +45,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
notification: getNotification(state, props.notification, props.accountId),
settings: state.get('local_settings'),
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
});
return mapStateToProps;

View File

@ -43,7 +43,7 @@ const mapDispatchToProps = dispatch => ({
});
const mapStateToProps = state => ({
revealed: state.getIn(['notifications', 'cleaningMode']),
show: state.getIn(['notifications', 'cleaningMode']),
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View File

@ -24,7 +24,7 @@ export default class NotificationOverlay extends ImmutablePureComponent {
static propTypes = {
notification : ImmutablePropTypes.map.isRequired,
onMarkForDelete : PropTypes.func.isRequired,
revealed : PropTypes.bool.isRequired,
show : PropTypes.bool.isRequired,
intl : PropTypes.object.isRequired,
};
@ -35,25 +35,27 @@ export default class NotificationOverlay extends ImmutablePureComponent {
}
render () {
const { notification, revealed, intl } = this.props;
const { notification, show, intl } = this.props;
const active = notification.get('markedForDelete');
const label = intl.formatMessage(messages.markForDeletion);
return (
return show ? (
<div
aria-label={label}
role='checkbox'
aria-checked={active}
tabIndex={0}
className={`notification__dismiss-overlay ${active ? 'active' : ''} ${revealed ? 'show' : ''}`}
className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
onClick={this.onToggleMark}
>
<div className='notification__dismiss-overlay__ckbox' aria-hidden='true' title={label}>
{active ? (<i className='fa fa-check' />) : ''}
<div className='wrappy'>
<div className='ckbox' aria-hidden='true' title={label}>
{active ? (<i className='fa fa-check' />) : ''}
</div>
</div>
</div>
);
) : null;
}
}

View File

@ -29,5 +29,14 @@
"settings.navbar_under": "Navbar at the bottom (Mobile only)",
"status.collapse": "Collapse",
"status.uncollapse": "Uncollapse",
"notification.markForDeletion": "Mark for deletion"
"notification.markForDeletion": "Mark for deletion",
"notifications.clear": "Clear all my notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"notifications.marked_clear": "Clear selected notifications",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_none": "Select\nnone",
"notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_apply": "Clear\nselected"
}

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

View File

@ -1,5 +1,6 @@
@import 'mixins';
@import 'variables';
@import 'variables-glitch';
@import 'fonts/roboto';
@import 'fonts/roboto-mono';
@import 'fonts/montserrat';

View File

@ -1,4 +1,5 @@
@import 'variables';
@import 'variables-glitch';
.app-body {
-webkit-overflow-scrolling: touch;
@ -451,62 +452,6 @@
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 & {
box-shadow: 0 0 2px 2px #3e6fc1;
}
}
// --- Extra clickable area in the status gutter ---
.ui.wide {
@mixin xtraspaces-full {
@ -683,6 +628,12 @@
position: absolute;
}
.notif-cleaning {
.status, .notification-follow {
padding-right: ($dismiss-overlay-width + 0.5rem);
}
}
.notification-follow {
position: relative;
@ -2479,17 +2430,88 @@ button.icon-button.active i.fa-retweet {
background: lighten($ui-base-color, 8%);
}
}
// glitch - added focus ring for keyboard navigation
&:focus {
text-shadow: 0 0 4px darken($ui-highlight-color, 5%);
}
}
.scrollable > div > :first-child .notification__dismiss-overlay > .wrappy {
border-top: 1px solid $ui-base-color;
}
.notification__dismiss-overlay {
overflow: hidden;
position: absolute;
top: 0;
right: 0;
bottom: -1px;
padding-left: 15px; // space for the box shadow to be visible
z-index: 999;
align-items: center;
justify-content: flex-end;
cursor: pointer;
display: flex;
.wrappy {
width: $dismiss-overlay-width;
align-self: stretch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: lighten($ui-base-color, 8%);
border-left: 1px solid lighten($ui-base-color, 20%);
box-shadow: 0 0 5px black;
border-bottom: 1px solid $ui-base-color;
}
.ckbox {
border: 2px solid $ui-primary-color;
border-radius: 2px;
width: 30px;
height: 30px;
font-size: 20px;
color: $ui-primary-color;
text-shadow: 0 0 5px black;
display: flex;
justify-content: center;
align-items: center;
}
&:focus {
outline: 0 !important;
.ckbox {
box-shadow: 0 0 1px 1px $ui-highlight-color;
}
}
}
.column-header__notif-cleaning-buttons {
display: flex;
align-items: stretch;
justify-content: space-around;
button {
@extend .column-header__button;
padding-left: 12px;
padding-right: 12px;
background: transparent;
text-align: center;
padding: 10px 0;
white-space: pre-wrap;
}
b {
font-weight: bold;
}
}
// The notifs drawer with no padding to have more space for the buttons
.column-header__collapsible-inner.nopad-drawer {
padding: 0;
}
.column-header__collapsible {
@ -2508,6 +2530,15 @@ button.icon-button.active i.fa-retweet {
&.animating {
overflow-y: hidden;
}
// notif cleaning drawer
&.ncd {
transition: none;
&.collapsed {
max-height: 0;
opacity: 0.7;
}
}
}
.column-header__collapsible-inner {

View File

@ -0,0 +1,3 @@
// glitch-soc added variables
$dismiss-overlay-width: 4rem;