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) => {
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 = {
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,
+ };
+};
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' },
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'];
// 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 () {
'account',
'muted',
'hidden',
+ 'unread',
];
state = {
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>
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' },
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
+ unread: PropTypes.bool,
};
handleMoveUp = () => {
}
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 />
}
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 />
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 />
}
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 />
}
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 />
}
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 />
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';
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([
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)
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
numPending: PropTypes.number,
+ lastReadId: PropTypes.string,
+ canMarkAsRead: PropTypes.bool,
};
static defaultProps = {
}
}
+ 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." />;
accountId={item.get('account')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
+ unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
/>
));
} else {
</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
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
+ extraButton={extraButton}
>
<ColumnSettingsContainer />
</ColumnHeader>
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';
handleWindowFocus = () => {
this.props.dispatch(focusApp());
+ this.props.dispatch(submitMarkers());
}
handleWindowBlur = () => {
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
}
+ this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications());
NOTIFICATIONS_LOAD_PENDING,
NOTIFICATIONS_MOUNT,
NOTIFICATIONS_UNMOUNT,
+ NOTIFICATIONS_MARK_AS_READ,
} from '../actions/notifications';
import {
ACCOUNT_BLOCK_SUCCESS,
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
FOLLOW_REQUEST_REJECT_SUCCESS,
} from '../actions/accounts';
+import {
+ MARKERS_FETCH_SUCCESS,
+} from '../actions/markers';
+import {
+ APP_FOCUS,
+ APP_UNFOCUS,
+} from '../actions/app';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
items: ImmutableList(),
hasMore: true,
top: false,
- mounted: false,
+ mounted: 0,
unread: 0,
+ lastReadId: '0',
+ readMarkerId: '0',
+ isTabVisible: true,
isLoading: false,
});
return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1);
}
- if (!top) {
+ if (shouldCountUnreadNotifications(state)) {
state = state.update('unread', unread => unread + 1);
+ } else {
+ state = state.set('lastReadId', notification.id);
}
return state.update('items', list => {
};
const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
+ const lastReadId = state.get('lastReadId');
let items = ImmutableList();
notifications.forEach((n, i) => {
mutable.set('hasMore', false);
}
+ if (shouldCountUnreadNotifications(state)) {
+ mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
+ } else {
+ const mostRecent = items.find(item => item !== null);
+ if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
+ mutable.set('lastReadId', mostRecent.get('id'));
+ }
+ }
+
mutable.set('isLoading', false);
});
};
return state.update('items', helper).update('pendingItems', helper);
};
+const clearUnread = (state) => {
+ state = state.set('unread', state.get('pendingItems').size);
+ const lastNotification = state.get('items').find(item => item !== null);
+ return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
+};
+
const updateTop = (state, top) => {
- if (top) {
- state = state.set('unread', state.get('pendingItems').size);
+ state = state.set('top', top);
+
+ if (!shouldCountUnreadNotifications(state)) {
+ state = clearUnread(state);
}
- return state.set('top', top);
+ return state;
};
const deleteByStatus = (state, statusId) => {
+ const lastReadId = state.get('lastReadId');
+
+ if (shouldCountUnreadNotifications(state)) {
+ const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+ state = state.update('unread', unread => unread - deletedUnread.size);
+ }
+
const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
+ const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+ state = state.update('unread', unread => unread - deletedUnread.size);
return state.update('items', helper).update('pendingItems', helper);
};
+const updateMounted = (state) => {
+ state = state.update('mounted', count => count + 1);
+ if (!shouldCountUnreadNotifications(state)) {
+ state = state.set('readMarkerId', state.get('lastReadId'));
+ state = clearUnread(state);
+ }
+ return state;
+};
+
+const updateVisibility = (state, visibility) => {
+ state = state.set('isTabVisible', visibility);
+ if (!shouldCountUnreadNotifications(state)) {
+ state = state.set('readMarkerId', state.get('lastReadId'));
+ state = clearUnread(state);
+ }
+ return state;
+};
+
+const shouldCountUnreadNotifications = (state) => {
+ const isTabVisible = state.get('isTabVisible');
+ const isOnTop = state.get('top');
+ const isMounted = state.get('mounted') > 0;
+ const lastReadId = state.get('lastReadId');
+ const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+
+ return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+};
+
+const recountUnread = (state, last_read_id) => {
+ return state.withMutations(mutable => {
+ if (compareId(last_read_id, mutable.get('lastReadId')) > 0) {
+ mutable.set('lastReadId', last_read_id);
+ }
+
+ if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
+ mutable.set('readMarkerId', last_read_id);
+ }
+
+ if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) {
+ mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0));
+ }
+ });
+};
+
export default function notifications(state = initialState, action) {
switch(action.type) {
+ case MARKERS_FETCH_SUCCESS:
+ return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state;
+ case NOTIFICATIONS_MOUNT:
+ return updateMounted(state);
+ case NOTIFICATIONS_UNMOUNT:
+ return state.update('mounted', count => count - 1);
+ case APP_FOCUS:
+ return updateVisibility(state, true);
+ case APP_UNFOCUS:
+ return updateVisibility(state, false);
case NOTIFICATIONS_LOAD_PENDING:
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
case NOTIFICATIONS_EXPAND_REQUEST:
return action.timeline === 'home' ?
state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
state;
- case NOTIFICATIONS_MOUNT:
- return state.set('mounted', true);
- case NOTIFICATIONS_UNMOUNT:
- return state.set('mounted', false);
+ case NOTIFICATIONS_MARK_AS_READ:
+ const lastNotification = state.get('items').find(item => item !== null);
+ return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
default:
return state;
}
}
}
}
+
+.notification,
+.status__wrapper {
+ position: relative;
+
+ &.unread {
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: 0;
+ width: 100%;
+ height: 100%;
+ border-left: 2px solid $highlight-text-color;
+ pointer-events: none;
+ }
+ }
+}