From d9df25ad388d2ad1d08508d7f6935b568fc8d7f4 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Fri, 14 Jun 2024 11:43:00 +0200 Subject: [PATCH] Handle filters, and add basic pagination management --- .../mastodon/actions/notification_groups.ts | 57 ++++++- app/javascript/mastodon/api/notifications.ts | 17 +- .../mastodon/api_types/notifications.ts | 15 ++ .../features/notifications_v2/filter_bar.tsx | 145 ++++++++++++++++++ .../features/notifications_v2/index.tsx | 65 +++----- .../mastodon/reducers/notifications_groups.ts | 9 +- app/javascript/mastodon/selectors/settings.ts | 40 +++++ 7 files changed, 290 insertions(+), 58 deletions(-) create mode 100644 app/javascript/mastodon/features/notifications_v2/filter_bar.tsx create mode 100644 app/javascript/mastodon/selectors/settings.ts diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index dc01fbedaf..81699f63f9 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -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()); + }, +); diff --git a/app/javascript/mastodon/api/notifications.ts b/app/javascript/mastodon/api/notifications.ts index a32520de79..97f5fd13cf 100644 --- a/app/javascript/mastodon/api/notifications.ts +++ b/app/javascript/mastodon/api/notifications.ts @@ -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('GET', '/v2_alpha/notifications'); +export const apiFetchNotifications = async ( + params?: { + exclude_types?: string[]; + }, + forceUrl?: string, +) => { + const response = await api().request({ + method: 'GET', + url: forceUrl ?? '/api/v2_alpha/notifications', + params, + }); + + return { notifications: response.data, links: getLinks(response) }; }; diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 01ae3c3b1d..8658b1d6dd 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -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' diff --git a/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx b/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx new file mode 100644 index 0000000000..37d2d864bb --- /dev/null +++ b/app/javascript/mastodon/features/notifications_v2/filter_bar.tsx @@ -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 ( + + ); +}; + +export const FilterBar: React.FC = () => { + const intl = useIntl(); + + const selectedFilter = useAppSelector( + selectSettingsNotificationsQuickFilterActive, + ); + const advancedMode = useAppSelector( + selectSettingsNotificationsQuickFilterAdvanced, + ); + + if (advancedMode) + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); + else + return ( +
+ + + + + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index f28d5da1bc..e701d71bc1 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -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 ? : null; + const filterBar = signedIn ? : 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' ? ( ) : ( @@ -379,7 +350,7 @@ export const Notifications: React.FC<{ - {filterBarContainer} + {filterBar} diff --git a/app/javascript/mastodon/reducers/notifications_groups.ts b/app/javascript/mastodon/reducers/notifications_groups.ts index e4cf452d6e..1d6cf54420 100644 --- a/app/javascript/mastodon/reducers/notifications_groups.ts +++ b/app/javascript/mastodon/reducers/notifications_groups.ts @@ -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( builder.addCase(fetchNotifications.fulfilled, (state, action) => { state.groups = action.payload.map((json) => - createNotificationGroupFromJSON(json), + json.type === 'gap' ? json : createNotificationGroupFromJSON(json), ); state.isLoading = false; }); diff --git a/app/javascript/mastodon/selectors/settings.ts b/app/javascript/mastodon/selectors/settings.ts new file mode 100644 index 0000000000..64d9440bc8 --- /dev/null +++ b/app/javascript/mastodon/selectors/settings.ts @@ -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 */