Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unread notification markers #14818

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion app/javascript/mastodon/actions/markers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { debounce } from 'lodash';
import compareId from '../compare_id';
import { showAlertForError } from './alerts';

export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';

export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
Expand Down Expand Up @@ -58,7 +61,7 @@ const _buildParams = (state) => {
const params = {};

const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
const lastNotificationId = state.getIn(['notifications', 'lastReadId']);

if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
params.home = {
Expand Down Expand Up @@ -100,3 +103,39 @@ export function submitMarkersSuccess({ home, notifications }) {
export function submitMarkers() {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
};

export const fetchMarkers = () => (dispatch, getState) => {
const params = { timeline: ['notifications'] };

dispatch(fetchMarkersRequest());

api(getState).get('/api/v1/markers', { params }).then(response => {
dispatch(fetchMarkersSuccess(response.data));
}).catch(error => {
dispatch(fetchMarkersFail(error));
});
};

export function fetchMarkersRequest() {
return {
type: MARKERS_FETCH_REQUEST,
skipLoading: true,
};
};

export function fetchMarkersSuccess(markers) {
return {
type: MARKERS_FETCH_SUCCESS,
markers,
skipLoading: true,
};
};

export function fetchMarkersFail(error) {
return {
type: MARKERS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
};
2 changes: 2 additions & 0 deletions app/javascript/mastodon/actions/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';

export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';

defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import { is } from 'immutable';

// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];

Expand Down Expand Up @@ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component {
// If we're going from rendered to unrendered (or vice versa) then update
return true;
}
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
// If we are and remain hidden, diff based on props
if (isUnrendered) {
return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
}
// Else, assume the children have changed
return true;
}

componentDidMount () {
Expand Down
5 changes: 3 additions & 2 deletions app/javascript/mastodon/components/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class Status extends ImmutablePureComponent {
'account',
'muted',
'hidden',
'unread',
];

state = {
Expand Down Expand Up @@ -438,10 +439,10 @@ class Status extends ImmutablePureComponent {

return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
{prepend}

<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container';
import FollowRequestContainer from '../containers/follow_request_container';
import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink';
import classNames from 'classnames';

const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
Expand Down Expand Up @@ -50,6 +51,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
unread: PropTypes.bool,
};

handleMoveUp = () => {
Expand Down Expand Up @@ -114,11 +116,11 @@ class Notification extends ImmutablePureComponent {
}

renderFollow (notification, account, link) {
const { intl } = this.props;
const { intl, unread } = this.props;

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
<div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='user-plus' fixedWidth />
Expand All @@ -136,11 +138,11 @@ class Notification extends ImmutablePureComponent {
}

renderFollowRequest (notification, account, link) {
const { intl } = this.props;
const { intl, unread } = this.props;

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='user' fixedWidth />
Expand Down Expand Up @@ -170,16 +172,17 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
unread={this.props.unread}
/>
);
}

renderFavourite (notification, link) {
const { intl } = this.props;
const { intl, unread } = this.props;

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className={classNames('notification notification-favourite focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='star' className='star-icon' fixedWidth />
Expand Down Expand Up @@ -207,11 +210,11 @@ class Notification extends ImmutablePureComponent {
}

renderReblog (notification, link) {
const { intl } = this.props;
const { intl, unread } = this.props;

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className={classNames('notification notification-reblog focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='retweet' fixedWidth />
Expand Down Expand Up @@ -239,11 +242,11 @@ class Notification extends ImmutablePureComponent {
}

renderStatus (notification, link) {
const { intl } = this.props;
const { intl, unread } = this.props;

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-status focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className={classNames('notification notification-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='home' fixedWidth />
Expand Down Expand Up @@ -271,13 +274,13 @@ class Notification extends ImmutablePureComponent {
}

renderPoll (notification, account) {
const { intl } = this.props;
const { intl, unread } = this.props;
const ownPoll = me === account.get('id');
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
<div className={classNames('notification notification-poll focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='tasks' fixedWidth />
Expand Down
39 changes: 37 additions & 2 deletions app/javascript/mastodon/features/notifications/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ 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, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications';
import {
expandNotifications,
scrollTopNotifications,
loadPending,
mountNotifications,
unmountNotifications,
markNotificationsAsRead,
} from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
Expand All @@ -15,9 +22,12 @@ import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
import LoadGap from '../../components/load_gap';
import Icon from 'mastodon/components/icon';
import compareId from 'mastodon/compare_id';

const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
});

const getNotifications = createSelector([
Expand All @@ -42,6 +52,8 @@ const mapStateToProps = state => ({
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
hasMore: state.getIn(['notifications', 'hasMore']),
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
lastReadId: state.getIn(['notifications', 'readMarkerId']),
canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
});

export default @connect(mapStateToProps)
Expand All @@ -60,6 +72,8 @@ class Notifications extends React.PureComponent {
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
numPending: PropTypes.number,
lastReadId: PropTypes.string,
canMarkAsRead: PropTypes.bool,
};

static defaultProps = {
Expand Down Expand Up @@ -146,8 +160,12 @@ class Notifications extends React.PureComponent {
}
}

handleMarkAsRead = () => {
this.props.dispatch(markNotificationsAsRead());
};

render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;

Expand All @@ -174,6 +192,7 @@ class Notifications extends React.PureComponent {
accountId={item.get('account')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
/>
));
} else {
Expand Down Expand Up @@ -202,6 +221,21 @@ class Notifications extends React.PureComponent {
</ScrollableList>
);

let extraButton = null;

if (canMarkAsRead) {
extraButton = (
<button
aria-label={intl.formatMessage(messages.markAsRead)}
title={intl.formatMessage(messages.markAsRead)}
onClick={this.handleMarkAsRead}
className='column-header__button'
>
<Icon id='check' />
</button>
);
}

return (
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
Expand All @@ -213,6 +247,7 @@ class Notifications extends React.PureComponent {
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
extraButton={extraButton}
>
<ColumnSettingsContainer />
</ColumnHeader>
Expand Down
4 changes: 3 additions & 1 deletion app/javascript/mastodon/features/ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers } from 'mastodon/actions/markers';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
Expand Down Expand Up @@ -265,6 +265,7 @@ class UI extends React.PureComponent {

handleWindowFocus = () => {
this.props.dispatch(focusApp());
this.props.dispatch(submitMarkers());
}

handleWindowBlur = () => {
Expand Down Expand Up @@ -368,6 +369,7 @@ class UI extends React.PureComponent {
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
}

this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());

Expand Down
Loading