[Glitch] Add automatic notification polling for grouped notifications

Port d67e11733e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
shrike
Claire 2024-08-21 16:41:31 +02:00
parent a9cef5c324
commit 24849cdb1f
4 changed files with 186 additions and 85 deletions

View File

@ -11,6 +11,7 @@ import type {
} from 'flavours/glitch/api_types/notifications'; } from 'flavours/glitch/api_types/notifications';
import { allNotificationTypes } from 'flavours/glitch/api_types/notifications'; import { allNotificationTypes } from 'flavours/glitch/api_types/notifications';
import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses'; import type { ApiStatusJSON } from 'flavours/glitch/api_types/statuses';
import { usePendingItems } from 'flavours/glitch/initial_state';
import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups'; import type { NotificationGap } from 'flavours/glitch/reducers/notification_groups';
import { import {
selectSettingsNotificationsExcludedTypes, 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( export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew', 'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch, getState }) => { (notification: ApiNotificationJSON, { dispatch, getState }) => {

View File

@ -10,7 +10,7 @@ import {
deleteAnnouncement, deleteAnnouncement,
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; 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 { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
@ -37,7 +37,7 @@ const randomUpTo = max =>
* @param {string} channelName * @param {string} channelName
* @param {Object.<string, string>} params * @param {Object.<string, string>} params
* @param {Object} options * @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(): void} [options.fillGaps]
* @param {function(object): boolean} [options.accept] * @param {function(object): boolean} [options.accept]
* @returns {function(): void} * @returns {function(): void}
@ -52,11 +52,11 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
let pollingId; let pollingId;
/** /**
* @param {function(Function): Promise<void>} fallback * @param {function(Function, Function): Promise<void>} fallback
*/ */
const useFallback = async 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 // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
}; };
@ -139,10 +139,23 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
/** /**
* @param {Function} dispatch * @param {Function} dispatch
* @param {Function} getState
*/ */
async function refreshHomeTimelineAndNotification(dispatch) { async function refreshHomeTimelineAndNotification(dispatch, getState) {
await dispatch(expandHomeTimeline({ maxId: undefined })); await dispatch(expandHomeTimeline({ maxId: undefined }));
// 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(expandNotifications({}));
}
await dispatch(fetchAnnouncements()); await dispatch(fetchAnnouncements());
} }

View File

@ -4,6 +4,7 @@ import type { ApiNotificationGroupsResultJSON } from 'flavours/glitch/api_types/
export const apiFetchNotifications = async (params?: { export const apiFetchNotifications = async (params?: {
exclude_types?: string[]; exclude_types?: string[];
max_id?: string; max_id?: string;
since_id?: string;
}) => { }) => {
const response = await api().request<ApiNotificationGroupsResultJSON>({ const response = await api().request<ApiNotificationGroupsResultJSON>({
method: 'GET', method: 'GET',

View File

@ -20,12 +20,16 @@ import {
mountNotifications, mountNotifications,
unmountNotifications, unmountNotifications,
refreshStaleNotificationGroups, refreshStaleNotificationGroups,
pollRecentNotifications,
} from 'flavours/glitch/actions/notification_groups'; } from 'flavours/glitch/actions/notification_groups';
import { import {
disconnectTimeline, disconnectTimeline,
timelineDelete, timelineDelete,
} from 'flavours/glitch/actions/timelines_typed'; } from 'flavours/glitch/actions/timelines_typed';
import type { ApiNotificationJSON } from 'flavours/glitch/api_types/notifications'; import type {
ApiNotificationJSON,
ApiNotificationGroupJSON,
} from 'flavours/glitch/api_types/notifications';
import { compareId } from 'flavours/glitch/compare_id'; import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems } from 'flavours/glitch/initial_state'; import { usePendingItems } from 'flavours/glitch/initial_state';
import { import {
@ -296,32 +300,22 @@ function commitLastReadId(state: NotificationGroupsState) {
} }
} }
export const notificationGroupsReducer = createReducer<NotificationGroupsState>( function fillNotificationsGap(
initialState, groups: NotificationGroupsState['groups'],
(builder) => { gap: NotificationGap,
builder notifications: ApiNotificationGroupJSON[],
.addCase(fetchNotifications.fulfilled, (state, action) => { ): NotificationGroupsState['groups'] {
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) => {
const { notifications } = action.payload;
// find the gap in the existing notifications // find the gap in the existing notifications
const gapIndex = state.groups.findIndex( const gapIndex = groups.findIndex(
(groupOrGap) => (groupOrGap) =>
groupOrGap.type === 'gap' && groupOrGap.type === 'gap' &&
groupOrGap.sinceId === action.meta.arg.gap.sinceId && groupOrGap.sinceId === gap.sinceId &&
groupOrGap.maxId === action.meta.arg.gap.maxId, groupOrGap.maxId === gap.maxId,
); );
if (gapIndex < 0) if (gapIndex < 0)
// We do not know where to insert, let's return // We do not know where to insert, let's return
return; return groups;
// Filling a disconnection gap means we're getting historical data // Filling a disconnection gap means we're getting historical data
// about groups we may know or may not know about. // about groups we may know or may not know about.
@ -339,22 +333,20 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
// replace the gap with the notifications + a new gap // replace the gap with the notifications + a new gap
const newerGroupKeys = state.groups const newerGroupKeys = groups
.slice(0, gapIndex) .slice(0, gapIndex)
.filter(isNotificationGroup) .filter(isNotificationGroup)
.map((group) => group.group_key); .map((group) => group.group_key);
const toInsert: NotificationGroupsState['groups'] = notifications const toInsert: NotificationGroupsState['groups'] = notifications
.map((json) => createNotificationGroupFromJSON(json)) .map((json) => createNotificationGroupFromJSON(json))
.filter( .filter((notification) => !newerGroupKeys.includes(notification.group_key));
(notification) => !newerGroupKeys.includes(notification.group_key),
);
const apiGroupKeys = (toInsert as NotificationGroup[]).map( const apiGroupKeys = (toInsert as NotificationGroup[]).map(
(group) => group.group_key, (group) => group.group_key,
); );
const sinceId = action.meta.arg.gap.sinceId; const sinceId = gap.sinceId;
if ( if (
notifications.length > 0 && notifications.length > 0 &&
!( !(
@ -373,22 +365,84 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
} }
// Remove older groups covered by the API // Remove older groups covered by the API
state.groups = state.groups.filter( groups = groups.filter(
(groupOrGap) => (groupOrGap) =>
groupOrGap.type !== 'gap' && groupOrGap.type !== 'gap' && !apiGroupKeys.includes(groupOrGap.group_key),
!apiGroupKeys.includes(groupOrGap.group_key),
); );
// Replace the gap with API results (+ the new gap if needed) // Replace the gap with API results (+ the new gap if needed)
state.groups.splice(gapIndex, 1, ...toInsert); groups.splice(gapIndex, 1, ...toInsert);
// Finally, merge any adjacent gaps that could have been created by filtering // Finally, merge any adjacent gaps that could have been created by filtering
// groups earlier // groups earlier
mergeGaps(state.groups); 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) => {
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; state.isLoading = false;
updateLastReadId(state); updateLastReadId(state);
trimNotifications(state);
}) })
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => { .addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload; const notification = action.payload;
@ -403,10 +457,11 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}) })
.addCase(disconnectTimeline, (state, action) => { .addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') { if (action.payload.timeline === 'home') {
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') { const groups = usePendingItems ? state.pendingGroups : state.groups;
state.groups.unshift({ if (groups.length > 0 && groups[0]?.type !== 'gap') {
groups.unshift({
type: 'gap', 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 // Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups); state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = []; state.pendingGroups = [];
mergeGaps(state.groups);
trimNotifications(state);
}) })
.addCase(updateScrollPosition.fulfilled, (state, action) => { .addCase(updateScrollPosition.fulfilled, (state, action) => {
state.scrolledToTop = action.payload.top; state.scrolledToTop = action.payload.top;
@ -518,13 +574,21 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
}, },
) )
.addMatcher( .addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), isAnyOf(
fetchNotifications.pending,
fetchNotificationsGap.pending,
pollRecentNotifications.pending,
),
(state) => { (state) => {
state.isLoading = true; state.isLoading = true;
}, },
) )
.addMatcher( .addMatcher(
isAnyOf(fetchNotifications.rejected, fetchNotificationsGap.rejected), isAnyOf(
fetchNotifications.rejected,
fetchNotificationsGap.rejected,
pollRecentNotifications.rejected,
),
(state) => { (state) => {
state.isLoading = false; state.isLoading = false;
}, },