Grouped Notifications UI (#30440)

Co-authored-by: Eugen Rochko <eugen@zeonfederated.com>
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Renaud Chaput 2024-07-18 16:36:09 +02:00 committed by GitHub
commit f587ff643f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3329 additions and 131 deletions

View file

@ -75,9 +75,17 @@ interface MarkerParam {
}
function getLastNotificationId(state: RootState): string | undefined {
// @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
return state.getIn(['notifications', 'lastReadId']);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = state.settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta
? state.notificationGroups.lastReadId
: // @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
state.getIn(['notifications', 'lastReadId']);
}
const buildPostMarkersParams = (state: RootState) => {

View file

@ -0,0 +1,144 @@
import { createAction } from '@reduxjs/toolkit';
import {
apiClearNotifications,
apiFetchNotifications,
} from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type {
ApiNotificationGroupJSON,
ApiNotificationJSON,
} from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { NotificationGap } from 'mastodon/reducers/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
} from 'mastodon/selectors/settings';
import type { AppDispatch } from 'mastodon/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from 'mastodon/store/typed_functions';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';
function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
}
function dispatchAssociatedRecords(
dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
) {
const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = [];
notifications.forEach((notification) => {
if ('sample_accounts' in notification) {
fetchedAccounts.push(...notification.sample_accounts);
}
if (notification.type === 'admin.report') {
fetchedAccounts.push(notification.report.target_account);
}
if (notification.type === 'moderation_warning') {
fetchedAccounts.push(notification.moderation_warning.target_account);
}
if ('status' in notification) {
fetchedStatuses.push(notification.status);
}
});
if (fetchedAccounts.length > 0)
dispatch(importFetchedAccounts(fetchedAccounts));
if (fetchedStatuses.length > 0)
dispatch(importFetchedStatuses(fetchedStatuses));
}
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) => {
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
notifications;
// 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;
// dispatch(submitMarkers());
},
);
export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) =>
apiFetchNotifications({ max_id: params.gap.maxId }),
({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);
return { notifications };
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch }) => {
dispatchAssociatedRecords(dispatch, [notification]);
return notification;
},
);
export const loadPending = createAction('notificationGroups/loadPending');
export const updateScrollPosition = createAction<{ top: boolean }>(
'notificationGroups/updateScrollPosition',
);
export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => {
dispatch({
type: NOTIFICATIONS_FILTER_SET,
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications());
dispatch(saveSettings());
},
);
export const clearNotifications = createDataLoadingThunk(
'notifications/clear',
() => apiClearNotifications(),
);
export const markNotificationsAsRead = createAction(
'notificationGroups/markAsRead',
);
export const mountNotifications = createAction('notificationGroups/mount');
export const unmountNotifications = createAction('notificationGroups/unmount');

View file

@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
@ -174,7 +173,7 @@ const noOp = () => {};
let expandNotificationsController = new AbortController();
export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications');
@ -257,16 +256,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
};
}
export function clearNotifications() {
return (dispatch) => {
dispatch({
type: NOTIFICATIONS_CLEAR,
});
api().post('/api/v1/notifications/clear');
};
}
export function scrollTopNotifications(top) {
return {
type: NOTIFICATIONS_SCROLL_TOP,

View file

@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'mastodon/store';
import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = getState().settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
if (enableBeta) void dispatch(fetchNotifications());
else dispatch(expandNotifications());
},
);

View file

@ -1,11 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from '../api_types/accounts';
// To be replaced once ApiNotificationJSON type exists
interface FakeApiNotificationJSON {
type: string;
account: ApiAccountJSON;
}
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';
export const notificationsUpdate = createAction(
'notifications/update',
@ -13,7 +8,7 @@ export const notificationsUpdate = createAction(
playSound,
...args
}: {
notification: FakeApiNotificationJSON;
notification: ApiNotificationJSON;
usePendingItems: boolean;
playSound: boolean;
}) => ({

View file

@ -10,6 +10,7 @@ import {
deleteAnnouncement,
} from './announcements';
import { updateConversations } from './conversations';
import { processNewNotificationForGroups } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses';
import {
@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'delete':
dispatch(deleteFromTimelines(data.payload));
break;
case 'notification':
case 'notification': {
// @ts-expect-error
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
const notificationJSON = JSON.parse(data.payload);
dispatch(updateNotifications(notificationJSON, messages, locale));
// TODO: remove this once the groups feature replaces the previous one
if(getState().notificationGroups.groups.length > 0) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
break;
}
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));