mirror of https://github.com/tootsuite/mastodon
Add automatic notification polling for grouped notifications (#31513)
parent
01a757d306
commit
d67e11733e
|
@ -11,6 +11,7 @@ import type {
|
||||||
} from 'mastodon/api_types/notifications';
|
} from 'mastodon/api_types/notifications';
|
||||||
import { allNotificationTypes } from 'mastodon/api_types/notifications';
|
import { allNotificationTypes } from 'mastodon/api_types/notifications';
|
||||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||||
|
import { usePendingItems } from 'mastodon/initial_state';
|
||||||
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
|
import type { NotificationGap } from 'mastodon/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 }) => {
|
||||||
|
|
|
@ -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 }));
|
||||||
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());
|
await dispatch(fetchAnnouncements());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type { ApiNotificationGroupsResultJSON } from 'mastodon/api_types/notific
|
||||||
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',
|
||||||
|
|
|
@ -20,12 +20,16 @@ import {
|
||||||
mountNotifications,
|
mountNotifications,
|
||||||
unmountNotifications,
|
unmountNotifications,
|
||||||
refreshStaleNotificationGroups,
|
refreshStaleNotificationGroups,
|
||||||
|
pollRecentNotifications,
|
||||||
} from 'mastodon/actions/notification_groups';
|
} from 'mastodon/actions/notification_groups';
|
||||||
import {
|
import {
|
||||||
disconnectTimeline,
|
disconnectTimeline,
|
||||||
timelineDelete,
|
timelineDelete,
|
||||||
} from 'mastodon/actions/timelines_typed';
|
} 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 { compareId } from 'mastodon/compare_id';
|
||||||
import { usePendingItems } from 'mastodon/initial_state';
|
import { usePendingItems } from 'mastodon/initial_state';
|
||||||
import {
|
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>(
|
export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||||
initialState,
|
initialState,
|
||||||
(builder) => {
|
(builder) => {
|
||||||
|
@ -309,86 +413,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||||
updateLastReadId(state);
|
updateLastReadId(state);
|
||||||
})
|
})
|
||||||
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
||||||
const { notifications } = action.payload;
|
state.groups = fillNotificationsGap(
|
||||||
|
state.groups,
|
||||||
// find the gap in the existing notifications
|
action.meta.arg.gap,
|
||||||
const gapIndex = state.groups.findIndex(
|
action.payload.notifications,
|
||||||
(groupOrGap) =>
|
|
||||||
groupOrGap.type === 'gap' &&
|
|
||||||
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
|
|
||||||
groupOrGap.maxId === action.meta.arg.gap.maxId,
|
|
||||||
);
|
);
|
||||||
|
state.isLoading = false;
|
||||||
|
|
||||||
if (gapIndex < 0)
|
updateLastReadId(state);
|
||||||
// We do not know where to insert, let's return
|
})
|
||||||
return;
|
.addCase(pollRecentNotifications.fulfilled, (state, action) => {
|
||||||
|
if (usePendingItems) {
|
||||||
// Filling a disconnection gap means we're getting historical data
|
const gap = ensureLeadingGap(state.pendingGroups);
|
||||||
// about groups we may know or may not know about.
|
state.pendingGroups = fillNotificationsGap(
|
||||||
|
state.pendingGroups,
|
||||||
// The notifications timeline is split in two by the gap, with
|
gap,
|
||||||
// group information newer than the gap, and group information older
|
action.payload.notifications,
|
||||||
// than the gap.
|
);
|
||||||
|
} else {
|
||||||
// Filling a gap should not touch anything before the gap, so any
|
const gap = ensureLeadingGap(state.groups);
|
||||||
// information on groups already appearing before the gap should be
|
state.groups = fillNotificationsGap(
|
||||||
// discarded, while any information on groups appearing after the gap
|
state.groups,
|
||||||
// can be updated and re-ordered.
|
gap,
|
||||||
|
action.payload.notifications,
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
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;
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue