From d67e11733e4159c736f4370ee7ea86240a297bb7 Mon Sep 17 00:00:00 2001
From: Claire <claire.github-309c@sitedethib.com>
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.<string, string>} params
  * @param {Object} options
- * @param {function(Function): Promise<void>} [options.fallback]
+ * @param {function(Function, Function): Promise<void>} [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<void>} fallback
+     * @param {function(Function, Function): Promise<void>} 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<ApiNotificationGroupsResultJSON>({
     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<NotificationGroupsState>(
   initialState,
   (builder) => {
@@ -309,86 +413,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
         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<NotificationGroupsState>(
       })
       .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<NotificationGroupsState>(
               }
             }
           }
-          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<NotificationGroupsState>(
         },
       )
       .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;
         },
-- 
GitLab