From d67e11733e4159c736f4370ee7ea86240a297bb7 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 21 Aug 2024 16:41:31 +0200 Subject: [PATCH] Add automatic notification polling for grouped notifications (#31513) --- .../mastodon/actions/notification_groups.ts | 23 ++ app/javascript/mastodon/actions/streaming.js | 25 +- app/javascript/mastodon/api/notifications.ts | 1 + .../mastodon/reducers/notification_groups.ts | 222 +++++++++++------- 4 files changed, 186 insertions(+), 85 deletions(-) diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 6b699706e2..51f83f1d24 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -11,6 +11,7 @@ import type { } from 'mastodon/api_types/notifications'; import { allNotificationTypes } from 'mastodon/api_types/notifications'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; +import { usePendingItems } from 'mastodon/initial_state'; import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import { selectSettingsNotificationsExcludedTypes, @@ -103,6 +104,28 @@ export const fetchNotificationsGap = createDataLoadingThunk( }, ); +export const pollRecentNotifications = createDataLoadingThunk( + 'notificationGroups/pollRecentNotifications', + async (_params, { getState }) => { + return apiFetchNotifications({ + max_id: undefined, + // In slow mode, we don't want to include notifications that duplicate the already-displayed ones + since_id: usePendingItems + ? getState().notificationGroups.groups.find( + (group) => group.type !== 'gap', + )?.page_max_id + : undefined, + }); + }, + ({ notifications, accounts, statuses }, { dispatch }) => { + dispatch(importFetchedAccounts(accounts)); + dispatch(importFetchedStatuses(statuses)); + dispatchAssociatedRecords(dispatch, notifications); + + return { notifications }; + }, +); + export const processNewNotificationForGroups = createAppAsyncThunk( 'notificationGroups/processNew', (notification: ApiNotificationJSON, { dispatch, getState }) => { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index e082aea43d..04f5e6b88c 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,7 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; -import { processNewNotificationForGroups, refreshStaleNotificationGroups } from './notification_groups'; +import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { updateNotifications, expandNotifications } from './notifications'; import { updateStatus } from './statuses'; import { @@ -37,7 +37,7 @@ const randomUpTo = max => * @param {string} channelName * @param {Object.} params * @param {Object} options - * @param {function(Function): Promise} [options.fallback] + * @param {function(Function, Function): Promise} [options.fallback] * @param {function(): void} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} @@ -52,11 +52,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti let pollingId; /** - * @param {function(Function): Promise} fallback + * @param {function(Function, Function): Promise} fallback */ const useFallback = async fallback => { - await fallback(dispatch); + await fallback(dispatch, getState); // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); }; @@ -139,10 +139,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti /** * @param {Function} dispatch + * @param {Function} getState */ -async function refreshHomeTimelineAndNotification(dispatch) { +async function refreshHomeTimelineAndNotification(dispatch, getState) { await dispatch(expandHomeTimeline({ maxId: undefined })); - await dispatch(expandNotifications({})); + + // TODO: remove this once the groups feature replaces the previous one + if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + // TODO: polling for merged notifications + try { + await dispatch(pollRecentGroupNotifications()); + } catch (error) { + // TODO + } + } else { + await dispatch(expandNotifications({})); + } + await dispatch(fetchAnnouncements()); } diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts index ed187da5ec..cb07e4114c 100644 --- a/app/javascript/mastodon/api/notifications.ts +++ b/app/javascript/mastodon/api/notifications.ts @@ -4,6 +4,7 @@ import type { ApiNotificationGroupsResultJSON } from 'mastodon/api_types/notific export const apiFetchNotifications = async (params?: { exclude_types?: string[]; max_id?: string; + since_id?: string; }) => { const response = await api().request({ method: 'GET', diff --git a/app/javascript/mastodon/reducers/notification_groups.ts b/app/javascript/mastodon/reducers/notification_groups.ts index 0348f080ff..b3535d7b67 100644 --- a/app/javascript/mastodon/reducers/notification_groups.ts +++ b/app/javascript/mastodon/reducers/notification_groups.ts @@ -20,12 +20,16 @@ import { mountNotifications, unmountNotifications, refreshStaleNotificationGroups, + pollRecentNotifications, } from 'mastodon/actions/notification_groups'; import { disconnectTimeline, timelineDelete, } from 'mastodon/actions/timelines_typed'; -import type { ApiNotificationJSON } from 'mastodon/api_types/notifications'; +import type { + ApiNotificationJSON, + ApiNotificationGroupJSON, +} from 'mastodon/api_types/notifications'; import { compareId } from 'mastodon/compare_id'; import { usePendingItems } from 'mastodon/initial_state'; import { @@ -296,6 +300,106 @@ function commitLastReadId(state: NotificationGroupsState) { } } +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; + } +} + export const notificationGroupsReducer = createReducer( initialState, (builder) => { @@ -309,86 +413,36 @@ export const notificationGroupsReducer = createReducer( updateLastReadId(state); }) .addCase(fetchNotificationsGap.fulfilled, (state, action) => { - const { notifications } = action.payload; - - // find the gap in the existing notifications - const gapIndex = state.groups.findIndex( - (groupOrGap) => - groupOrGap.type === 'gap' && - groupOrGap.sinceId === action.meta.arg.gap.sinceId && - groupOrGap.maxId === action.meta.arg.gap.maxId, + state.groups = fillNotificationsGap( + state.groups, + action.meta.arg.gap, + action.payload.notifications, ); + state.isLoading = false; - if (gapIndex < 0) - // We do not know where to insert, let's return - return; - - // 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 = state.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), + 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, ); - - const apiGroupKeys = (toInsert as NotificationGroup[]).map( - (group) => group.group_key, - ); - - const sinceId = action.meta.arg.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 - state.groups = state.groups.filter( - (groupOrGap) => - groupOrGap.type !== 'gap' && - !apiGroupKeys.includes(groupOrGap.group_key), - ); - - // Replace the gap with API results (+ the new gap if needed) - state.groups.splice(gapIndex, 1, ...toInsert); - - // Finally, merge any adjacent gaps that could have been created by filtering - // groups earlier - mergeGaps(state.groups); - state.isLoading = false; updateLastReadId(state); + trimNotifications(state); }) .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { const notification = action.payload; @@ -403,10 +457,11 @@ export const notificationGroupsReducer = createReducer( }) .addCase(disconnectTimeline, (state, action) => { if (action.payload.timeline === 'home') { - if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') { - state.groups.unshift({ + const groups = usePendingItems ? state.pendingGroups : state.groups; + if (groups.length > 0 && groups[0]?.type !== 'gap') { + groups.unshift({ type: 'gap', - sinceId: state.groups[0]?.page_min_id, + sinceId: groups[0]?.page_min_id, }); } } @@ -453,12 +508,13 @@ export const notificationGroupsReducer = createReducer( } } } - trimNotifications(state); }); // 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; @@ -518,13 +574,21 @@ export const notificationGroupsReducer = createReducer( }, ) .addMatcher( - isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), + isAnyOf( + fetchNotifications.pending, + fetchNotificationsGap.pending, + pollRecentNotifications.pending, + ), (state) => { state.isLoading = true; }, ) .addMatcher( - isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected), + isAnyOf( + fetchNotifications.rejected, + fetchNotificationsGap.rejected, + pollRecentNotifications.rejected, + ), (state) => { state.isLoading = false; },