Add slow mode support

Claire 2024-06-26 17:00:05 +02:00
parent 3b064b269f
commit 04e638349b
4 changed files with 155 additions and 67 deletions

View File

@ -1,3 +1,5 @@
import { createAction } from '@reduxjs/toolkit';
import { import {
apiClearNotifications, apiClearNotifications,
apiFetchNotifications, apiFetchNotifications,
@ -109,6 +111,8 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
}, },
); );
export const loadPending = createAction('notificationGroups/loadPending');
export const setNotificationsFilter = createAppAsyncThunk( export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set', 'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => { ({ filterType }: { filterType: string }, { dispatch }) => {

View File

@ -13,13 +13,17 @@ import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?
import { import {
fetchNotifications, fetchNotifications,
fetchNotificationsGap, fetchNotificationsGap,
loadPending,
} from 'mastodon/actions/notification_groups'; } from 'mastodon/actions/notification_groups';
import { compareId } from 'mastodon/compare_id'; import { compareId } from 'mastodon/compare_id';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import type { NotificationGap } from 'mastodon/reducers/notifications_groups'; import type { NotificationGap } from 'mastodon/reducers/notifications_groups';
import { selectUnreadNotificationsGroupsCount } from 'mastodon/selectors/notifications'; import {
selectUnreadNotificationsGroupsCount,
selectPendingNotificationsGroupsCount,
} from 'mastodon/selectors/notifications';
import { import {
selectNeedsNotificationPermission, selectNeedsNotificationPermission,
selectSettingsNotificationsExcludedTypes, selectSettingsNotificationsExcludedTypes,
@ -34,7 +38,6 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { submitMarkers } from '../../actions/markers'; import { submitMarkers } from '../../actions/markers';
import { import {
scrollTopNotifications, scrollTopNotifications,
loadPending,
// mountNotifications, // mountNotifications,
// unmountNotifications, // unmountNotifications,
markNotificationsAsRead, markNotificationsAsRead,
@ -80,16 +83,10 @@ const getNotifications = createSelector(
}, },
); );
// const mapStateToProps = (state) => ({
// numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList())
// .size,
// });
export const Notifications: React.FC<{ export const Notifications: React.FC<{
columnId?: string; columnId?: string;
multiColumn?: boolean; multiColumn?: boolean;
numPending: number; }> = ({ columnId, multiColumn }) => {
}> = ({ columnId, multiColumn, numPending }) => {
const intl = useIntl(); const intl = useIntl();
const notifications = useAppSelector(getNotifications); const notifications = useAppSelector(getNotifications);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -100,6 +97,8 @@ export const Notifications: React.FC<{
selectSettingsNotificationsShowUnread(s) ? s.markers.notifications : '0', selectSettingsNotificationsShowUnread(s) ? s.markers.notifications : '0',
); );
const numPending = useAppSelector(selectPendingNotificationsGroupsCount);
const unreadNotificationsCount = useAppSelector( const unreadNotificationsCount = useAppSelector(
selectUnreadNotificationsGroupsCount, selectUnreadNotificationsGroupsCount,
); );

View File

@ -12,12 +12,15 @@ import {
fetchNotifications, fetchNotifications,
fetchNotificationsGap, fetchNotificationsGap,
processNewNotificationForGroups, processNewNotificationForGroups,
loadPending,
} 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 { compareId } from 'mastodon/compare_id'; import { compareId } from 'mastodon/compare_id';
import { usePendingItems } from 'mastodon/initial_state';
import { import {
NOTIFICATIONS_GROUP_MAX_AVATARS, NOTIFICATIONS_GROUP_MAX_AVATARS,
createNotificationGroupFromJSON, createNotificationGroupFromJSON,
@ -33,20 +36,22 @@ export interface NotificationGap {
interface NotificationGroupsState { interface NotificationGroupsState {
groups: (NotificationGroup | NotificationGap)[]; groups: (NotificationGroup | NotificationGap)[];
pendingGroups: (NotificationGroup | NotificationGap)[];
isLoading: boolean; isLoading: boolean;
} }
const initialState: NotificationGroupsState = { const initialState: NotificationGroupsState = {
groups: [], groups: [],
pendingGroups: [], // holds pending groups in slow mode
isLoading: false, isLoading: false,
}; };
function removeNotificationsForAccounts( function filterNotificationsForAccounts(
state: NotificationGroupsState, groups: NotificationGroupsState['groups'],
accountIds: string[], accountIds: string[],
onlyForType?: string, onlyForType?: string,
) { ) {
state.groups = state.groups groups = groups
.map((group) => { .map((group) => {
if ( if (
group.type !== 'gap' && group.type !== 'gap' &&
@ -69,20 +74,50 @@ function removeNotificationsForAccounts(
.filter( .filter(
(group) => group.type === 'gap' || group.sampleAccountsIds.length > 0, (group) => group.type === 'gap' || group.sampleAccountsIds.length > 0,
); );
mergeGaps(state.groups); mergeGaps(groups);
return groups;
}
function filterNotificationsForStatus(
groups: NotificationGroupsState['groups'],
statusId: string,
) {
groups = groups.filter(
(group) =>
group.type === 'gap' ||
!('statusId' in group) ||
group.statusId !== statusId,
);
mergeGaps(groups);
return groups;
}
function removeNotificationsForAccounts(
state: NotificationGroupsState,
accountIds: string[],
onlyForType?: string,
) {
state.groups = filterNotificationsForAccounts(
state.groups,
accountIds,
onlyForType,
);
state.pendingGroups = filterNotificationsForAccounts(
state.pendingGroups,
accountIds,
onlyForType,
);
} }
function removeNotificationsForStatus( function removeNotificationsForStatus(
state: NotificationGroupsState, state: NotificationGroupsState,
statusId: string, statusId: string,
) { ) {
state.groups = state.groups.filter( state.groups = filterNotificationsForStatus(state.groups, statusId);
(group) => state.pendingGroups = filterNotificationsForStatus(
group.type === 'gap' || state.pendingGroups,
!('statusId' in group) || statusId,
group.statusId !== statusId,
); );
mergeGaps(state.groups);
} }
function isNotificationGroup( function isNotificationGroup(
@ -139,6 +174,52 @@ function mergeGapsAround(
} }
} }
function processNewNotification(
groups: NotificationGroupsState['groups'],
notification: ApiNotificationJSON,
) {
const existingGroupIndex = groups.findIndex(
(group) =>
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 (groups.length > 0 && groups[0]?.type === 'gap') {
groups[0].maxId = notification.id;
}
if (existingGroupIndex > -1) {
const existingGroup = groups[existingGroupIndex];
if (
existingGroup &&
existingGroup.type !== 'gap' &&
!existingGroup.sampleAccountsIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post
) {
// Update the existing group
if (
existingGroup.sampleAccountsIds.unshift(notification.account.id) >
NOTIFICATIONS_GROUP_MAX_AVATARS
)
existingGroup.sampleAccountsIds.pop();
existingGroup.most_recent_notification_id = notification.id;
existingGroup.page_max_id = notification.id;
existingGroup.latest_page_notification_at = notification.created_at;
existingGroup.notifications_count += 1;
groups.splice(existingGroupIndex, 1);
mergeGapsAround(groups, existingGroupIndex);
groups.unshift(existingGroup);
}
} else {
// Create a new group
groups.unshift(createNotificationGroupFromNotificationJSON(notification));
}
}
export const notificationsGroupsReducer = export const notificationsGroupsReducer =
createReducer<NotificationGroupsState>(initialState, (builder) => { createReducer<NotificationGroupsState>(initialState, (builder) => {
builder builder
@ -230,48 +311,10 @@ export const notificationsGroupsReducer =
}) })
.addCase(processNewNotificationForGroups.fulfilled, (state, action) => { .addCase(processNewNotificationForGroups.fulfilled, (state, action) => {
const notification = action.payload; const notification = action.payload;
const existingGroupIndex = state.groups.findIndex( processNewNotification(
(group) => usePendingItems ? state.pendingGroups : state.groups,
group.type !== 'gap' && group.group_key === notification.group_key, notification,
); );
// 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) {
const existingGroup = state.groups[existingGroupIndex];
if (
existingGroup &&
existingGroup.type !== 'gap' &&
!existingGroup.sampleAccountsIds.includes(notification.account.id) // This can happen for example if you like, then unlike, then like again the same post
) {
// Update the existing group
if (
existingGroup.sampleAccountsIds.unshift(notification.account.id) >
NOTIFICATIONS_GROUP_MAX_AVATARS
)
existingGroup.sampleAccountsIds.pop();
existingGroup.most_recent_notification_id = notification.id;
existingGroup.page_max_id = notification.id;
existingGroup.latest_page_notification_at = notification.created_at;
existingGroup.notifications_count += 1;
state.groups.splice(existingGroupIndex, 1);
mergeGapsAround(state.groups, existingGroupIndex);
state.groups.unshift(existingGroup);
}
} else {
// Create a new group
state.groups.unshift(
createNotificationGroupFromNotificationJSON(notification),
);
}
}) })
.addCase(disconnectTimeline, (state, action) => { .addCase(disconnectTimeline, (state, action) => {
if (action.payload.timeline === 'home') { if (action.payload.timeline === 'home') {
@ -288,6 +331,7 @@ export const notificationsGroupsReducer =
}) })
.addCase(clearNotifications.pending, (state) => { .addCase(clearNotifications.pending, (state) => {
state.groups = []; state.groups = [];
state.pendingGroups = [];
}) })
.addCase(blockAccountSuccess, (state, action) => { .addCase(blockAccountSuccess, (state, action) => {
removeNotificationsForAccounts(state, [action.payload.relationship.id]); removeNotificationsForAccounts(state, [action.payload.relationship.id]);
@ -304,6 +348,32 @@ export const notificationsGroupsReducer =
action.payload.accounts.map((account) => account.id), action.payload.accounts.map((account) => account.id),
); );
}) })
.addCase(loadPending, (state) => {
// First, remove any existing group and merge data
state.pendingGroups.forEach((group) => {
if (group.type !== 'gap') {
const existingGroupIndex = state.groups.findIndex(
(groupOrGap) =>
isNotificationGroup(groupOrGap) &&
groupOrGap.group_key === group.group_key,
);
if (existingGroupIndex > -1) {
const existingGroup = state.groups[existingGroupIndex];
if (existingGroup && existingGroup.type !== 'gap') {
group.notifications_count += existingGroup.notifications_count;
group.sampleAccountsIds = group.sampleAccountsIds
.concat(existingGroup.sampleAccountsIds)
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS);
state.groups.splice(existingGroupIndex, 1);
}
}
}
});
// Then build the consolidated list and clear pending groups
state.groups = state.pendingGroups.concat(state.groups);
state.pendingGroups = [];
})
.addMatcher( .addMatcher(
isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess), isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess),
(state, action) => { (state, action) => {

View File

@ -6,14 +6,29 @@ import type { RootState } from 'mastodon/store';
export const selectUnreadNotificationsGroupsCount = createSelector( export const selectUnreadNotificationsGroupsCount = createSelector(
[ [
(s: RootState) => s.markers.notifications, (s: RootState) => s.markers.notifications,
(s: RootState) => s.notificationsGroups.pendingGroups,
(s: RootState) => s.notificationsGroups.groups, (s: RootState) => s.notificationsGroups.groups,
], ],
(notificationMarker, groups) => { (notificationMarker, pendingGroups, groups) => {
return groups.filter( return (
groups.filter(
(group) => (group) =>
group.type !== 'gap' && group.type !== 'gap' &&
group.page_max_id && group.page_max_id &&
compareId(group.page_max_id, notificationMarker) > 0, compareId(group.page_max_id, notificationMarker) > 0,
).length; ).length +
pendingGroups.filter(
(group) =>
group.type !== 'gap' &&
group.page_max_id &&
compareId(group.page_max_id, notificationMarker) > 0,
).length
);
}, },
); );
export const selectPendingNotificationsGroupsCount = createSelector(
[(s: RootState) => s.notificationsGroups.pendingGroups],
(pendingGroups) =>
pendingGroups.filter((group) => group.type !== 'gap').length,
);