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