Improve gap handling

Claire 2024-06-20 12:36:45 +02:00
parent 5e58492b8b
commit 3b064b269f
4 changed files with 146 additions and 44 deletions

View File

@ -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 };
}, },
); );

View File

@ -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,
}); });

View File

@ -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}

View File

@ -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]);