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';
9 scrollTopNotifications
,
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';
30 const messages
= defineMessages({
31 title: { id: 'column.notifications', defaultMessage: 'Notifications' },
32 markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
35 const getExcludedTypes
= createSelector([
36 state
=> state
.getIn(['settings', 'notifications', 'shows']),
38 return ImmutableList(shows
.filter(item
=> !item
).keys());
41 const getNotifications
= createSelector([
42 state
=> state
.getIn(['settings', 'notifications', 'quickFilter', 'show']),
43 state
=> state
.getIn(['settings', 'notifications', 'quickFilter', 'active']),
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')));
53 return notifications
.filter(item
=> item
=== null || allowedType
=== item
.get('type'));
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']),
68 export default @connect(mapStateToProps
)
70 class Notifications
extends React
.PureComponent
{
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
,
89 static defaultProps
= {
93 componentWillMount() {
94 this.props
.dispatch(mountNotifications());
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());
105 handleLoadGap
= (maxId
) => {
106 this.props
.dispatch(expandNotifications({ maxId
}));
109 handleLoadOlder
= debounce(() => {
110 const last
= this.props
.notifications
.last();
111 this.props
.dispatch(expandNotifications({ maxId: last
&& last
.get('id') }));
112 }, 300, { leading: true });
114 handleLoadPending
= () => {
115 this.props
.dispatch(loadPending());
118 handleScrollToTop
= debounce(() => {
119 this.props
.dispatch(scrollTopNotifications(true));
122 handleScroll
= debounce(() => {
123 this.props
.dispatch(scrollTopNotifications(false));
127 const { columnId
, dispatch
} = this.props
;
130 dispatch(removeColumn(columnId
));
132 dispatch(addColumn('NOTIFICATIONS', {}));
136 handleMove
= (dir
) => {
137 const { columnId
, dispatch
} = this.props
;
138 dispatch(moveColumn(columnId
, dir
));
141 handleHeaderClick
= () => {
142 this.column
.scrollTop();
145 setColumnRef
= c
=> {
149 handleMoveUp
= id
=> {
150 const elementIndex
= this.props
.notifications
.findIndex(item
=> item
!== null && item
.get('id') === id
) - 1;
151 this._selectChild(elementIndex
, true);
154 handleMoveDown
= id
=> {
155 const elementIndex
= this.props
.notifications
.findIndex(item
=> item
!== null && item
.get('id') === id
) + 1;
156 this._selectChild(elementIndex
, false);
159 _selectChild (index
, align_top
) {
160 const container
= this.column
.node
;
161 const element
= container
.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
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);
173 handleMarkAsRead
= () => {
174 this.props
.dispatch(markNotificationsAsRead());
175 this.props
.dispatch(submitMarkers({ immediate: true }));
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." />;
183 let scrollableContent
= null;
185 const filterBarContainer
= showFilterBar
186 ? (<FilterBarContainer
/>)
189 if (isLoading
&& this.scrollableContent
) {
190 scrollableContent
= this.scrollableContent
;
191 } else if (notifications
.size
> 0 || hasMore
) {
192 scrollableContent
= notifications
.map((item
, index
) => item
=== null ? (
194 key
={'gap:' + notifications
.getIn([index
+ 1, 'id'])}
196 maxId
={index
> 0 ? notifications
.getIn([index
- 1, 'id']) : null}
197 onClick
={this.handleLoadGap
}
200 <NotificationContainer
203 accountId
={item
.get('account')}
204 onMoveUp
={this.handleMoveUp
}
205 onMoveDown
={this.handleMoveDown
}
206 unread
={lastReadId
!== '0' && compareId(item
.get('id'), lastReadId
) > 0}
210 scrollableContent
= null;
213 this.scrollableContent
= scrollableContent
;
215 const scrollContainer
= (
217 scrollKey
={`notifications-${columnId}`}
218 trackScroll
={!pinned
}
219 isLoading
={isLoading
}
220 showLoading
={isLoading
&& notifications
.size
=== 0}
222 numPending
={numPending
}
223 prepend
={needsNotificationPermission
&& <NotificationsPermissionBanner
/>}
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
}
237 let extraButton
= null;
242 aria
-label
={intl
.formatMessage(messages
.markAsRead
)}
243 title
={intl
.formatMessage(messages
.markAsRead
)}
244 onClick
={this.handleMarkAsRead
}
245 className
='column-header__button'
253 <Column bindToDocument
={!multiColumn
} ref
={this.setColumnRef
} label
={intl
.formatMessage(messages
.title
)}>
257 title
={intl
.formatMessage(messages
.title
)}
258 onPin
={this.handlePin
}
259 onMove
={this.handleMove
}
260 onClick
={this.handleHeaderClick
}
262 multiColumn
={multiColumn
}
263 extraButton
={extraButton
}
265 <ColumnSettingsContainer
/>