import { createReducer, isAnyOf } from '@reduxjs/toolkit'; import { authorizeFollowRequestSuccess, blockAccountSuccess, muteAccountSuccess, rejectFollowRequestSuccess, } from 'mastodon/actions/accounts_typed'; import { focusApp, unfocusApp } from 'mastodon/actions/app'; import { blockDomainSuccess } from 'mastodon/actions/domain_blocks_typed'; import { fetchMarkers } from 'mastodon/actions/markers'; import { clearNotifications, fetchNotifications, fetchNotificationsGap, processNewNotificationForGroups, loadPending, updateScrollPosition, markNotificationsAsRead, mountNotifications, unmountNotifications, refreshStaleNotificationGroups, pollRecentNotifications, shouldGroupNotificationType, } from 'mastodon/actions/notification_groups'; import { disconnectTimeline, timelineDelete, } from 'mastodon/actions/timelines_typed'; import type { ApiNotificationJSON, ApiNotificationGroupJSON, } from 'mastodon/api_types/notifications'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems } from 'mastodon/initial_state'; import { NOTIFICATIONS_GROUP_MAX_AVATARS, createNotificationGroupFromJSON, createNotificationGroupFromNotificationJSON, } from 'mastodon/models/notification_group'; import type { NotificationGroup } from 'mastodon/models/notification_group'; const NOTIFICATIONS_TRIM_LIMIT = 50; export interface NotificationGap { type: 'gap'; maxId?: string; sinceId?: string; } interface NotificationGroupsState { groups: (NotificationGroup | NotificationGap)[]; pendingGroups: (NotificationGroup | NotificationGap)[]; scrolledToTop: boolean; isLoading: boolean; lastReadId: string; readMarkerId: string; mounted: number; isTabVisible: boolean; mergedNotifications: 'ok' | 'pending' | 'needs-reload'; } const initialState: NotificationGroupsState = { groups: [], pendingGroups: [], // holds pending groups in slow mode scrolledToTop: false, isLoading: false, // this is used to track whether we need to refresh notifications after accepting requests mergedNotifications: 'ok', // The following properties are used to track unread notifications lastReadId: '0', // used internally for unread notifications readMarkerId: '0', // user-facing and updated when focus changes mounted: 0, // number of mounted notification list components, usually 0 or 1 isTabVisible: true, }; function filterNotificationsForAccounts( groups: NotificationGroupsState['groups'], accountIds: string[], onlyForType?: string, ) { groups = groups .map((group) => { if ( group.type !== 'gap' && (!onlyForType || group.type === onlyForType) ) { const previousLength = group.sampleAccountIds.length; group.sampleAccountIds = group.sampleAccountIds.filter( (id) => !accountIds.includes(id), ); const newLength = group.sampleAccountIds.length; const removed = previousLength - newLength; group.notifications_count -= removed; } return group; }) .filter( (group) => group.type === 'gap' || group.sampleAccountIds.length > 0, ); 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 = filterNotificationsForStatus(state.groups, statusId); state.pendingGroups = filterNotificationsForStatus( state.pendingGroups, statusId, ); } function isNotificationGroup( groupOrGap: NotificationGroup | NotificationGap, ): groupOrGap is NotificationGroup { return groupOrGap.type !== 'gap'; } // Merge adjacent gaps in `groups` in-place function mergeGaps(groups: NotificationGroupsState['groups']) { for (let i = 0; i < groups.length; i++) { const firstGroupOrGap = groups[i]; if (firstGroupOrGap?.type === 'gap') { let lastGap = firstGroupOrGap; let j = i + 1; for (; j < groups.length; j++) { const groupOrGap = groups[j]; if (groupOrGap?.type === 'gap') lastGap = groupOrGap; else break; } if (j - i > 1) { groups.splice(i, j - i, { type: 'gap', maxId: firstGroupOrGap.maxId, sinceId: lastGap.sinceId, }); } } } } // Checks if `groups[index-1]` and `groups[index]` are gaps, and merge them in-place if they are function mergeGapsAround( groups: NotificationGroupsState['groups'], index: number, ) { if (index > 0) { const potentialFirstGap = groups[index - 1]; const potentialSecondGap = groups[index]; if ( potentialFirstGap?.type === 'gap' && potentialSecondGap?.type === 'gap' ) { groups.splice(index - 1, 2, { type: 'gap', maxId: potentialFirstGap.maxId, sinceId: potentialSecondGap.sinceId, }); } } } function processNewNotification( groups: NotificationGroupsState['groups'], notification: ApiNotificationJSON, ) { if (shouldGroupNotificationType(notification.type)) { 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.sampleAccountIds.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.sampleAccountIds.unshift(notification.account.id) > NOTIFICATIONS_GROUP_MAX_AVATARS ) existingGroup.sampleAccountIds.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); return; } } } // We have not found an existing group, create a new one groups.unshift(createNotificationGroupFromNotificationJSON(notification)); } function trimNotifications(state: NotificationGroupsState) { if (state.scrolledToTop && state.groups.length > NOTIFICATIONS_TRIM_LIMIT) { state.groups.splice(NOTIFICATIONS_TRIM_LIMIT); ensureTrailingGap(state.groups); } } function shouldMarkNewNotificationsAsRead( { isTabVisible, scrolledToTop, mounted, lastReadId, groups, }: NotificationGroupsState, ignoreScroll = false, ) { const isMounted = mounted > 0; const oldestGroup = groups.findLast(isNotificationGroup); const hasMore = groups.at(-1)?.type === 'gap'; const oldestGroupReached = !hasMore || lastReadId === '0' || (oldestGroup?.page_min_id && compareId(oldestGroup.page_min_id, lastReadId) <= 0); return ( isTabVisible && (ignoreScroll || scrolledToTop) && isMounted && oldestGroupReached ); } function updateLastReadId( state: NotificationGroupsState, group: NotificationGroup | undefined = undefined, ) { if (shouldMarkNewNotificationsAsRead(state)) { group = group ?? state.groups.find(isNotificationGroup); if ( group?.page_max_id && compareId(state.lastReadId, group.page_max_id) < 0 ) state.lastReadId = group.page_max_id; } } function commitLastReadId(state: NotificationGroupsState) { if (shouldMarkNewNotificationsAsRead(state)) { state.readMarkerId = state.lastReadId; } } function fillNotificationsGap( groups: NotificationGroupsState['groups'], gap: NotificationGap, notifications: ApiNotificationGroupJSON[], ): NotificationGroupsState['groups'] { // find the gap in the existing notifications const gapIndex = groups.findIndex( (groupOrGap) => groupOrGap.type === 'gap' && groupOrGap.sinceId === gap.sinceId && groupOrGap.maxId === gap.maxId, ); if (gapIndex < 0) // We do not know where to insert, let's return return groups; // Filling a disconnection gap means we're getting historical data // about groups we may know or may not know about. // The notifications timeline is split in two by the gap, with // group information newer than the gap, and group information older // than the gap. // Filling a gap should not touch anything before the gap, so any // information on groups already appearing before the gap should be // discarded, while any information on groups appearing after the gap // can be updated and re-ordered. const oldestPageNotification = notifications.at(-1)?.page_min_id; // replace the gap with the notifications + a new gap const newerGroupKeys = groups .slice(0, gapIndex) .filter(isNotificationGroup) .map((group) => group.group_key); const toInsert: NotificationGroupsState['groups'] = notifications .map((json) => createNotificationGroupFromJSON(json)) .filter((notification) => !newerGroupKeys.includes(notification.group_key)); const apiGroupKeys = (toInsert as NotificationGroup[]).map( (group) => group.group_key, ); const sinceId = gap.sinceId; if ( notifications.length > 0 && !( oldestPageNotification && sinceId && compareId(oldestPageNotification, sinceId) <= 0 ) ) { // If we get an empty page, it means we reached the bottom, so we do not need to insert a new gap // Similarly, if we've fetched more than the gap's, this means we have completely filled it toInsert.push({ type: 'gap', maxId: notifications.at(-1)?.page_max_id, sinceId, } as NotificationGap); } // Remove older groups covered by the API groups = groups.filter( (groupOrGap) => groupOrGap.type !== 'gap' && !apiGroupKeys.includes(groupOrGap.group_key), ); // Replace the gap with API results (+ the new gap if needed) groups.splice(gapIndex, 1, ...toInsert); // Finally, merge any adjacent gaps that could have been created by filtering // groups earlier mergeGaps(groups); return groups; } // Ensure the groups list starts with a gap, mutating it to prepend one if needed function ensureLeadingGap( groups: NotificationGroupsState['groups'], ): NotificationGap { if (groups[0]?.type === 'gap') { // We're expecting new notifications, so discard the maxId if there is one groups[0].maxId = undefined; return groups[0]; } else { const gap: NotificationGap = { type: 'gap', sinceId: groups[0]?.page_min_id, }; groups.unshift(gap); return gap; } } // Ensure the groups list ends with a gap suitable for loading more, mutating it to append one if needed function ensureTrailingGap( groups: NotificationGroupsState['groups'], ): NotificationGap { const groupOrGap = groups.at(-1); if (groupOrGap?.type === 'gap') { // We're expecting older notifications, so discard sinceId if it's set groupOrGap.sinceId = undefined; return groupOrGap; } else { const gap: NotificationGap = { type: 'gap', maxId: groupOrGap?.page_min_id, }; groups.push(gap); return gap; } } export const notificationGroupsReducer = createReducer( initialState, (builder) => { builder .addCase(fetchNotifications.fulfilled, (state, action) => { state.groups = action.payload.map((json) => json.type === 'gap' ? json : createNotificationGroupFromJSON(json), ); state.isLoading = false; state.mergedNotifications = 'ok'; updateLastReadId(state); }) .addCase(fetchNotificationsGap.fulfilled, (state, action) => { state.groups = fillNotificationsGap( state.groups, action.meta.arg.gap, action.payload.notifications, ); state.isLoading = false; updateLastReadId(state); }) .addCase(pollRecentNotifications.fulfilled, (state, action) => { if (usePendingItems) { const gap = ensureLeadingGap(state.pendingGroups); state.pendingGroups = fillNotificationsGap( state.pendingGroups, gap, action.payload.notifications, ); } else { const gap = ensureLeadingGap(state.groups); state.groups = fillNotificationsGap( state.groups, gap, action.payload.notifications, ); } state.isLoading = false; updateLastReadId(state); trimNotifications(state); }) .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { const notification = action.payload; if (notification) { processNewNotification( usePendingItems ? state.pendingGroups : state.groups, notification, ); updateLastReadId(state); trimNotifications(state); } }) .addCase(disconnectTimeline, (state, action) => { if (action.payload.timeline === 'home') { const groups = usePendingItems ? state.pendingGroups : state.groups; if (groups.length > 0 && groups[0]?.type !== 'gap') { groups.unshift({ type: 'gap', sinceId: groups[0]?.page_min_id, }); } } }) .addCase(timelineDelete, (state, action) => { removeNotificationsForStatus(state, action.payload.statusId); }) .addCase(clearNotifications.pending, (state) => { state.groups = []; state.pendingGroups = []; }) .addCase(blockAccountSuccess, (state, action) => { removeNotificationsForAccounts(state, [action.payload.relationship.id]); }) .addCase(muteAccountSuccess, (state, action) => { if (action.payload.relationship.muting_notifications) removeNotificationsForAccounts(state, [ action.payload.relationship.id, ]); }) .addCase(blockDomainSuccess, (state, action) => { removeNotificationsForAccounts( state, 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.sampleAccountIds = group.sampleAccountIds .concat(existingGroup.sampleAccountIds) .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 = []; mergeGaps(state.groups); trimNotifications(state); }) .addCase(updateScrollPosition.fulfilled, (state, action) => { state.scrolledToTop = action.payload.top; updateLastReadId(state); trimNotifications(state); }) .addCase(markNotificationsAsRead, (state) => { const mostRecentGroup = state.groups.find(isNotificationGroup); if ( mostRecentGroup?.page_max_id && compareId(state.lastReadId, mostRecentGroup.page_max_id) < 0 ) state.lastReadId = mostRecentGroup.page_max_id; commitLastReadId(state); }) .addCase(fetchMarkers.fulfilled, (state, action) => { if ( action.payload.markers.notifications && compareId( state.lastReadId, action.payload.markers.notifications.last_read_id, ) < 0 ) { state.lastReadId = action.payload.markers.notifications.last_read_id; state.readMarkerId = action.payload.markers.notifications.last_read_id; } }) .addCase(mountNotifications.fulfilled, (state) => { state.mounted += 1; commitLastReadId(state); updateLastReadId(state); }) .addCase(unmountNotifications, (state) => { state.mounted -= 1; }) .addCase(focusApp, (state) => { state.isTabVisible = true; commitLastReadId(state); updateLastReadId(state); }) .addCase(unfocusApp, (state) => { state.isTabVisible = false; }) .addCase(refreshStaleNotificationGroups.fulfilled, (state, action) => { if (action.payload.deferredRefresh) state.mergedNotifications = 'needs-reload'; }) .addMatcher( isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess), (state, action) => { removeNotificationsForAccounts( state, [action.payload.id], 'follow_request', ); }, ) .addMatcher( isAnyOf( fetchNotifications.pending, fetchNotificationsGap.pending, pollRecentNotifications.pending, ), (state) => { state.isLoading = true; }, ) .addMatcher( isAnyOf( fetchNotifications.rejected, fetchNotificationsGap.rejected, pollRecentNotifications.rejected, ), (state) => { state.isLoading = false; }, ); }, );