From c8e8618f641b7c9d360c27ce5326e3d954fcae76 Mon Sep 17 00:00:00 2001 From: mgabdev <> Date: Thu, 11 Jul 2019 00:02:18 -0400 Subject: [PATCH] Added notification queueing functionality updated streaming functionality to load notifications into a queue (if currently on notitications page) and to display TimelineQueueButtonHeader with outstanding notification count. (if not on notifications page, it behaves as normal, adding/updating notification state). Max 40 are saved to queuedNotifications state and all are tallied into the totalQueuedNotificationsCount state. On click of TimelineQueueButtonHeader it dequeues the queuedNotifications and loads on page if <= max, otherwise it refreshes the page and shows latest 20 (default count) and clears/resets the state for queuedNotifications and totalQueuedNotificationsCount. --- .../gabsocial/actions/notifications.js | 88 ++++++++++++++----- app/javascript/gabsocial/actions/streaming.js | 4 +- .../gabsocial/features/notifications/index.js | 18 +++- .../gabsocial/reducers/notifications.js | 38 ++++++++ 4 files changed, 123 insertions(+), 25 deletions(-) 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: