Handle filters, and add basic pagination management

Renaud Chaput 2024-06-14 11:43:00 +02:00
parent 5ad3e77f85
commit d9df25ad38
No known key found for this signature in database
GPG Key ID: BCFC859D49B46990
7 changed files with 290 additions and 58 deletions

View File

@ -1,17 +1,49 @@
import { apiFetchNotifications } from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { NotificationGroupJSON } from 'mastodon/api_types/notifications';
import { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import type { NotificationGap } from 'mastodon/reducers/notifications_groups';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
} from 'mastodon/selectors/settings';
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);
}
export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
() => apiFetchNotifications(),
(notifications, { dispatch }) => {
async (params: { url?: string } | undefined, { getState }) => {
if (params?.url) return apiFetchNotifications({}, params.url);
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());
return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications, links }, { dispatch }) => {
const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = [];
// 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');
notifications.forEach((notification) => {
if ('sample_accounts' in notification) {
fetchedAccounts.push(...notification.sample_accounts);
@ -36,6 +68,25 @@ export const fetchNotifications = createDataLoadingThunk(
if (fetchedStatuses.length > 0)
dispatch(importFetchedStatuses(fetchedStatuses));
const payload: (NotificationGroupJSON | NotificationGap)[] = notifications;
if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
return payload;
// dispatch(submitMarkers());
},
);
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());
},
);

View File

