Add unread notification markers (#14818)
* Add unread notification markers Fixes #14804 * Allow IntersectionObserverArticle's children to be updated
This commit is contained in:
parent
03b6b034b9
commit
ff89025979
9 changed files with 227 additions and 33 deletions
|
@ -3,6 +3,9 @@ import { debounce } from 'lodash';
|
||||||
import compareId from '../compare_id';
|
import compareId from '../compare_id';
|
||||||
import { showAlertForError } from './alerts';
|
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 MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
|
||||||
|
|
||||||
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
|
||||||
|
@ -58,7 +61,7 @@ const _buildParams = (state) => {
|
||||||
const params = {};
|
const params = {};
|
||||||
|
|
||||||
const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
|
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) {
|
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
|
||||||
params.home = {
|
params.home = {
|
||||||
|
@ -100,3 +103,39 @@ export function submitMarkersSuccess({ home, notifications }) {
|
||||||
export function submitMarkers() {
|
export function submitMarkers() {
|
||||||
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -33,6 +33,8 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
|
||||||
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
|
||||||
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
|
||||||
|
|
||||||
defineMessages({
|
defineMessages({
|
||||||
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
|
||||||
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
|
||||||
|
|
|
@ -2,10 +2,7 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
||||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
|
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
|
// Diff these props in the "unrendered" state
|
||||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||||
|
|
||||||
|
@ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component {
|
||||||
// If we're going from rendered to unrendered (or vice versa) then update
|
// If we're going from rendered to unrendered (or vice versa) then update
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Otherwise, diff based on props
|
// If we are and remain hidden, diff based on props
|
||||||
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
|
if (isUnrendered) {
|
||||||
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
|
return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
|
||||||
|
}
|
||||||
|
// Else, assume the children have changed
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
|
|
|
@ -104,6 +104,7 @@ class Status extends ImmutablePureComponent {
|
||||||
'account',
|
'account',
|
||||||
'muted',
|
'muted',
|
||||||
'hidden',
|
'hidden',
|
||||||
|
'unread',
|
||||||
];
|
];
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -438,10 +439,10 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<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}
|
{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__expand' onClick={this.handleExpandClick} role='presentation' />
|
||||||
<div className='status__info'>
|
<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>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container';
|
||||||
import FollowRequestContainer from '../containers/follow_request_container';
|
import FollowRequestContainer from '../containers/follow_request_container';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import Permalink from 'mastodon/components/permalink';
|
import Permalink from 'mastodon/components/permalink';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
|
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
|
||||||
|
@ -50,6 +51,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
cacheMediaWidth: PropTypes.func,
|
cacheMediaWidth: PropTypes.func,
|
||||||
cachedMediaWidth: PropTypes.number,
|
cachedMediaWidth: PropTypes.number,
|
||||||
|
unread: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = () => {
|
handleMoveUp = () => {
|
||||||
|
@ -114,11 +116,11 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFollow (notification, account, link) {
|
renderFollow (notification, account, link) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='user-plus' fixedWidth />
|
<Icon id='user-plus' fixedWidth />
|
||||||
|
@ -136,11 +138,11 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFollowRequest (notification, account, link) {
|
renderFollowRequest (notification, account, link) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='user' fixedWidth />
|
<Icon id='user' fixedWidth />
|
||||||
|
@ -170,16 +172,17 @@ class Notification extends ImmutablePureComponent {
|
||||||
updateScrollBottom={this.props.updateScrollBottom}
|
updateScrollBottom={this.props.updateScrollBottom}
|
||||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||||
|
unread={this.props.unread}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFavourite (notification, link) {
|
renderFavourite (notification, link) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='star' className='star-icon' fixedWidth />
|
<Icon id='star' className='star-icon' fixedWidth />
|
||||||
|
@ -207,11 +210,11 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderReblog (notification, link) {
|
renderReblog (notification, link) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='retweet' fixedWidth />
|
<Icon id='retweet' fixedWidth />
|
||||||
|
@ -239,11 +242,11 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStatus (notification, link) {
|
renderStatus (notification, link) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='home' fixedWidth />
|
<Icon id='home' fixedWidth />
|
||||||
|
@ -271,13 +274,13 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPoll (notification, account) {
|
renderPoll (notification, account) {
|
||||||
const { intl } = this.props;
|
const { intl, unread } = this.props;
|
||||||
const ownPoll = me === account.get('id');
|
const ownPoll = me === account.get('id');
|
||||||
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
|
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={this.getHandlers()}>
|
<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__message'>
|
||||||
<div className='notification__favourite-icon-wrapper'>
|
<div className='notification__favourite-icon-wrapper'>
|
||||||
<Icon id='tasks' fixedWidth />
|
<Icon id='tasks' fixedWidth />
|
||||||
|
|
|
@ -4,7 +4,14 @@ import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Column from '../../components/column';
|
import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
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 { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import NotificationContainer from './containers/notification_container';
|
import NotificationContainer from './containers/notification_container';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
@ -15,9 +22,12 @@ import { List as ImmutableList } from 'immutable';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
import ScrollableList from '../../components/scrollable_list';
|
||||||
import LoadGap from '../../components/load_gap';
|
import LoadGap from '../../components/load_gap';
|
||||||
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import compareId from 'mastodon/compare_id';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
|
||||||
|
markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const getNotifications = createSelector([
|
const getNotifications = createSelector([
|
||||||
|
@ -42,6 +52,8 @@ const mapStateToProps = state => ({
|
||||||
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
|
||||||
hasMore: state.getIn(['notifications', 'hasMore']),
|
hasMore: state.getIn(['notifications', 'hasMore']),
|
||||||
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
|
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)
|
export default @connect(mapStateToProps)
|
||||||
|
@ -60,6 +72,8 @@ class Notifications extends React.PureComponent {
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
numPending: PropTypes.number,
|
numPending: PropTypes.number,
|
||||||
|
lastReadId: PropTypes.string,
|
||||||
|
canMarkAsRead: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -146,8 +160,12 @@ class Notifications extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMarkAsRead = () => {
|
||||||
|
this.props.dispatch(markNotificationsAsRead());
|
||||||
|
};
|
||||||
|
|
||||||
render () {
|
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 pinned = !!columnId;
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
|
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
|
||||||
|
|
||||||
|
@ -174,6 +192,7 @@ class Notifications extends React.PureComponent {
|
||||||
accountId={item.get('account')}
|
accountId={item.get('account')}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={this.handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
|
unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
|
@ -202,6 +221,21 @@ class Notifications extends React.PureComponent {
|
||||||
</ScrollableList>
|
</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 (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
@ -213,6 +247,7 @@ class Notifications extends React.PureComponent {
|
||||||
onClick={this.handleHeaderClick}
|
onClick={this.handleHeaderClick}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
|
extraButton={extraButton}
|
||||||
>
|
>
|
||||||
<ColumnSettingsContainer />
|
<ColumnSettingsContainer />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { expandNotifications } from '../../actions/notifications';
|
||||||
import { fetchFilters } from '../../actions/filters';
|
import { fetchFilters } from '../../actions/filters';
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
import { focusApp, unfocusApp } from 'mastodon/actions/app';
|
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 { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||||
import UploadArea from './components/upload_area';
|
import UploadArea from './components/upload_area';
|
||||||
import ColumnsAreaContainer from './containers/columns_area_container';
|
import ColumnsAreaContainer from './containers/columns_area_container';
|
||||||
|
@ -265,6 +265,7 @@ class UI extends React.PureComponent {
|
||||||
|
|
||||||
handleWindowFocus = () => {
|
handleWindowFocus = () => {
|
||||||
this.props.dispatch(focusApp());
|
this.props.dispatch(focusApp());
|
||||||
|
this.props.dispatch(submitMarkers());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleWindowBlur = () => {
|
handleWindowBlur = () => {
|
||||||
|
@ -368,6 +369,7 @@ class UI extends React.PureComponent {
|
||||||
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
|
window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.props.dispatch(fetchMarkers());
|
||||||
this.props.dispatch(expandHomeTimeline());
|
this.props.dispatch(expandHomeTimeline());
|
||||||
this.props.dispatch(expandNotifications());
|
this.props.dispatch(expandNotifications());
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
NOTIFICATIONS_LOAD_PENDING,
|
NOTIFICATIONS_LOAD_PENDING,
|
||||||
NOTIFICATIONS_MOUNT,
|
NOTIFICATIONS_MOUNT,
|
||||||
NOTIFICATIONS_UNMOUNT,
|
NOTIFICATIONS_UNMOUNT,
|
||||||
|
NOTIFICATIONS_MARK_AS_READ,
|
||||||
} from '../actions/notifications';
|
} from '../actions/notifications';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
|
@ -16,6 +17,13 @@ import {
|
||||||
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
|
FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
|
||||||
FOLLOW_REQUEST_REJECT_SUCCESS,
|
FOLLOW_REQUEST_REJECT_SUCCESS,
|
||||||
} from '../actions/accounts';
|
} 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 { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
|
@ -26,8 +34,11 @@ const initialState = ImmutableMap({
|
||||||
items: ImmutableList(),
|
items: ImmutableList(),
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
top: false,
|
top: false,
|
||||||
mounted: false,
|
mounted: 0,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
|
lastReadId: '0',
|
||||||
|
readMarkerId: '0',
|
||||||
|
isTabVisible: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -46,8 +57,10 @@ const normalizeNotification = (state, notification, usePendingItems) => {
|
||||||
return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1);
|
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);
|
state = state.update('unread', unread => unread + 1);
|
||||||
|
} else {
|
||||||
|
state = state.set('lastReadId', notification.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.update('items', list => {
|
return state.update('items', list => {
|
||||||
|
@ -60,6 +73,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
|
const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
|
||||||
|
const lastReadId = state.get('lastReadId');
|
||||||
let items = ImmutableList();
|
let items = ImmutableList();
|
||||||
|
|
||||||
notifications.forEach((n, i) => {
|
notifications.forEach((n, i) => {
|
||||||
|
@ -87,6 +101,15 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
|
||||||
mutable.set('hasMore', false);
|
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);
|
mutable.set('isLoading', false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -96,21 +119,92 @@ const filterNotifications = (state, accountIds, type) => {
|
||||||
return state.update('items', helper).update('pendingItems', helper);
|
return state.update('items', helper).update('pendingItems', helper);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTop = (state, top) => {
|
const clearUnread = (state) => {
|
||||||
if (top) {
|
|
||||||
state = state.set('unread', state.get('pendingItems').size);
|
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) => {
|
||||||
|
state = state.set('top', top);
|
||||||
|
|
||||||
|
if (!shouldCountUnreadNotifications(state)) {
|
||||||
|
state = clearUnread(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.set('top', top);
|
return state;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteByStatus = (state, statusId) => {
|
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 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);
|
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) {
|
export default function notifications(state = initialState, action) {
|
||||||
switch(action.type) {
|
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:
|
case NOTIFICATIONS_LOAD_PENDING:
|
||||||
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
|
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
|
||||||
case NOTIFICATIONS_EXPAND_REQUEST:
|
case NOTIFICATIONS_EXPAND_REQUEST:
|
||||||
|
@ -144,10 +238,9 @@ export default function notifications(state = initialState, action) {
|
||||||
return action.timeline === 'home' ?
|
return action.timeline === 'home' ?
|
||||||
state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
|
state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
|
||||||
state;
|
state;
|
||||||
case NOTIFICATIONS_MOUNT:
|
case NOTIFICATIONS_MARK_AS_READ:
|
||||||
return state.set('mounted', true);
|
const lastNotification = state.get('items').find(item => item !== null);
|
||||||
case NOTIFICATIONS_UNMOUNT:
|
return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
|
||||||
return state.set('mounted', false);
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7015,3 +7015,22 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue