mirror of https://github.com/mastodon/mastodon.git
Improve gap handling
parent
5e58492b8b
commit
3b064b269f
|
@ -73,17 +73,15 @@ export const fetchNotifications = createDataLoadingThunk(
|
||||||
: excludeAllTypesExcept(activeFilter),
|
: excludeAllTypesExcept(activeFilter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
({ notifications, links }, { dispatch }) => {
|
({ notifications }, { dispatch }) => {
|
||||||
dispatchAssociatedRecords(dispatch, notifications);
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
// We ignore the previous link, as it will always be here but we know there are no more
|
|
||||||
// recent notifications when doing the initial load
|
|
||||||
const nextLink = links.refs.find((link) => link.rel === 'next');
|
|
||||||
|
|
||||||
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
|
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
|
||||||
notifications;
|
notifications;
|
||||||
|
|
||||||
if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
|
// TODO: might be worth not using gaps for that…
|
||||||
|
// if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
|
||||||
|
if (notifications.length > 1)
|
||||||
|
payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
// dispatch(submitMarkers());
|
// dispatch(submitMarkers());
|
||||||
|
@ -93,14 +91,12 @@ export const fetchNotifications = createDataLoadingThunk(
|
||||||
export const fetchNotificationsGap = createDataLoadingThunk(
|
export const fetchNotificationsGap = createDataLoadingThunk(
|
||||||
'notificationGroups/fetchGat',
|
'notificationGroups/fetchGat',
|
||||||
async (params: { gap: NotificationGap }) =>
|
async (params: { gap: NotificationGap }) =>
|
||||||
apiFetchNotifications({}, params.gap.loadUrl),
|
apiFetchNotifications({ max_id: params.gap.maxId }),
|
||||||
|
|
||||||
({ notifications, links }, { dispatch }) => {
|
({ notifications }, { dispatch }) => {
|
||||||
dispatchAssociatedRecords(dispatch, notifications);
|
dispatchAssociatedRecords(dispatch, notifications);
|
||||||
|
|
||||||
const nextLink = links.refs.find((link) => link.rel === 'next');
|
return { notifications };
|
||||||
|
|
||||||
return { notifications, nextLink };
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import api, { apiRequest, getLinks } from 'mastodon/api';
|
import api, { apiRequest, getLinks } from 'mastodon/api';
|
||||||
import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';
|
import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';
|
||||||
|
|
||||||
export const apiFetchNotifications = async (
|
export const apiFetchNotifications = async (params?: {
|
||||||
params?: {
|
exclude_types?: string[];
|
||||||
exclude_types?: string[];
|
max_id?: string;
|
||||||
},
|
}) => {
|
||||||
forceUrl?: string,
|
|
||||||
) => {
|
|
||||||
const response = await api().request<ApiNotificationGroupJSON[]>({
|
const response = await api().request<ApiNotificationGroupJSON[]>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: forceUrl ?? '/api/v2_alpha/notifications',
|
url: '/api/v2_alpha/notifications',
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,6 @@ import type { RootState } from 'mastodon/store';
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { submitMarkers } from '../../actions/markers';
|
import { submitMarkers } from '../../actions/markers';
|
||||||
import {
|
import {
|
||||||
expandNotifications,
|
|
||||||
scrollTopNotifications,
|
scrollTopNotifications,
|
||||||
loadPending,
|
loadPending,
|
||||||
// mountNotifications,
|
// mountNotifications,
|
||||||
|
@ -95,7 +94,7 @@ export const Notifications: React.FC<{
|
||||||
const notifications = useAppSelector(getNotifications);
|
const notifications = useAppSelector(getNotifications);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const isLoading = useAppSelector((s) => s.notificationsGroups.isLoading);
|
const isLoading = useAppSelector((s) => s.notificationsGroups.isLoading);
|
||||||
const hasMore = useAppSelector((s) => s.notificationsGroups.hasMore);
|
const hasMore = notifications.at(-1)?.type === 'gap';
|
||||||
|
|
||||||
const lastReadId = useAppSelector((s) =>
|
const lastReadId = useAppSelector((s) =>
|
||||||
selectSettingsNotificationsShowUnread(s) ? s.markers.notifications : '0',
|
selectSettingsNotificationsShowUnread(s) ? s.markers.notifications : '0',
|
||||||
|
@ -159,12 +158,10 @@ export const Notifications: React.FC<{
|
||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: fix this, probably incorrect
|
|
||||||
const handleLoadOlder = useDebouncedCallback(
|
const handleLoadOlder = useDebouncedCallback(
|
||||||
() => {
|
() => {
|
||||||
const last = notifications[notifications.length - 1];
|
const gap = notifications.at(-1);
|
||||||
if (last && last.type !== 'gap')
|
if (gap?.type === 'gap') void dispatch(fetchNotificationsGap({ gap }));
|
||||||
dispatch(expandNotifications({ maxId: last.group_key }));
|
|
||||||
},
|
},
|
||||||
300,
|
300,
|
||||||
{ leading: true },
|
{ leading: true },
|
||||||
|
@ -254,7 +251,7 @@ export const Notifications: React.FC<{
|
||||||
return notifications.map((item) =>
|
return notifications.map((item) =>
|
||||||
item.type === 'gap' ? (
|
item.type === 'gap' ? (
|
||||||
<LoadGap
|
<LoadGap
|
||||||
key={item.loadUrl}
|
key={`${item.maxId}-${item.sinceId}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
param={item}
|
param={item}
|
||||||
onClick={handleLoadGap}
|
onClick={handleLoadGap}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
disconnectTimeline,
|
disconnectTimeline,
|
||||||
timelineDelete,
|
timelineDelete,
|
||||||
} from 'mastodon/actions/timelines_typed';
|
} from 'mastodon/actions/timelines_typed';
|
||||||
|
import { compareId } from 'mastodon/compare_id';
|
||||||
import {
|
import {
|
||||||
NOTIFICATIONS_GROUP_MAX_AVATARS,
|
NOTIFICATIONS_GROUP_MAX_AVATARS,
|
||||||
createNotificationGroupFromJSON,
|
createNotificationGroupFromJSON,
|
||||||
|
@ -26,19 +27,18 @@ import type { NotificationGroup } from 'mastodon/models/notification_group';
|
||||||
|
|
||||||
export interface NotificationGap {
|
export interface NotificationGap {
|
||||||
type: 'gap';
|
type: 'gap';
|
||||||
loadUrl: string;
|
maxId?: string;
|
||||||
|
sinceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationGroupsState {
|
interface NotificationGroupsState {
|
||||||
groups: (NotificationGroup | NotificationGap)[];
|
groups: (NotificationGroup | NotificationGap)[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasMore: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: NotificationGroupsState = {
|
const initialState: NotificationGroupsState = {
|
||||||
groups: [],
|
groups: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasMore: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function removeNotificationsForAccounts(
|
function removeNotificationsForAccounts(
|
||||||
|
@ -69,6 +69,7 @@ function removeNotificationsForAccounts(
|
||||||
.filter(
|
.filter(
|
||||||
(group) => group.type === 'gap' || group.sampleAccountsIds.length > 0,
|
(group) => group.type === 'gap' || group.sampleAccountsIds.length > 0,
|
||||||
);
|
);
|
||||||
|
mergeGaps(state.groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeNotificationsForStatus(
|
function removeNotificationsForStatus(
|
||||||
|
@ -81,6 +82,61 @@ function removeNotificationsForStatus(
|
||||||
!('statusId' in group) ||
|
!('statusId' in group) ||
|
||||||
group.statusId !== statusId,
|
group.statusId !== statusId,
|
||||||
);
|
);
|
||||||
|
mergeGaps(state.groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationsGroupsReducer =
|
export const notificationsGroupsReducer =
|
||||||
|
@ -93,34 +149,83 @@ export const notificationsGroupsReducer =
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
})
|
})
|
||||||
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
||||||
const { notifications, nextLink } = action.payload;
|
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 = state.groups.findIndex(
|
||||||
(groupOrGap) =>
|
(groupOrGap) =>
|
||||||
groupOrGap.type === 'gap' && groupOrGap.loadUrl === nextLink?.uri,
|
groupOrGap.type === 'gap' &&
|
||||||
|
groupOrGap.sinceId === action.meta.arg.gap.sinceId &&
|
||||||
|
groupOrGap.maxId === action.meta.arg.gap.maxId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!gapIndex)
|
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;
|
||||||
|
|
||||||
|
// 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
|
// replace the gap with the notifications + a new gap
|
||||||
|
|
||||||
const toInsert: NotificationGroupsState['groups'] = notifications.map(
|
const newerGroupKeys = state.groups
|
||||||
(json) => createNotificationGroupFromJSON(json),
|
.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,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextLink?.uri && notifications.length > 0) {
|
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
|
// 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({
|
toInsert.push({
|
||||||
type: 'gap',
|
type: 'gap',
|
||||||
loadUrl: nextLink.uri,
|
maxId: notifications.at(-1)?.page_max_id,
|
||||||
|
sinceId,
|
||||||
} as NotificationGap);
|
} 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);
|
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;
|
||||||
})
|
})
|
||||||
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
|
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
|
||||||
|
@ -130,6 +235,12 @@ export const notificationsGroupsReducer =
|
||||||
group.type !== 'gap' && group.group_key === notification.group_key,
|
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 (state.groups.length > 0 && state.groups[0]?.type === 'gap') {
|
||||||
|
state.groups[0].maxId = notification.id;
|
||||||
|
}
|
||||||
|
|
||||||
if (existingGroupIndex > -1) {
|
if (existingGroupIndex > -1) {
|
||||||
const existingGroup = state.groups[existingGroupIndex];
|
const existingGroup = state.groups[existingGroupIndex];
|
||||||
|
|
||||||
|
@ -151,6 +262,8 @@ export const notificationsGroupsReducer =
|
||||||
existingGroup.notifications_count += 1;
|
existingGroup.notifications_count += 1;
|
||||||
|
|
||||||
state.groups.splice(existingGroupIndex, 1);
|
state.groups.splice(existingGroupIndex, 1);
|
||||||
|
mergeGapsAround(state.groups, existingGroupIndex);
|
||||||
|
|
||||||
state.groups.unshift(existingGroup);
|
state.groups.unshift(existingGroup);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -162,13 +275,12 @@ export const notificationsGroupsReducer =
|
||||||
})
|
})
|
||||||
.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')
|
if (state.groups.length > 0 && state.groups[0]?.type !== 'gap') {
|
||||||
state.groups.shift();
|
state.groups.unshift({
|
||||||
|
type: 'gap',
|
||||||
state.groups.unshift({
|
sinceId: state.groups[0]?.page_min_id,
|
||||||
type: 'gap',
|
});
|
||||||
loadUrl: 'TODO_LOAD_URL_TOP_OF_TL', // TODO
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addCase(timelineDelete, (state, action) => {
|
.addCase(timelineDelete, (state, action) => {
|
||||||
|
@ -176,7 +288,6 @@ export const notificationsGroupsReducer =
|
||||||
})
|
})
|
||||||
.addCase(clearNotifications.pending, (state) => {
|
.addCase(clearNotifications.pending, (state) => {
|
||||||
state.groups = [];
|
state.groups = [];
|
||||||
state.hasMore = false;
|
|
||||||
})
|
})
|
||||||
.addCase(blockAccountSuccess, (state, action) => {
|
.addCase(blockAccountSuccess, (state, action) => {
|
||||||
removeNotificationsForAccounts(state, [action.payload.relationship.id]);
|
removeNotificationsForAccounts(state, [action.payload.relationship.id]);
|
||||||
|
|
Loading…
Reference in New Issue