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.

273 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(['notifications', 'readMarkerId']),
  60. canMarkAsRead: 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. shouldUpdateScroll: PropTypes.func,
  72. intl: PropTypes.object.isRequired,
  73. isLoading: PropTypes.bool,
  74. isUnread: PropTypes.bool,
  75. multiColumn: PropTypes.bool,
  76. hasMore: PropTypes.bool,
  77. numPending: PropTypes.number,
  78. lastReadId: PropTypes.string,
  79. canMarkAsRead: PropTypes.bool,
  80. needsNotificationPermission: PropTypes.bool,
  81. };
  82. static defaultProps = {
  83. trackScroll: true,
  84. };
  85. componentWillMount() {
  86. this.props.dispatch(mountNotifications());
  87. }
  88. componentWillUnmount () {
  89. this.handleLoadOlder.cancel();
  90. this.handleScrollToTop.cancel();
  91. this.handleScroll.cancel();
  92. this.props.dispatch(scrollTopNotifications(false));
  93. this.props.dispatch(unmountNotifications());
  94. }
  95. handleLoadGap = (maxId) => {
  96. this.props.dispatch(expandNotifications({ maxId }));
  97. };
  98. handleLoadOlder = debounce(() => {
  99. const last = this.props.notifications.last();
  100. this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
  101. }, 300, { leading: true });
  102. handleLoadPending = () => {
  103. this.props.dispatch(loadPending());
  104. };
  105. handleScrollToTop = debounce(() => {
  106. this.props.dispatch(scrollTopNotifications(true));
  107. }, 100);
  108. handleScroll = debounce(() => {
  109. this.props.dispatch(scrollTopNotifications(false));
  110. }, 100);
  111. handlePin = () => {
  112. const { columnId, dispatch } = this.props;
  113. if (columnId) {
  114. dispatch(removeColumn(columnId));
  115. } else {
  116. dispatch(addColumn('NOTIFICATIONS', {}));
  117. }
  118. }
  119. handleMove = (dir) => {
  120. const { columnId, dispatch } = this.props;
  121. dispatch(moveColumn(columnId, dir));
  122. }
  123. handleHeaderClick = () => {
  124. this.column.scrollTop();
  125. }
  126. setColumnRef = c => {
  127. this.column = c;
  128. }
  129. handleMoveUp = id => {
  130. const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
  131. this._selectChild(elementIndex, true);
  132. }
  133. handleMoveDown = id => {
  134. const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
  135. this._selectChild(elementIndex, false);
  136. }
  137. _selectChild (index, align_top) {
  138. const container = this.column.node;
  139. const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
  140. if (element) {
  141. if (align_top && container.scrollTop > element.offsetTop) {
  142. element.scrollIntoView(true);
  143. } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
  144. element.scrollIntoView(false);
  145. }
  146. element.focus();
  147. }
  148. }
  149. handleMarkAsRead = () => {
  150. this.props.dispatch(markNotificationsAsRead());
  151. this.props.dispatch(submitMarkers({ immediate: true }));
  152. };
  153. render () {
  154. const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
  155. const pinned = !!columnId;
  156. const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
  157. let scrollableContent = null;
  158. const filterBarContainer = showFilterBar
  159. ? (<FilterBarContainer />)
  160. : null;
  161. if (isLoading && this.scrollableContent) {
  162. scrollableContent = this.scrollableContent;
  163. } else if (notifications.size > 0 || hasMore) {
  164. scrollableContent = notifications.map((item, index) => item === null ? (
  165. <LoadGap
  166. key={'gap:' + notifications.getIn([index + 1, 'id'])}
  167. disabled={isLoading}
  168. maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
  169. onClick={this.handleLoadGap}
  170. />
  171. ) : (
  172. <NotificationContainer
  173. key={item.get('id')}
  174. notification={item}
  175. accountId={item.get('account')}
  176. onMoveUp={this.handleMoveUp}
  177. onMoveDown={this.handleMoveDown}
  178. unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
  179. />
  180. ));
  181. } else {
  182. scrollableContent = null;
  183. }
  184. this.scrollableContent = scrollableContent;
  185. const scrollContainer = (
  186. <ScrollableList
  187. scrollKey={`notifications-${columnId}`}
  188. trackScroll={!pinned}
  189. isLoading={isLoading}
  190. showLoading={isLoading && notifications.size === 0}
  191. hasMore={hasMore}
  192. numPending={numPending}
  193. prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
  194. alwaysPrepend
  195. emptyMessage={emptyMessage}
  196. onLoadMore={this.handleLoadOlder}
  197. onLoadPending={this.handleLoadPending}
  198. onScrollToTop={this.handleScrollToTop}
  199. onScroll={this.handleScroll}
  200. shouldUpdateScroll={shouldUpdateScroll}
  201. bindToDocument={!multiColumn}
  202. >
  203. {scrollableContent}
  204. </ScrollableList>
  205. );
  206. let extraButton = null;
  207. if (canMarkAsRead) {
  208. extraButton = (
  209. <button
  210. aria-label={intl.formatMessage(messages.markAsRead)}
  211. title={intl.formatMessage(messages.markAsRead)}
  212. onClick={this.handleMarkAsRead}
  213. className='column-header__button'
  214. >
  215. <Icon id='check' />
  216. </button>
  217. );
  218. }
  219. return (
  220. <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
  221. <ColumnHeader
  222. icon='bell'
  223. active={isUnread}
  224. title={intl.formatMessage(messages.title)}
  225. onPin={this.handlePin}
  226. onMove={this.handleMove}
  227. onClick={this.handleHeaderClick}
  228. pinned={pinned}
  229. multiColumn={multiColumn}
  230. extraButton={extraButton}
  231. >
  232. <ColumnSettingsContainer />
  233. </ColumnHeader>
  234. {filterBarContainer}
  235. {scrollContainer}
  236. </Column>
  237. );
  238. }
  239. }