You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

271 lines
9.7 KiB

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