diff --git a/app/javascript/gabsocial/actions/notifications.js b/app/javascript/gabsocial/actions/notifications.js index 8266ac55..21b1cf3f 100644 --- a/app/javascript/gabsocial/actions/notifications.js +++ b/app/javascript/gabsocial/actions/notifications.js @@ -16,6 +16,8 @@ import { me } from 'gabsocial/initial_state'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; +export const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE'; +export const NOTIFICATIONS_DEQUEUE = 'NOTIFICATIONS_DEQUEUE'; export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST'; export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS'; @@ -26,6 +28,8 @@ export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const MAX_QUEUED_NOTIFICATIONS = 40; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, @@ -42,18 +46,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => { export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); - const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); - const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); - const filters = getFilters(getState(), { contextType: 'notifications' }); - - let filtered = false; - - if (notification.type === 'mention') { - const regex = regexFromFilters(filters); - const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); - - filtered = regex && regex.test(searchIndex); - } if (showInColumn) { dispatch(importFetchedAccount(notification.account)); @@ -65,21 +57,33 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch({ type: NOTIFICATIONS_UPDATE, notification, - meta: (playSound && !filtered) ? { sound: 'ribbit' } : undefined, }); fetchRelatedRelationships(dispatch, [notification]); - } else if (playSound && !filtered) { - dispatch({ - type: NOTIFICATIONS_UPDATE_NOOP, - meta: { sound: 'ribbit' }, - }); + } + }; +}; + +export function updateNotificationsQueue(notification, intlMessages, intlLocale, curPath) { + return (dispatch, getState) => { + const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true); + const filters = getFilters(getState(), { contextType: 'notifications' }); + const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true); + + let filtered = false; + + const isOnNotificationsPage = curPath === '/notifications'; + + if (notification.type === 'mention') { + const regex = regexFromFilters(filters); + const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + filtered = regex && regex.test(searchIndex); } // Desktop notifications if (typeof window.Notification !== 'undefined' && showAlert && !filtered) { const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username }); - const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); + const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); @@ -88,7 +92,49 @@ export function updateNotifications(notification, intlMessages, intlLocale) { notify.close(); }); } - }; + + if (playSound && !filtered) { + dispatch({ + type: NOTIFICATIONS_UPDATE_NOOP, + meta: { sound: 'ribbit' }, + }); + } + + if (isOnNotificationsPage) { + dispatch({ + type: NOTIFICATIONS_UPDATE_QUEUE, + notification, + intlMessages, + intlLocale, + }); + } + else { + dispatch(updateNotifications(notification, intlMessages, intlLocale)); + } + } +}; + +export function dequeueNotifications() { + return (dispatch, getState) => { + const queuedNotifications = getState().getIn(['notifications', 'queuedNotifications'], ImmutableList()); + const totalQueuedNotificationsCount = getState().getIn(['notifications', 'totalQueuedNotificationsCount'], 0); + + if (totalQueuedNotificationsCount == 0) { + return; + } + else if (totalQueuedNotificationsCount > 0 && totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { + queuedNotifications.forEach(block => { + dispatch(updateNotifications(block.notification, block.intlMessages, block.intlLocale)); + }); + } + else { + dispatch(expandNotifications()); + } + + dispatch({ + type: NOTIFICATIONS_DEQUEUE, + }); + } }; const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); @@ -169,7 +215,7 @@ export function expandNotificationsFail(error, isLoadingMore) { export function clearNotifications() { return (dispatch, getState) => { if (!me) return; - + dispatch({ type: NOTIFICATIONS_CLEAR, }); diff --git a/app/javascript/gabsocial/actions/streaming.js b/app/javascript/gabsocial/actions/streaming.js index 3876a4f5..5e2d355a 100644 --- a/app/javascript/gabsocial/actions/streaming.js +++ b/app/javascript/gabsocial/actions/streaming.js @@ -6,7 +6,7 @@ import { connectTimeline, disconnectTimeline, } from './timelines'; -import { updateNotifications, expandNotifications } from './notifications'; +import { updateNotificationsQueue, expandNotifications } from './notifications'; import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { getLocale } from '../locales'; @@ -36,7 +36,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, dispatch(deleteFromTimelines(data.payload)); break; case 'notification': - dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); break; case 'conversation': dispatch(updateConversations(JSON.parse(data.payload))); diff --git a/app/javascript/gabsocial/features/notifications/index.js b/app/javascript/gabsocial/features/notifications/index.js index 9c075c08..9f4a7c35 100644 --- a/app/javascript/gabsocial/features/notifications/index.js +++ b/app/javascript/gabsocial/features/notifications/index.js @@ -4,7 +4,11 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; +import { + expandNotifications, + scrollTopNotifications, + dequeueNotifications, +} from '../../actions/notifications'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; @@ -14,6 +18,7 @@ import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from '../../components/scrollable_list'; import LoadGap from '../../components/load_gap'; +import TimelineQueueButtonHeader from '../../components/timeline_queue_button_header'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -40,6 +45,7 @@ const mapStateToProps = state => ({ isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, hasMore: state.getIn(['notifications', 'hasMore']), + totalQueuedNotificationsCount: state.getIn(['notifications', 'totalQueuedNotificationsCount'], 0), }); export default @connect(mapStateToProps) @@ -54,6 +60,8 @@ class Notifications extends React.PureComponent { isLoading: PropTypes.bool, isUnread: PropTypes.bool, hasMore: PropTypes.bool, + dequeueNotifications: PropTypes.func, + totalQueuedNotificationsCount: PropTypes.number, }; componentWillUnmount () { @@ -61,6 +69,7 @@ class Notifications extends React.PureComponent { this.handleScrollToTop.cancel(); this.handleScroll.cancel(); this.props.dispatch(scrollTopNotifications(false)); + this.handleDequeueNotifications(); } componentDidMount() { @@ -112,8 +121,12 @@ class Notifications extends React.PureComponent { } } + handleDequeueNotifications = () => { + this.props.dispatch(dequeueNotifications()); + }; + render () { - const { intl, notifications, isLoading, isUnread, columnId, hasMore, showFilterBar } = this.props; + const { intl, notifications, isLoading, isUnread, hasMore, showFilterBar, totalQueuedNotificationsCount } = this.props; const emptyMessage = ; let scrollableContent = null; @@ -168,6 +181,7 @@ class Notifications extends React.PureComponent { {filterBarContainer} + {scrollContainer} ); diff --git a/app/javascript/gabsocial/reducers/notifications.js b/app/javascript/gabsocial/reducers/notifications.js index 4d9604de..7531165f 100644 --- a/app/javascript/gabsocial/reducers/notifications.js +++ b/app/javascript/gabsocial/reducers/notifications.js @@ -6,6 +6,9 @@ import { NOTIFICATIONS_FILTER_SET, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATIONS_UPDATE_QUEUE, + NOTIFICATIONS_DEQUEUE, + MAX_QUEUED_NOTIFICATIONS, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -21,6 +24,8 @@ const initialState = ImmutableMap({ top: false, unread: 0, isLoading: false, + queuedNotifications: ImmutableList(), //max = MAX_QUEUED_NOTIFICATIONS + totalQueuedNotificationsCount: 0, //used for queuedItems overflow for MAX_QUEUED_NOTIFICATIONS+ }); const notificationToMap = notification => ImmutableMap({ @@ -93,6 +98,32 @@ const deleteByStatus = (state, statusId) => { return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); }; +const updateNotificationsQueue = (state, notification, intlMessages, intlLocale) => { + const queuedNotifications = state.getIn(['queuedNotifications'], ImmutableList()); + const listedNotifications = state.getIn(['items'], ImmutableList()); + const totalQueuedNotificationsCount = state.getIn(['totalQueuedNotificationsCount'], 0); + + let alreadyExists = queuedNotifications.find(existingQueuedNotification => existingQueuedNotification.id === notification.id); + if (!alreadyExists) alreadyExists = listedNotifications.find(existingListedNotification => existingListedNotification.get('id') === notification.id); + + if (alreadyExists) { + return state; + } + + let newQueuedNotifications = queuedNotifications; + + return state.withMutations(mutable => { + if (totalQueuedNotificationsCount <= MAX_QUEUED_NOTIFICATIONS) { + mutable.set('queuedNotifications', newQueuedNotifications.push({ + notification, + intlMessages, + intlLocale, + })); + } + mutable.set('totalQueuedNotificationsCount', totalQueuedNotificationsCount + 1); + }); +}; + export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_EXPAND_REQUEST: @@ -105,6 +136,13 @@ export default function notifications(state = initialState, action) { return updateTop(state, action.top); case NOTIFICATIONS_UPDATE: return normalizeNotification(state, action.notification); + case NOTIFICATIONS_UPDATE_QUEUE: + return updateNotificationsQueue(state, action.notification, action.intlMessages, action.intlLocale); + case NOTIFICATIONS_DEQUEUE: + return state.withMutations(mutable => { + mutable.set('queuedNotifications', ImmutableList()) + mutable.set('totalQueuedNotificationsCount', 0) + }); case NOTIFICATIONS_EXPAND_SUCCESS: return expandNormalizedNotifications(state, action.notifications, action.next); case ACCOUNT_BLOCK_SUCCESS: