index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import React from 'react';
  2. import { connect } from 'react-redux';
  3. import PropTypes from 'prop-types';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import Column from '../../components/column';
  6. import ColumnHeader from '../../components/column_header';
  7. import {
  8. expandNotifications,
  9. scrollTopNotifications,
  10. loadPending,
  11. mountNotifications,
  12. unmountNotifications,
  13. markNotificationsAsRead,
  14. } from '../../actions/notifications';
  15. import { submitMarkers } from '../../actions/markers';
  16. import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
  17. import NotificationContainer from './containers/notification_container';
  18. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  19. import ColumnSettingsContainer from './containers/column_settings_container';
  20. import FilterBarContainer from './containers/filter_bar_container';
  21. import { createSelector } from 'reselect';
  22. import { List as ImmutableList } from 'immutable';
  23. import { debounce } from 'lodash';
  24. import ScrollableList from '../../components/scrollable_list';
  25. import LoadGap from '../../components/load_gap';
  26. import Icon from 'mastodon/components/icon';
  27. import compareId from 'mastodon/compare_id';
  28. import NotificationsPermissionBanner from './components/notifications_permission_banner';
  29. import NotSignedInIndicator from 'mastodon/components/not_signed_in_indicator';
  30. import { Helmet } from 'react-helmet';
  31. const messages = defineMessages({
  32. title: { id: 'column.notifications', defaultMessage: 'Notifications' },
  33. markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
  34. });
  35. const getExcludedTypes = createSelector([
  36. state => state.getIn(['settings', 'notifications', 'shows']),
  37. ], (shows) => {
  38. return ImmutableList(shows.filter(item => !item).keys());
  39. });
  40. const getNotifications = createSelector([
  41. state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
  42. state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
  43. getExcludedTypes,
  44. state => state.getIn(['notifications', 'items']),
  45. ], (showFilterBar, allowedType, excludedTypes, notifications) => {
  46. if (!showFilterBar || allowedType === 'all') {
  47. // used if user changed the notification settings after loading the notifications from the server
  48. // otherwise a list of notifications will come pre-filtered from the backend
  49. // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
  50. return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
  51. }
  52. return notifications.filter(item => item === null || allowedType === item.get('type'));
  53. });
  54. const mapStateToProps = state => ({
  55. showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
  56. notifications: getNotifications(state),
  57. isLoading: state.getIn(['notifications', 'isLoading'], true),
  58. isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
  59. hasMore: state.getIn(['notifications', 'hasMore']),
  60. numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
  61. lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0',
  62. canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
  63. needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']),
  64. });
  65. export default @connect(mapStateToProps)
  66. @injectIntl
  67. class Notifications extends React.PureComponent {
  68. static contextTypes = {
  69. identity: PropTypes.object,
  70. };
  71. static propTypes = {
  72. columnId: PropTypes.string,
  73. notifications: ImmutablePropTypes.list.isRequired,
  74. showFilterBar: PropTypes.bool.isRequired,
  75. dispatch: PropTypes.func.isRequired,
  76. intl: PropTypes.object.isRequired,
  77. isLoading: PropTypes.bool,
  78. isUnread: PropTypes.bool,
  79. multiColumn: PropTypes.bool,
  80. hasMore: PropTypes.bool,
  81. numPending: PropTypes.number,
  82. lastReadId: PropTypes.string,
  83. canMarkAsRead: PropTypes.bool,
  84. needsNotificationPermission: PropTypes.bool,
  85. };
  86. static defaultProps = {
  87. trackScroll: true,
  88. };
  89. componentWillMount() {
  90. this.props.dispatch(mountNotifications());
  91. }
  92. componentWillUnmount () {
  93. this.handleLoadOlder.cancel();
  94. this.handleScrollToTop.cancel();
  95. this.handleScroll.cancel();
  96. this.props.dispatch(scrollTopNotifications(false));
  97. this.props.dispatch(unmountNotifications());
  98. }
  99. handleLoadGap = (maxId) => {
  100. this.props.dispatch(expandNotifications({ maxId }));
  101. };
  102. handleLoadOlder = debounce(() => {
  103. const last = this.props.notifications.last();
  104. this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
  105. }, 300, { leading: true });
  106. handleLoadPending = () => {
  107. this.props.dispatch(loadPending());
  108. };
  109. handleScrollToTop = debounce(() => {
  110. this.props.dispatch(scrollTopNotifications(true));
  111. }, 100);
  112. handleScroll = debounce(() => {
  113. this.props.dispatch(scrollTopNotifications(false));
  114. }, 100);
  115. handlePin = () => {
  116. const { columnId, dispatch } = this.props;
  117. if (columnId) {
  118. dispatch(removeColumn(columnId));
  119. } else {
  120. dispatch(addColumn('NOTIFICATIONS', {}));
  121. }
  122. }
  123. handleMove = (dir) => {
  124. const { columnId, dispatch } = this.props;
  125. dispatch(moveColumn(columnId, dir));
  126. }
  127. handleHeaderClick = () => {
  128. this.column.scrollTop();
  129. }
  130. setColumnRef = c => {
  131. this.column = c;
  132. }
  133. handleMoveUp = id => {
  134. const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
  135. this._selectChild(elementIndex, true);
  136. }
  137. handleMoveDown = id => {
  138. const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
  139. this._selectChild(elementIndex, false);
  140. }
  141. _selectChild (index, align_top) {
  142. const container = this.column.node;
  143. const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
  144. if (element) {
  145. if (align_top && container.scrollTop > element.offsetTop) {
  146. element.scrollIntoView(true);
  147. } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
  148. element.scrollIntoView(false);
  149. }
  150. element.focus();
  151. }
  152. }
  153. handleMarkAsRead = () => {
  154. this.props.dispatch(markNotificationsAsRead());
  155. this.props.dispatch(submitMarkers({ immediate: true }));
  156. };
  157. render () {
  158. const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
  159. const pinned = !!columnId;
  160. const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. When other people interact with you, you will see it here." />;
  161. const { signedIn } = this.context.identity;
  162. let scrollableContent = null;
  163. const filterBarContainer = (signedIn && showFilterBar)
  164. ? (<FilterBarContainer />)
  165. : null;
  166. if (isLoading && this.scrollableContent) {
  167. scrollableContent = this.scrollableContent;
  168. } else if (notifications.size > 0 || hasMore) {
  169. scrollableContent = notifications.map((item, index) => item === null ? (
  170. <LoadGap
  171. key={'gap:' + notifications.getIn([index + 1, 'id'])}
  172. disabled={isLoading}
  173. maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
  174. onClick={this.handleLoadGap}
  175. />
  176. ) : (
  177. <NotificationContainer
  178. key={item.get('id')}
  179. notification={item}
  180. accountId={item.get('account')}
  181. onMoveUp={this.handleMoveUp}
  182. onMoveDown={this.handleMoveDown}
  183. unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
  184. />
  185. ));
  186. } else {
  187. scrollableContent = null;
  188. }
  189. this.scrollableContent = scrollableContent;
  190. let scrollContainer;
  191. if (signedIn) {
  192. scrollContainer = (
  193. <ScrollableList
  194. scrollKey={`notifications-${columnId}`}
  195. trackScroll={!pinned}
  196. isLoading={isLoading}
  197. showLoading={isLoading && notifications.size === 0}
  198. hasMore={hasMore}
  199. numPending={numPending}
  200. prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
  201. alwaysPrepend
  202. emptyMessage={emptyMessage}
  203. onLoadMore={this.handleLoadOlder}
  204. onLoadPending={this.handleLoadPending}
  205. onScrollToTop={this.handleScrollToTop}
  206. onScroll={this.handleScroll}
  207. bindToDocument={!multiColumn}
  208. >
  209. {scrollableContent}
  210. </ScrollableList>
  211. );
  212. } else {
  213. scrollContainer = <NotSignedInIndicator />;
  214. }
  215. let extraButton = null;
  216. if (canMarkAsRead) {
  217. extraButton = (
  218. <button
  219. aria-label={intl.formatMessage(messages.markAsRead)}
  220. title={intl.formatMessage(messages.markAsRead)}
  221. onClick={this.handleMarkAsRead}
  222. className='column-header__button'
  223. >
  224. <Icon id='check' />
  225. </button>
  226. );
  227. }
  228. return (
  229. <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
  230. <ColumnHeader
  231. icon='bell'
  232. active={isUnread}
  233. title={intl.formatMessage(messages.title)}
  234. onPin={this.handlePin}
  235. onMove={this.handleMove}
  236. onClick={this.handleHeaderClick}
  237. pinned={pinned}
  238. multiColumn={multiColumn}
  239. extraButton={extraButton}
  240. >
  241. <ColumnSettingsContainer />
  242. </ColumnHeader>
  243. {filterBarContainer}
  244. {scrollContainer}
  245. <Helmet>
  246. <title>{intl.formatMessage(messages.title)}</title>
  247. <meta name='robots' content='noindex' />
  248. </Helmet>
  249. </Column>
  250. );
  251. }
  252. }