mirror of https://github.com/mastodon/mastodon.git
Handle filters, and add basic pagination management
parent
5ad3e77f85
commit
d9df25ad38
|
@ -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());
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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) };
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 />
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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 */
|
Loading…
Reference in New Issue