@ -1,6 +1,17 @@
import { apiRequest } from 'mastodon/api';
import api, { getLinks } from 'mastodon/api';
import type { NotificationGroupJSON } from 'mastodon/api_types/notifications';
export const apiFetchNotifications = () => {
return apiRequest<NotificationGroupJSON[]>('GET', '/v2_alpha/notifications');
export const apiFetchNotifications = async (
params?: {
exclude_types?: string[];
},
forceUrl?: string,
) => {
const response = await api().request<NotificationGroupJSON[]>({
method: 'GET',
url: forceUrl ?? '/api/v2_alpha/notifications',
params,
});
return { notifications: response.data, links: getLinks(response) };
};

View File

@ -7,6 +7,21 @@ import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
// See app/model/notification.rb
export const allNotificationTypes = [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'status',
'update',
'admin.sign_up',
'admin.report',
'moderation_warning',
'severed_relationships',
];
export type NotificationWithStatusType =
| 'favourite'
| 'reblog'

View File

@ -0,0 +1,145 @@
import type { PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import { setNotificationsFilter } from 'mastodon/actions/notification_groups';
import { Icon } from 'mastodon/components/icon';
import {
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterAdvanced,
} from 'mastodon/selectors/settings';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const tooltips = defineMessages({
mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
favourites: {
id: 'notifications.filter.favourites',
defaultMessage: 'Favorites',
},
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: {
id: 'notifications.filter.statuses',
defaultMessage: 'Updates from people you follow',
},
});
const BarButton: React.FC<
PropsWithChildren<{
selectedFilter: string;
type: string;
title?: string;
}>
> = ({ selectedFilter, type, title, children }) => {
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
void dispatch(setNotificationsFilter({ filterType: type }));
}, [dispatch, type]);
return (
<button
className={selectedFilter === type ? 'active' : ''}
onClick={onClick}
title={title}
>
{children}
</button>
);
};
export const FilterBar: React.FC = () => {
const intl = useIntl();
const selectedFilter = useAppSelector(
selectSettingsNotificationsQuickFilterActive,
);
const advancedMode = useAppSelector(
selectSettingsNotificationsQuickFilterAdvanced,
);
if (advancedMode)
return (
<div className='notification__filter-bar'>
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
<FormattedMessage
id='notifications.filter.all'
defaultMessage='All'
/>
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='mention'
key='mention'
title={intl.formatMessage(tooltips.mentions)}
>
<Icon id='reply-all' icon={ReplyAllIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='favourite'
key='favourite'
title={intl.formatMessage(tooltips.favourites)}
>
<Icon id='star' icon={StarIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='reblog'
key='reblog'
title={intl.formatMessage(tooltips.boosts)}
>
<Icon id='retweet' icon={RepeatIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='poll'
key='poll'
title={intl.formatMessage(tooltips.polls)}
>
<Icon id='tasks' icon={InsertChartIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='status'
key='status'
title={intl.formatMessage(tooltips.statuses)}
>
<Icon id='home' icon={HomeIcon} />
</BarButton>
<BarButton
selectedFilter={selectedFilter}
type='follow'
key='follow'
title={intl.formatMessage(tooltips.follows)}
>
<Icon id='user-plus' icon={PersonAddIcon} />
</BarButton>
</div>
);
else
return (
<div className='notification__filter-bar'>
<BarButton selectedFilter={selectedFilter} type='all' key='all'>
<FormattedMessage
id='notifications.filter.all'
defaultMessage='All'
/>
</BarButton>
<BarButton selectedFilter={selectedFilter} type='mention' key='mention'>
<FormattedMessage
id='notifications.filter.mentions'
defaultMessage='Mentions'
/>
</BarButton>
</div>
);
};

View File

@ -5,8 +5,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { useDebouncedCallback } from 'use-debounce';
@ -17,6 +15,13 @@ import { compareId } from 'mastodon/compare_id';
import { Icon } from 'mastodon/components/icon';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import { useIdentity } from 'mastodon/identity_context';
import {
selectNeedsNotificationPermission,
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsShowUnread,
} from 'mastodon/selectors/settings';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { RootState } from 'mastodon/store';
@ -37,9 +42,9 @@ import ScrollableList from '../../components/scrollable_list';
import { FilteredNotificationsBanner } from '../notifications/components/filtered_notifications_banner';
import NotificationsPermissionBanner from '../notifications/components/notifications_permission_banner';
import ColumnSettingsContainer from '../notifications/containers/column_settings_container';
import FilterBarContainer from '../notifications/containers/filter_bar_container';
import { NotificationGroup } from './components/notification_group';
import { FilterBar } from './filter_bar';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@ -49,46 +54,11 @@ const messages = defineMessages({
},
});
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
const selectSettingsNotificationsShow = (state: RootState) =>
state.settings.getIn(['notifications', 'shows']) as ImmutableMap<
string,
boolean
>;
const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
const selectSettingsNotificationsQuickFilterActive = (state: RootState) =>
state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
const selectSettingsNotificationsShowUnread = (state: RootState) =>
state.settings.getIn(['notifications', 'showUnread']) as boolean;
const selectNeedsNotificationPermission = (state: RootState) =>
(state.settings.getIn(['notifications', 'alerts']).includes(true) &&
state.notifications.get('browserSupport') &&
state.notifications.get('browserPermission') === 'default' &&
!state.settings.getIn([
'notifications',
'dismissPermissionBanner',
])) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
const getExcludedTypes = createSelector(
[selectSettingsNotificationsShow],
(shows) => {
return ImmutableList(shows.filter((item) => !item).keys());
},
);
const getNotifications = createSelector(
[
selectSettingsNotificationsQuickFilterShow,
selectSettingsNotificationsQuickFilterActive,
getExcludedTypes,
selectSettingsNotificationsExcludedTypes,
(state: RootState) => state.notificationsGroups.groups,
],
(showFilterBar, allowedType, excludedTypes, notifications) => {
@ -97,11 +67,11 @@ const getNotifications = createSelector(
// otherwise a list of notifications will come pre-filtered from the backend
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filter(
(item) => item.type !== 'gap' || !excludedTypes.includes(item.type),
(item) => item.type === 'gap' || !excludedTypes.includes(item.type),
);
}
return notifications.filter(
(item) => item.type !== 'gap' || allowedType === item.type,
(item) => item.type === 'gap' || allowedType === item.type,
);
},
);
@ -195,8 +165,9 @@ export const Notifications: React.FC<{
}, [dispatch]);
const handleLoadGap = useCallback(
(maxId: string) => {
dispatch(expandNotifications({ maxId }));
(loadUrl: string) => {
// TODO: this should not be fetch (as this overrides the existing notifications), but expand?
void dispatch(fetchNotifications({ url: loadUrl }));
},
[dispatch],
);
@ -288,7 +259,7 @@ export const Notifications: React.FC<{
const { signedIn } = useIdentity();
const filterBarContainer = signedIn ? <FilterBarContainer /> : null;
const filterBar = signedIn ? <FilterBar /> : null;
const scrollableContent = useMemo(() => {
if (notifications.length === 0 && !hasMore) return null;
@ -296,9 +267,9 @@ export const Notifications: React.FC<{
return notifications.map((item) =>
item.type === 'gap' ? (
<LoadGap
key={item.id}
key={item.loadUrl}
disabled={isLoading}
maxId={item.maxId}
maxId={item.loadUrl}
onClick={handleLoadGap}
/>
) : (
@ -379,7 +350,7 @@ export const Notifications: React.FC<{
<ColumnSettingsContainer />
</ColumnHeader>
{filterBarContainer}
{filterBar}
<FilteredNotificationsBanner />

View File

@ -4,14 +4,13 @@ import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { createNotificationGroupFromJSON } from 'mastodon/models/notification_group';
import type { NotificationGroup } from 'mastodon/models/notification_group';
interface Gap {
export interface NotificationGap {
type: 'gap';
id: string;
maxId: string;
loadUrl: string;
}
interface NotificationGroupsState {
groups: (NotificationGroup | Gap)[];
groups: (NotificationGroup | NotificationGap)[];
unread: number;
isLoading: boolean;
hasMore: boolean;
@ -35,7 +34,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.groups = action.payload.map((json) =>
createNotificationGroupFromJSON(json),
json.type === 'gap' ? json : createNotificationGroupFromJSON(json),
);
state.isLoading = false;
});

View File

@ -0,0 +1,40 @@
import type { RootState } from 'mastodon/store';
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
// state.settings is not yet typed, so we disable some ESLint checks for those selectors
export const selectSettingsNotificationsShows = (state: RootState) =>
state.settings.getIn(['notifications', 'shows']).toJS() as Record<
string,
boolean
>;
export const selectSettingsNotificationsExcludedTypes = (state: RootState) =>
Object.entries(selectSettingsNotificationsShows(state))
.filter(([_type, enabled]) => !enabled)
.map(([type, _enabled]) => type);
export const selectSettingsNotificationsQuickFilterShow = (state: RootState) =>
state.settings.getIn(['notifications', 'quickFilter', 'show']) as boolean;
export const selectSettingsNotificationsQuickFilterActive = (
state: RootState,
) => state.settings.getIn(['notifications', 'quickFilter', 'active']) as string;
export const selectSettingsNotificationsQuickFilterAdvanced = (
state: RootState,
) =>
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
state.settings.getIn(['notifications', 'showUnread']) as boolean;
export const selectNeedsNotificationPermission = (state: RootState) =>
(state.settings.getIn(['notifications', 'alerts']).includes(true) &&
state.notifications.get('browserSupport') &&
state.notifications.get('browserPermission') === 'default' &&
!state.settings.getIn([
'notifications',
'dismissPermissionBanner',
])) as boolean;
/* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */