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 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) => { | const updateTop = (state, top) => { | ||||||
|   if (top) { |   state = state.set('top', top); | ||||||
|     state = state.set('unread', state.get('pendingItems').size); | 
 | ||||||
|  |   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…
	
	Add table
		Add a link
		
	
		Reference in a new issue