diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index a9ddbf4397..9a1b5158fa 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -1,3 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; + import { apiClearNotifications, apiFetchNotifications, @@ -109,6 +111,8 @@ export const processNewNotificationForGroups = createAppAsyncThunk( }, ); +export const loadPending = createAction('notificationGroups/loadPending'); + export const setNotificationsFilter = createAppAsyncThunk( 'notifications/filter/set', ({ filterType }: { filterType: string }, { dispatch }) => { diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index 7ad9dbf1cc..4d79e261f8 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -13,13 +13,17 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg? import { fetchNotifications, fetchNotificationsGap, + loadPending, } from 'mastodon/actions/notification_groups'; import { compareId } from 'mastodon/compare_id'; import { Icon } from 'mastodon/components/icon'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { useIdentity } from 'mastodon/identity_context'; import type { NotificationGap } from 'mastodon/reducers/notifications_groups'; -import { selectUnreadNotificationsGroupsCount } from 'mastodon/selectors/notifications'; +import { + selectUnreadNotificationsGroupsCount, + selectPendingNotificationsGroupsCount, +} from 'mastodon/selectors/notifications'; import { selectNeedsNotificationPermission, selectSettingsNotificationsExcludedTypes, @@ -34,7 +38,6 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { submitMarkers } from '../../actions/markers'; import { scrollTopNotifications, - loadPending, // mountNotifications, // unmountNotifications, markNotificationsAsRead, @@ -80,16 +83,10 @@ const getNotifications = createSelector( }, ); -// const mapStateToProps = (state) => ({ -// numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()) -// .size, -// }); - export const Notifications: React.FC<{ columnId?: string; multiColumn?: boolean; - numPending: number; -}> = ({ columnId, multiColumn, numPending }) => { +}> = ({ columnId, multiColumn }) => { const intl = useIntl(); const notifications = useAppSelector(getNotifications); const dispatch = useAppDispatch(); @@ -100,6 +97,8 @@ export const Notifications: React.FC<{ selectSettingsNotificationsShowUnread(s) ? s.markers.notifications : '0', ); + const numPending = useAppSelector(selectPendingNotificationsGroupsCount); + const unreadNotificationsCount = useAppSelector( selectUnreadNotificationsGroupsCount, ); diff --git a/app/javascript/mastodon/reducers/notifications_groups.ts b/app/javascript/mastodon/reducers/notifications_groups.ts index ca7acdeefe..78bfda831e 100644 --- a/app/javascript/mastodon/reducers/notifications_groups.ts +++ b/app/javascript/mastodon/reducers/notifications_groups.ts @@ -12,12 +12,15 @@ import { fetchNotifications, fetchNotificationsGap, processNewNotificationForGroups, + loadPending, } from 'mastodon/actions/notification_groups'; import { disconnectTimeline, timelineDelete, } from 'mastodon/actions/timelines_typed'; +import type { ApiNotificationJSON } from 'mastodon/api_types/notifications'; import { compareId } from 'mastodon/compare_id'; +import { usePendingItems } from 'mastodon/initial_state'; import { NOTIFICATIONS_GROUP_MAX_AVATARS, createNotificationGroupFromJSON, @@ -33,20 +36,22 @@ export interface NotificationGap { interface NotificationGroupsState { groups: (NotificationGroup | NotificationGap)[]; + pendingGroups: (NotificationGroup | NotificationGap)[]; isLoading: boolean; } const initialState: NotificationGroupsState = { groups: [], + pendingGroups: [], // holds pending groups in slow mode isLoading: false, }; -function removeNotificationsForAccounts( - state: NotificationGroupsState, +function filterNotificationsForAccounts( + groups: NotificationGroupsState['groups'], accountIds: string[], onlyForType?: string, ) { - state.groups = state.groups + groups = groups .map((group) => { if ( group.type !== 'gap' && @@ -69,20 +74,50 @@ function removeNotificationsForAccounts( .filter( (group) => group.type === 'gap' || group.sampleAccountsIds.length > 0, ); - mergeGaps(state.groups); + mergeGaps(groups); + return groups; +} + +function filterNotificationsForStatus( + groups: NotificationGroupsState['groups'], + statusId: string, +) { + groups = groups.filter( + (group) => + group.type === 'gap' || + !('statusId' in group) || + group.statusId !== statusId, + ); + mergeGaps(groups); + return groups; +} + +function removeNotificationsForAccounts( + state: NotificationGroupsState, + accountIds: string[], + onlyForType?: string, +) { + state.groups = filterNotificationsForAccounts( + state.groups, + accountIds, + onlyForType, + ); + state.pendingGroups = filterNotificationsForAccounts( + state.pendingGroups, + accountIds, + onlyForType, + ); } function removeNotificationsForStatus( state: NotificationGroupsState, statusId: string, ) { - state.groups = state.groups.filter( - (group) => - group.type === 'gap' || - !('statusId' in group) || - group.statusId !== statusId, + state.groups = filterNotificationsForStatus(state.groups, statusId); + state.pendingGroups = filterNotificationsForStatus( + state.pendingGroups, + statusId, ); - mergeGaps(state.groups); } function isNotificationGroup( @@ -139,6 +174,52 @@ function mergeGapsAround( } } +function processNewNotification( + groups: NotificationGroupsState['groups'], + notification: ApiNotificationJSON, +) { + const existingGroupIndex = groups.findIndex( + (group) => + group.type !== 'gap' && group.group_key === notification.group_key, + ); + + // In any case, we are going to add a group at the top + // If there is currently a gap at the top, now is the time to update it + if (groups.length > 0 && groups[0]?.type === 'gap') { + groups[0].maxId = notification.id; + } + + if (existingGroupIndex > -1) { + const existingGroup = groups[existingGroupIndex]; + + if ( + existingGroup && + existingGroup.type !== 'gap' && + !existingGroup.sampleAccountsIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post + ) { + // Update the existing group + if ( + existingGroup.sampleAccountsIds.unshift(notification.account.id) > + NOTIFICATIONS_GROUP_MAX_AVATARS + ) + existingGroup.sampleAccountsIds.pop(); + + existingGroup.most_recent_notification_id = notification.id; + existingGroup.page_max_id = notification.id; + existingGroup.latest_page_notification_at = notification.created_at; + existingGroup.notifications_count += 1; + + groups.splice(existingGroupIndex, 1); + mergeGapsAround(groups, existingGroupIndex); + + groups.unshift(existingGroup); + } + } else { + // Create a new group + groups.unshift(createNotificationGroupFromNotificationJSON(notification)); + } +} + export const notificationsGroupsReducer = createReducer(initialState, (builder) => { builder @@ -230,48 +311,10 @@ export const notificationsGroupsReducer = }) .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { const notification = action.payload; - const existingGroupIndex = state.groups.findIndex( - (group) => - group.type !== 'gap' && group.group_key === notification.group_key, + processNewNotification( + usePendingItems ? state.pendingGroups : state.groups, + notification, ); - - // In any case, we are going to add a group at the top - // If there is currently a gap at the top, now is the time to update it - if (state.groups.length > 0 && state.groups[0]?.type === 'gap') { - state.groups[0].maxId = notification.id; - } - - if (existingGroupIndex > -1) { - const existingGroup = state.groups[existingGroupIndex]; - - if ( - existingGroup && - existingGroup.type !== 'gap' && - !existingGroup.sampleAccountsIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post - ) { - // Update the existing group - if ( - existingGroup.sampleAccountsIds.unshift(notification.account.id) > - NOTIFICATIONS_GROUP_MAX_AVATARS - ) - existingGroup.sampleAccountsIds.pop(); - - existingGroup.most_recent_notification_id = notification.id; - existingGroup.page_max_id = notification.id; - existingGroup.latest_page_notification_at = notification.created_at; - existingGroup.notifications_count += 1; - - state.groups.splice(existingGroupIndex, 1); - mergeGapsAround(state.groups, existingGroupIndex); - - state.groups.unshift(existingGroup); - } - } else { - // Create a new group - state.groups.unshift( - createNotificationGroupFromNotificationJSON(notification), - ); - } }) .addCase(disconnectTimeline, (state, action) => { if (action.payload.timeline === 'home') { @@ -288,6 +331,7 @@ export const notificationsGroupsReducer = }) .addCase(clearNotifications.pending, (state) => { state.groups = []; + state.pendingGroups = []; }) .addCase(blockAccountSuccess, (state, action) => { removeNotificationsForAccounts(state, [action.payload.relationship.id]); @@ -304,6 +348,32 @@ export const notificationsGroupsReducer = action.payload.accounts.map((account) => account.id), ); }) + .addCase(loadPending, (state) => { + // First, remove any existing group and merge data + state.pendingGroups.forEach((group) => { + if (group.type !== 'gap') { + const existingGroupIndex = state.groups.findIndex( + (groupOrGap) => + isNotificationGroup(groupOrGap) && + groupOrGap.group_key === group.group_key, + ); + if (existingGroupIndex > -1) { + const existingGroup = state.groups[existingGroupIndex]; + if (existingGroup && existingGroup.type !== 'gap') { + group.notifications_count += existingGroup.notifications_count; + group.sampleAccountsIds = group.sampleAccountsIds + .concat(existingGroup.sampleAccountsIds) + .slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS); + state.groups.splice(existingGroupIndex, 1); + } + } + } + }); + + // Then build the consolidated list and clear pending groups + state.groups = state.pendingGroups.concat(state.groups); + state.pendingGroups = []; + }) .addMatcher( isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess), (state, action) => { diff --git a/app/javascript/mastodon/selectors/notifications.ts b/app/javascript/mastodon/selectors/notifications.ts index ced25fcafb..3756ad7192 100644 --- a/app/javascript/mastodon/selectors/notifications.ts +++ b/app/javascript/mastodon/selectors/notifications.ts @@ -6,14 +6,29 @@ import type { RootState } from 'mastodon/store'; export const selectUnreadNotificationsGroupsCount = createSelector( [ (s: RootState) => s.markers.notifications, + (s: RootState) => s.notificationsGroups.pendingGroups, (s: RootState) => s.notificationsGroups.groups, ], - (notificationMarker, groups) => { - return groups.filter( - (group) => - group.type !== 'gap' && - group.page_max_id && - compareId(group.page_max_id, notificationMarker) > 0, - ).length; + (notificationMarker, pendingGroups, groups) => { + return ( + groups.filter( + (group) => + group.type !== 'gap' && + group.page_max_id && + compareId(group.page_max_id, notificationMarker) > 0, + ).length + + pendingGroups.filter( + (group) => + group.type !== 'gap' && + group.page_max_id && + compareId(group.page_max_id, notificationMarker) > 0, + ).length + ); }, ); + +export const selectPendingNotificationsGroupsCount = createSelector( + [(s: RootState) => s.notificationsGroups.pendingGroups], + (pendingGroups) => + pendingGroups.filter((group) => group.type !== 'gap').length, +);