From 6ceee73b601c1dde6193a6ffd09cf50b00a1f848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nicole=20miko=C5=82ajczyk?= Date: Mon, 23 Feb 2026 22:51:10 +0100 Subject: [PATCH] nicolium: migrate notifications to tanstack/react-query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: nicole mikołajczyk --- packages/pl-fe/src/actions/notifications.ts | 349 ----------------- .../api/hooks/streaming/use-user-stream.ts | 8 +- packages/pl-fe/src/columns/notifications.tsx | 176 +++++---- packages/pl-fe/src/components/helmet.tsx | 12 +- .../src/components/scroll-top-button.tsx | 2 +- .../src/components/sidebar-navigation.tsx | 4 +- .../pl-fe/src/components/thumb-navigation.tsx | 3 +- .../notifications/components/notification.tsx | 7 +- packages/pl-fe/src/features/ui/index.tsx | 12 +- .../notifications/use-notifications.ts | 356 ++++++++++++++++++ .../make-paginated-response-query-options.ts | 48 ++- .../utils/make-paginated-response-query.ts | 51 ++- .../pl-fe/src/queries/utils/minify-list.ts | 44 ++- packages/pl-fe/src/reducers/index.ts | 2 - packages/pl-fe/src/reducers/notifications.ts | 187 --------- packages/pl-fe/src/schemas/pl-fe/settings.ts | 5 +- 16 files changed, 581 insertions(+), 685 deletions(-) delete mode 100644 packages/pl-fe/src/actions/notifications.ts create mode 100644 packages/pl-fe/src/queries/notifications/use-notifications.ts delete mode 100644 packages/pl-fe/src/reducers/notifications.ts diff --git a/packages/pl-fe/src/actions/notifications.ts b/packages/pl-fe/src/actions/notifications.ts deleted file mode 100644 index 2346b0f76..000000000 --- a/packages/pl-fe/src/actions/notifications.ts +++ /dev/null @@ -1,349 +0,0 @@ -import IntlMessageFormat from 'intl-messageformat'; -import 'intl-pluralrules'; -import { defineMessages } from 'react-intl'; - -import { getClient } from '@/api'; -import { getNotificationStatus } from '@/features/notifications/components/notification'; -import { normalizeNotification } from '@/normalizers/notification'; -import { appendFollowRequest } from '@/queries/accounts/use-follow-requests'; -import { getFilters, regexFromFilters } from '@/selectors'; -import { useSettingsStore } from '@/stores/settings'; -import { isLoggedIn } from '@/utils/auth'; -import { compareId } from '@/utils/comparators'; -import { unescapeHTML } from '@/utils/html'; -import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from '@/utils/notification'; -import { joinPublicPath } from '@/utils/static'; - -import { fetchRelationships } from './accounts'; -import { importEntities } from './importer'; -import { saveMarker } from './markers'; -import { saveSettings } from './settings'; - -import type { AppDispatch, RootState } from '@/store'; -import type { - Notification as BaseNotification, - GetGroupedNotificationsParams, - GroupedNotificationsResults, - NotificationGroup, - PaginatedResponse, -} from 'pl-api'; - -const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE' as const; -const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP' as const; - -const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST' as const; -const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS' as const; -const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL' as const; - -const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET' as const; - -const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP' as const; - -const FILTER_TYPES = { - all: undefined, - mention: ['mention', 'quote'], - favourite: ['favourite', 'emoji_reaction', 'reaction'], - reblog: ['reblog'], - poll: ['poll'], - status: ['status'], - follow: ['follow', 'follow_request'], - events: ['event_reminder', 'participation_request', 'participation_accepted'], -}; - -type FilterType = keyof typeof FILTER_TYPES; - -defineMessages({ - mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, -}); - -const fetchRelatedRelationships = ( - dispatch: AppDispatch, - notifications: Array, -) => { - const accountIds = notifications - .filter((item) => item.type === 'follow') - .map((item) => item.sample_account_ids) - .flat(); - - if (accountIds.length > 0) { - dispatch(fetchRelationships(accountIds)); - } -}; - -interface NotificationsUpdateAction { - type: typeof NOTIFICATIONS_UPDATE; - notification: NotificationGroup; -} - -const updateNotifications = (notification: BaseNotification) => (dispatch: AppDispatch) => { - const selectedFilter = useSettingsStore.getState().settings.notifications.quickFilter.active; - const showInColumn = - selectedFilter === 'all' - ? true - : (FILTER_TYPES[selectedFilter as FilterType] ?? [notification.type]).includes( - notification.type, - ); - - dispatch( - importEntities({ - accounts: [ - notification.account, - notification.type === 'move' ? notification.target : undefined, - ], - statuses: [getNotificationStatus(notification) as any], - }), - ); - - if (showInColumn) { - const normalizedNotification = normalizeNotification(notification); - - if (normalizedNotification.type === 'follow_request') { - normalizedNotification.sample_account_ids.forEach(appendFollowRequest); - } - - dispatch({ - type: NOTIFICATIONS_UPDATE, - notification: normalizedNotification, - }); - - fetchRelatedRelationships(dispatch, [normalizedNotification]); - } -}; - -interface NotificationsUpdateNoopAction { - type: typeof NOTIFICATIONS_UPDATE_NOOP; - meta: { sound: 'boop' }; -} - -const updateNotificationsQueue = - (notification: BaseNotification, intlMessages: Record, intlLocale: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (!notification.type) return; // drop invalid notifications - if (notification.type === 'chat_mention') return; // Drop chat notifications, handle them per-chat - - const filters = getFilters(getState(), { contextType: 'notifications' }); - const playSound = useSettingsStore.getState().settings.notifications.sounds[notification.type]; - - const status = getNotificationStatus(notification); - - let filtered: boolean | null = false; - - if (notification.type === 'mention' || notification.type === 'status') { - const regex = regexFromFilters(filters); - const searchIndex = - notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); - filtered = regex && regex.test(searchIndex); - } - - // Desktop notifications - try { - const isNotificationsEnabled = window.Notification?.permission === 'granted'; - - if (!filtered && isNotificationsEnabled) { - const title = new IntlMessageFormat( - intlMessages[`notification.${notification.type}`], - intlLocale, - ).format({ - name: - notification.account.display_name.length > 0 - ? notification.account.display_name - : notification.account.username, - }) as string; - const body = - status && status.spoiler_text.length > 0 - ? status.spoiler_text - : unescapeHTML(status ? status.content : ''); - - navigator.serviceWorker.ready - .then((serviceWorkerRegistration) => { - serviceWorkerRegistration - .showNotification(title, { - body, - icon: notification.account.avatar, - tag: notification.id, - data: { - url: joinPublicPath('/notifications'), - }, - }) - .catch(console.error); - }) - .catch(console.error); - } - } catch (e) { - console.warn(e); - } - - if (playSound && !filtered) { - dispatch({ - type: NOTIFICATIONS_UPDATE_NOOP, - meta: { sound: 'boop' }, - }); - } - - dispatch(updateNotifications(notification)); - }; - -const excludeTypesFromFilter = (filters: string[]) => - NOTIFICATION_TYPES.filter((item) => !filters.includes(item)); - -const noOp = () => - new Promise((f) => { - f(undefined); - }); - -let abortExpandNotifications = new AbortController(); - -const expandNotifications = - ({ maxId }: Record = {}, done: () => any = noOp, abort?: boolean) => - async (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return dispatch(noOp); - const state = getState(); - - const features = state.auth.client.features; - const activeFilter = useSettingsStore.getState().settings.notifications.quickFilter - .active as FilterType; - const notifications = state.notifications; - - if (notifications.isLoading) { - if (abort) { - abortExpandNotifications.abort(); - abortExpandNotifications = new AbortController(); - } else { - done(); - return dispatch(noOp); - } - } - - const params: GetGroupedNotificationsParams = { - max_id: maxId, - }; - - if (activeFilter === 'all') { - if (features.notificationsIncludeTypes) { - params.types = NOTIFICATION_TYPES.filter((type) => !EXCLUDE_TYPES.includes(type as any)); - } else { - params.exclude_types = [...EXCLUDE_TYPES]; - } - } else { - const filtered = FILTER_TYPES[activeFilter] || [activeFilter]; - if (features.notificationsIncludeTypes) { - params.types = filtered; - } else { - params.exclude_types = excludeTypesFromFilter(filtered); - } - } - - dispatch(expandNotificationsRequest()); - - try { - const { - items: { accounts, statuses, notification_groups }, - next, - } = await getClient(state).groupedNotifications.getGroupedNotifications(params, { - signal: abortExpandNotifications.signal, - }); - - dispatch( - importEntities({ - accounts, - statuses, - }), - ); - - dispatch(expandNotificationsSuccess(notification_groups, next)); - fetchRelatedRelationships(dispatch, notification_groups); - done(); - } catch (error) { - dispatch(expandNotificationsFail(error)); - done(); - } - }; - -const expandNotificationsRequest = () => ({ type: NOTIFICATIONS_EXPAND_REQUEST }); - -const expandNotificationsSuccess = ( - notifications: Array, - next: (() => Promise>) | null, -) => ({ - type: NOTIFICATIONS_EXPAND_SUCCESS, - notifications, - next, -}); - -const expandNotificationsFail = (error: unknown) => ({ - type: NOTIFICATIONS_EXPAND_FAIL, - error, -}); - -interface NotificationsScrollTopAction { - type: typeof NOTIFICATIONS_SCROLL_TOP; - top: boolean; -} - -const scrollTopNotifications = (top: boolean) => (dispatch: AppDispatch) => { - dispatch(markReadNotifications()); - return dispatch({ - type: NOTIFICATIONS_SCROLL_TOP, - top, - }); -}; - -interface SetFilterAction { - type: typeof NOTIFICATIONS_FILTER_SET; -} - -const setFilter = (filterType: FilterType, abort?: boolean) => (dispatch: AppDispatch) => { - const settingsStore = useSettingsStore.getState(); - const activeFilter = settingsStore.settings.notifications.quickFilter.active as FilterType; - - settingsStore.actions.changeSetting(['notifications', 'quickFilter', 'active'], filterType); - - dispatch(expandNotifications(undefined, undefined, abort)); - if (activeFilter !== filterType) dispatch(saveSettings()); - - return dispatch({ type: NOTIFICATIONS_FILTER_SET }); -}; - -const markReadNotifications = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - const state = getState(); - const topNotificationId = state.notifications.items[0]?.page_max_id; - const lastReadId = state.notifications.lastRead; - - if (topNotificationId && (lastReadId === -1 || compareId(topNotificationId, lastReadId) > 0)) { - const marker = { - notifications: { - last_read_id: topNotificationId, - }, - }; - - dispatch(saveMarker(marker)); - } -}; - -type NotificationsAction = - | NotificationsUpdateAction - | NotificationsUpdateNoopAction - | ReturnType - | ReturnType - | ReturnType - | NotificationsScrollTopAction - | SetFilterAction; - -export { - NOTIFICATIONS_UPDATE, - NOTIFICATIONS_EXPAND_REQUEST, - NOTIFICATIONS_EXPAND_SUCCESS, - NOTIFICATIONS_EXPAND_FAIL, - NOTIFICATIONS_FILTER_SET, - NOTIFICATIONS_SCROLL_TOP, - type FilterType, - updateNotifications, - updateNotificationsQueue, - expandNotifications, - scrollTopNotifications, - setFilter, - markReadNotifications, - type NotificationsAction, -}; diff --git a/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts b/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts index 972ea4dbc..7a7d61c39 100644 --- a/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts +++ b/packages/pl-fe/src/api/hooks/streaming/use-user-stream.ts @@ -1,7 +1,5 @@ import { useCallback } from 'react'; -import { MARKER_FETCH_SUCCESS } from '@/actions/markers'; -import { updateNotificationsQueue } from '@/actions/notifications'; import { getLocale } from '@/actions/settings'; import { updateStatus } from '@/actions/statuses'; import { deleteFromTimelines, processTimelineUpdate } from '@/actions/timelines'; @@ -11,6 +9,7 @@ import { useLoggedIn } from '@/hooks/use-logged-in'; import messages from '@/messages'; import { queryClient } from '@/queries/client'; import { updateConversations } from '@/queries/conversations/use-conversations'; +import { useProcessStreamNotification } from '@/queries/notifications/use-notifications'; import { useSettings } from '@/stores/settings'; import { getUnreadChatsCount, updateChatListItem } from '@/utils/chats'; import { play, soundCache } from '@/utils/sounds'; @@ -108,6 +107,7 @@ const useUserStream = () => { const dispatch = useAppDispatch(); const statContext = useStatContext(); const settings = useSettings(); + const processStreamNotification = useProcessStreamNotification(); const listener = useCallback((event: StreamingEvent) => { switch (event.event) { @@ -123,7 +123,7 @@ const useUserStream = () => { case 'notification': messages[getLocale()]() .then((messages) => { - dispatch(updateNotificationsQueue(event.payload, messages, getLocale())); + processStreamNotification(event.payload, messages, getLocale()); }) .catch((error) => { console.error(error); @@ -167,7 +167,7 @@ const useUserStream = () => { deleteAnnouncement(event.payload); break; case 'marker': - dispatch({ type: MARKER_FETCH_SUCCESS, marker: event.payload }); + queryClient.setQueryData(['markers', 'notifications'], event.payload ?? null); break; } }, []); diff --git a/packages/pl-fe/src/columns/notifications.tsx b/packages/pl-fe/src/columns/notifications.tsx index a515f5b4c..6dc593a7b 100644 --- a/packages/pl-fe/src/columns/notifications.tsx +++ b/packages/pl-fe/src/columns/notifications.tsx @@ -1,17 +1,12 @@ +import { InfiniteData, useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import debounce from 'lodash/debounce'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { createSelector } from 'reselect'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import '@/styles/new/notifications.scss'; -import { - type FilterType, - expandNotifications, - markReadNotifications, - scrollTopNotifications, - setFilter, -} from '@/actions/notifications'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { saveSettings } from '@/actions/settings'; import PullToRefresh from '@/components/pull-to-refresh'; import ScrollTopButton from '@/components/scroll-top-button'; import ScrollableList from '@/components/scrollable-list'; @@ -21,13 +16,16 @@ import Tabs from '@/components/ui/tabs'; import Notification from '@/features/notifications/components/notification'; import PlaceholderNotification from '@/features/placeholder/components/placeholder-notification'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useAppSelector } from '@/hooks/use-app-selector'; import { useFeatures } from '@/hooks/use-features'; -import { useSettings } from '@/stores/settings'; +import { + type FilterType, + useMarkNotificationsReadMutation, + useNotifications, +} from '@/queries/notifications/use-notifications'; +import { useSettings, useSettingsStoreActions } from '@/stores/settings'; import { selectChild } from '@/utils/scroll-utils'; import type { Item } from '@/components/ui/tabs'; -import type { RootState } from '@/store'; import type { VirtuosoHandle } from 'react-virtuoso'; const messages = defineMessages({ @@ -58,17 +56,15 @@ const FilterBar = () => { const intl = useIntl(); const dispatch = useAppDispatch(); const settings = useSettings(); + const { changeSetting } = useSettingsStoreActions(); const features = useFeatures(); const selectedFilter = settings.notifications.quickFilter.active; const advancedMode = settings.notifications.quickFilter.advanced; - const onClick = (notificationType: FilterType) => () => { - try { - dispatch(setFilter(notificationType, true)); - } catch (e) { - console.error(e); - } + const onClick = (filterType: FilterType) => () => { + changeSetting(['notifications', 'quickFilter', 'active'], filterType); + dispatch(saveSettings()); }; const items: Item[] = [ @@ -174,21 +170,47 @@ const FilterBar = () => { return ; }; -const getNotifications = createSelector( - [ - (state: RootState) => state.notifications.items, - (_, topNotification?: string) => topNotification, - ], - (notifications, topNotificationId) => { - if (topNotificationId) { - const queuedNotificationCount = notifications.findIndex( - (notification) => notification.most_recent_notification_id <= topNotificationId, +interface INotificationsColumn { + multiColumn?: boolean; +} + +const NotificationsColumn: React.FC = ({ multiColumn }) => { + const features = useFeatures(); + const settings = useSettings(); + const { mutate: markNotificationsRead } = useMarkNotificationsReadMutation(); + const queryClient = useQueryClient(); + + const showFilterBar = + (features.notificationsExcludeTypes || features.notificationsIncludeTypes) && + settings.notifications.quickFilter.show; + const activeFilter = settings.notifications.quickFilter.active; + const { + data: notifications = [], + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + } = useNotifications(activeFilter); + + const [topNotification, setTopNotification] = useState(); + const { queuedNotificationCount, displayedNotifications } = useMemo(() => { + if (topNotification) { + const cutoffIndex = notifications.findIndex( + (notification) => notification.most_recent_notification_id <= topNotification, ); - const displayedNotifications = notifications.slice(queuedNotificationCount); + + if (cutoffIndex === -1) { + return { + queuedNotificationCount: 0, + displayedNotifications: notifications, + }; + } return { - queuedNotificationCount, - displayedNotifications, + queuedNotificationCount: cutoffIndex, + displayedNotifications: notifications.slice(cutoffIndex), }; } @@ -196,67 +218,35 @@ const getNotifications = createSelector( queuedNotificationCount: 0, displayedNotifications: notifications, }; - }, -); - -interface INotificationsColumn { - multiColumn?: boolean; -} - -const NotificationsColumn: React.FC = ({ multiColumn }) => { - const dispatch = useAppDispatch(); - const features = useFeatures(); - const settings = useSettings(); - - const showFilterBar = - (features.notificationsExcludeTypes || features.notificationsIncludeTypes) && - settings.notifications.quickFilter.show; - const activeFilter = settings.notifications.quickFilter.active; - const [topNotification, setTopNotification] = useState(); - const { queuedNotificationCount, displayedNotifications } = useAppSelector((state) => - getNotifications(state, topNotification), - ); - const isLoading = useAppSelector((state) => state.notifications.isLoading); - // const isUnread = useAppSelector(state => state.notifications.unread > 0); - const hasMore = useAppSelector((state) => state.notifications.hasMore); + }, [notifications, topNotification]); + const hasMore = hasNextPage ?? false; const node = useRef(null); const scrollableContentRef = useRef | null>(null); - // const handleLoadGap = (maxId) => { - // dispatch(expandNotifications({ maxId })); - // }; - const handleLoadOlder = useCallback( debounce( () => { - const minId = displayedNotifications.reduce( - (minId, notification) => - minId && notification.page_min_id && notification.page_min_id > minId - ? minId - : notification.page_min_id, - undefined, - ); - dispatch(expandNotifications({ maxId: minId })); + if (!hasMore || isFetchingNextPage) return; + + fetchNextPage().catch((error) => { + console.error(error); + }); }, 300, { leading: true }, ), - [displayedNotifications], + [fetchNextPage, hasMore, isFetchingNextPage], ); const handleScrollToTop = useCallback( debounce(() => { - dispatch(scrollTopNotifications(true)); + const topNotificationId = + displayedNotifications[0]?.page_max_id ?? + displayedNotifications[0]?.most_recent_notification_id; + markNotificationsRead(topNotificationId); }, 100), - [], - ); - - const handleScroll = useCallback( - debounce(() => { - dispatch(scrollTopNotifications(false)); - }, 100), - [], + [fetchNextPage, hasMore, isFetchingNextPage, displayedNotifications], ); const handleMoveUp = (id: string) => { @@ -273,27 +263,36 @@ const NotificationsColumn: React.FC = ({ multiColumn }) => const handleDequeueNotifications = useCallback(() => { setTopNotification(undefined); - dispatch(markReadNotifications()); - }, []); - const handleRefresh = useCallback(() => dispatch(expandNotifications()), []); + markNotificationsRead(notifications[0]?.most_recent_notification_id); + }, [notifications, markNotificationsRead]); + + const handleRefresh = useCallback(() => { + queryClient.setQueryData>(['notifications', activeFilter], (data) => { + if (!data) return data; + + return { + ...data, + pages: data.pages.slice(0, 1), + pageParams: data.pageParams.slice(0, 1), + }; + }); + refetch().catch(console.error); + }, [refetch]); useEffect(() => { handleDequeueNotifications(); - dispatch(scrollTopNotifications(true)); return () => { handleLoadOlder.cancel?.(); - handleScrollToTop.cancel(); - handleScroll.cancel?.(); - dispatch(scrollTopNotifications(false)); + handleScrollToTop.cancel?.(); }; }, []); useEffect(() => { if (topNotification || displayedNotifications.length === 0) return; setTopNotification(displayedNotifications[0].most_recent_notification_id); - }, [displayedNotifications.length]); + }, [displayedNotifications, topNotification]); const emptyMessage = activeFilter === 'all' ? ( @@ -333,18 +332,15 @@ const NotificationsColumn: React.FC = ({ multiColumn }) => {scrollableContent!} diff --git a/packages/pl-fe/src/components/helmet.tsx b/packages/pl-fe/src/components/helmet.tsx index 4728bf4fb..4acb73d1c 100644 --- a/packages/pl-fe/src/components/helmet.tsx +++ b/packages/pl-fe/src/components/helmet.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { Helmet as ReactHelmet } from 'react-helmet-async'; import { useStatContext } from '@/contexts/stat-context'; -import { useAppSelector } from '@/hooks/use-app-selector'; import { useInstance } from '@/hooks/use-instance'; import { usePendingUsersCount } from '@/queries/admin/use-accounts'; import { usePendingReportsCount } from '@/queries/admin/use-reports'; +import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications'; import { useSettings } from '@/stores/settings'; import FaviconService from '@/utils/favicon-service'; @@ -20,13 +20,9 @@ const Helmet: React.FC = ({ children }) => { const { unreadChatsCount } = useStatContext(); const { data: awaitingApprovalCount = 0 } = usePendingUsersCount(); const { data: pendingReportsCount = 0 } = usePendingReportsCount(); - const unreadCount = useAppSelector( - (state) => - (state.notifications.unread || 0) + - unreadChatsCount + - awaitingApprovalCount + - pendingReportsCount, - ); + const notificationCount = useNotificationsUnreadCount(); + const unreadCount = + notificationCount + unreadChatsCount + awaitingApprovalCount + pendingReportsCount; const { demetricator } = useSettings(); const hasUnreadNotifications = React.useMemo( diff --git a/packages/pl-fe/src/components/scroll-top-button.tsx b/packages/pl-fe/src/components/scroll-top-button.tsx index b8d04fd68..98b91963c 100644 --- a/packages/pl-fe/src/components/scroll-top-button.tsx +++ b/packages/pl-fe/src/components/scroll-top-button.tsx @@ -38,7 +38,7 @@ const ScrollTopButton: React.FC = ({ // Whether we are scrolled above the `autoloadThreshold`. const [scrolledTop, setScrolledTop] = useState(false); - const visible = count > 0 && (autoloadThreshold ? scrolled : scrolledTop); + const visible = count > 0 && (!autoloadTimelines || scrolled); const buttonMessage = intl.formatMessage(message, { count }); /** Number of pixels scrolled down from the top of the page. */ diff --git a/packages/pl-fe/src/components/sidebar-navigation.tsx b/packages/pl-fe/src/components/sidebar-navigation.tsx index 3722bba48..4c03f9f46 100644 --- a/packages/pl-fe/src/components/sidebar-navigation.tsx +++ b/packages/pl-fe/src/components/sidebar-navigation.tsx @@ -8,7 +8,6 @@ import Stack from '@/components/ui/stack'; import { useStatContext } from '@/contexts/stat-context'; import ComposeButton from '@/features/ui/components/compose-button'; import ProfileDropdown from '@/features/ui/components/profile-dropdown'; -import { useAppSelector } from '@/hooks/use-app-selector'; import { useFeatures } from '@/hooks/use-features'; import { useInstance } from '@/hooks/use-instance'; import { useOwnAccount } from '@/hooks/use-own-account'; @@ -16,6 +15,7 @@ import { useRegistrationStatus } from '@/hooks/use-registration-status'; import { useFollowRequestsCount } from '@/queries/accounts/use-follow-requests'; import { usePendingUsersCount } from '@/queries/admin/use-accounts'; import { usePendingReportsCount } from '@/queries/admin/use-reports'; +import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications'; import { scheduledStatusesCountQueryOptions } from '@/queries/statuses/scheduled-statuses'; import { useDraftStatusesCountQuery } from '@/queries/statuses/use-draft-statuses'; import { useInteractionRequestsCount } from '@/queries/statuses/use-interaction-requests'; @@ -78,7 +78,7 @@ const SidebarNavigation: React.FC = React.memo(({ shrink }) [!!account, features], ); - const notificationCount = useAppSelector((state) => state.notifications.unread); + const notificationCount = useNotificationsUnreadCount(); const followRequestsCount = useFollowRequestsCount().data ?? 0; const interactionRequestsCount = useInteractionRequestsCount().data ?? 0; const { data: awaitingApprovalCount = 0 } = usePendingUsersCount(); diff --git a/packages/pl-fe/src/components/thumb-navigation.tsx b/packages/pl-fe/src/components/thumb-navigation.tsx index 459531122..b48b19556 100644 --- a/packages/pl-fe/src/components/thumb-navigation.tsx +++ b/packages/pl-fe/src/components/thumb-navigation.tsx @@ -12,6 +12,7 @@ import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; import { useFeatures } from '@/hooks/use-features'; import { useOwnAccount } from '@/hooks/use-own-account'; +import { useNotificationsUnreadCount } from '@/queries/notifications/use-notifications'; import { useModalsActions } from '@/stores/modals'; import { useIsSidebarOpen, useUiStoreActions } from '@/stores/ui'; import { isStandalone } from '@/utils/state'; @@ -43,7 +44,7 @@ const ThumbNavigation: React.FC = React.memo((): JSX.Element => { const { unreadChatsCount } = useStatContext(); const standalone = useAppSelector(isStandalone); - const notificationCount = useAppSelector((state) => state.notifications.unread); + const notificationCount = useNotificationsUnreadCount(); const handleOpenComposeModal = () => { if (match?.params.groupId) { diff --git a/packages/pl-fe/src/features/notifications/components/notification.tsx b/packages/pl-fe/src/features/notifications/components/notification.tsx index 79921f852..8402d1823 100644 --- a/packages/pl-fe/src/features/notifications/components/notification.tsx +++ b/packages/pl-fe/src/features/notifications/components/notification.tsx @@ -580,4 +580,9 @@ const Notification: React.FC = (props) => { ); }; -export { Notification as default, buildLink, getNotificationStatus }; +export { + Notification as default, + buildLink, + getNotificationStatus, + messages as notificationMessages, +}; diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index dc49bd4ce..f32802024 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -4,8 +4,6 @@ import React, { Suspense, useEffect, useRef } from 'react'; import { Toaster } from 'react-hot-toast'; import { fetchConfig } from '@/actions/admin'; -import { fetchMarker } from '@/actions/markers'; -import { expandNotifications } from '@/actions/notifications'; import { register as registerPushNotifications } from '@/actions/push-notifications/registerer'; import { fetchHomeTimeline } from '@/actions/timelines'; import { useUserStream } from '@/api/hooks/streaming/use-user-stream'; @@ -22,6 +20,10 @@ import { useOwnAccount } from '@/hooks/use-own-account'; import { prefetchFollowRequests } from '@/queries/accounts/use-follow-requests'; import { queryClient } from '@/queries/client'; import { prefetchCustomEmojis } from '@/queries/instance/use-custom-emojis'; +import { + usePrefetchNotifications, + usePrefetchNotificationsMarker, +} from '@/queries/notifications/use-notifications'; import { useFilters } from '@/queries/settings/use-filters'; import { scheduledStatusesQueryOptions } from '@/queries/statuses/scheduled-statuses'; import { useSettings } from '@/stores/settings'; @@ -61,6 +63,8 @@ const UI: React.FC = React.memo(() => { useShoutboxSubscription(); useFilters(); + usePrefetchNotifications(); + usePrefetchNotificationsMarker(); const { isDragging } = useDraggedFiles(node); @@ -93,10 +97,6 @@ const UI: React.FC = React.memo(() => { dispatch(fetchHomeTimeline()); - dispatch(expandNotifications()) - .then(() => dispatch(fetchMarker(['notifications']))) - .catch(console.error); - if (account.is_admin && features.pleromaAdminAccounts) { dispatch(fetchConfig()); } diff --git a/packages/pl-fe/src/queries/notifications/use-notifications.ts b/packages/pl-fe/src/queries/notifications/use-notifications.ts new file mode 100644 index 000000000..a71b4d33b --- /dev/null +++ b/packages/pl-fe/src/queries/notifications/use-notifications.ts @@ -0,0 +1,356 @@ +import { + type InfiniteData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import 'intl-pluralrules'; +import { useCallback, useEffect } from 'react'; +import { useIntl } from 'react-intl'; + +import { importEntities } from '@/actions/importer'; +import { + getNotificationStatus, + notificationMessages, +} from '@/features/notifications/components/notification'; +import { useAppDispatch } from '@/hooks/use-app-dispatch'; +import { useAppSelector } from '@/hooks/use-app-selector'; +import { useClient } from '@/hooks/use-client'; +import { useLoggedIn } from '@/hooks/use-logged-in'; +import { normalizeNotification } from '@/normalizers/notification'; +import { appendFollowRequest } from '@/queries/accounts/use-follow-requests'; +import { queryClient } from '@/queries/client'; +import { makePaginatedResponseQueryOptions } from '@/queries/utils/make-paginated-response-query-options'; +import { getFilters, regexFromFilters } from '@/selectors'; +import { useSettingsStore } from '@/stores/settings'; +import { compareId } from '@/utils/comparators'; +import { unescapeHTML } from '@/utils/html'; +import { EXCLUDE_TYPES, NOTIFICATION_TYPES } from '@/utils/notification'; +import { play, soundCache } from '@/utils/sounds'; +import { joinPublicPath } from '@/utils/static'; + +import { minifyGroupedNotifications } from '../utils/minify-list'; + +import type { + GetGroupedNotificationsParams, + Notification, + NotificationGroup, + PaginatedResponse, +} from 'pl-api'; + +const FILTER_TYPES = { + all: undefined, + mention: ['mention', 'quote'], + favourite: ['favourite', 'emoji_reaction', 'reaction'], + reblog: ['reblog'], + poll: ['poll'], + status: ['status'], + follow: ['follow', 'follow_request'], + events: ['event_reminder', 'participation_request', 'participation_accepted'], +} as const; + +type FilterType = keyof typeof FILTER_TYPES; + +const useActiveFilter = () => + useSettingsStore((state) => state.settings.notifications.quickFilter.active); + +const excludeTypesFromFilter = (filters: string[]) => + NOTIFICATION_TYPES.filter((item) => !filters.includes(item)) as string[]; + +const buildNotificationsParams = ( + activeFilter: FilterType, + notificationsIncludeTypes: boolean, +): GetGroupedNotificationsParams => { + const params: GetGroupedNotificationsParams = {}; + + if (activeFilter === 'all') { + if (notificationsIncludeTypes) { + const excludedTypes = new Set(EXCLUDE_TYPES); + params.types = NOTIFICATION_TYPES.filter((type) => !excludedTypes.has(type)); + } else { + params.exclude_types = [...EXCLUDE_TYPES]; + } + + return params; + } + + const filtered = [...(FILTER_TYPES[activeFilter] || [activeFilter])]; + + if (notificationsIncludeTypes) { + params.types = filtered; + } else { + params.exclude_types = excludeTypesFromFilter(filtered); + } + + return params; +}; + +const shouldDisplayNotification = ( + notificationType: Notification['type'], + activeFilter: FilterType, +) => { + if (activeFilter === 'all') return true; + + const allowedTypes = [...(FILTER_TYPES[activeFilter] ?? [notificationType])] as string[]; + + return allowedTypes.includes(notificationType); +}; + +const notificationsQueryOptions = makePaginatedResponseQueryOptions( + (activeFilter: FilterType) => ['notifications', activeFilter], + (client, [activeFilter]) => + client.groupedNotifications + .getGroupedNotifications( + buildNotificationsParams(activeFilter, client.features.notificationsIncludeTypes), + ) + .then(minifyGroupedNotifications), +); + +const useNotifications = (activeFilter: FilterType) => { + const { me } = useLoggedIn(); + + return useInfiniteQuery({ + ...notificationsQueryOptions(activeFilter), + enabled: !!me, + }); +}; + +const useNotificationsMarker = () => { + const client = useClient(); + const { me } = useLoggedIn(); + + return useQuery({ + queryKey: ['markers', 'notifications'], + queryFn: async () => + (await client.timelines.getMarkers(['notifications'])).notifications ?? null, + enabled: !!me, + }); +}; + +const usePrefetchNotificationsMarker = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const { me } = useLoggedIn(); + + useEffect(() => { + if (!me) return; + queryClient.prefetchQuery({ + queryKey: ['markers', 'notifications'], + queryFn: async () => + (await client.timelines.getMarkers(['notifications'])).notifications ?? null, + }); + }, [me]); +}; + +const useProcessStreamNotification = () => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const filters = useAppSelector((state) => getFilters(state, { contextType: 'notifications' })); + const activeFilter = useActiveFilter(); + const { sounds } = useSettingsStore((state) => state.settings.notifications); + + const processStreamNotification = useCallback( + (notification: Notification, intlMessages: Record, intlLocale: string) => { + if (!notification.type) return; + if (notification.type === 'chat_mention') return; + + const playSound = sounds[notification.type]; + const status = getNotificationStatus(notification); + + let filtered: boolean | null = false; + + if (notification.type === 'mention' || notification.type === 'status') { + const regex = regexFromFilters(filters); + const searchIndex = + notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + filtered = regex && regex.test(searchIndex); + } + + try { + const isNotificationsEnabled = window.Notification?.permission === 'granted'; + + if (!filtered && isNotificationsEnabled) { + const targetName = notification.type === 'move' ? notification.target.acct : ''; + const isReblog = status?.reblog_id ? 1 : 0; + + const title = intl.formatMessage(notificationMessages[notification.type], { + name: notification.account.display_name, + targetName, + isReblog, + }); + const body = + status && status.spoiler_text.length > 0 + ? status.spoiler_text + : unescapeHTML(status ? status.content : ''); + + navigator.serviceWorker.ready + .then((serviceWorkerRegistration) => { + serviceWorkerRegistration + .showNotification(title, { + body, + icon: notification.account.avatar, + tag: notification.id, + data: { + url: joinPublicPath('/notifications'), + }, + }) + .catch(console.error); + }) + .catch(console.error); + } + } catch (error) { + console.warn(error); + } + + if (playSound && !filtered) { + play(soundCache.boop); + } + + dispatch( + importEntities({ + accounts: [ + notification.account, + notification.type === 'move' ? notification.target : undefined, + ], + statuses: [status], + }), + ); + + const normalizedNotification = normalizeNotification(notification); + + prependNotification(normalizedNotification, 'all'); + + if (shouldDisplayNotification(notification.type, activeFilter)) { + prependNotification(normalizedNotification, activeFilter); + } + + if (normalizedNotification.type === 'follow_request') { + normalizedNotification.sample_account_ids.forEach(appendFollowRequest); + } + }, + [filters, sounds, activeFilter], + ); + + return processStreamNotification; +}; + +const useMarkNotificationsReadMutation = () => { + const client = useClient(); + + return useMutation({ + mutationKey: ['markers', 'notifications', 'save'], + mutationFn: async (lastReadId?: string | null) => { + if (!lastReadId) return; + + return await client.timelines.saveMarkers({ + notifications: { + last_read_id: lastReadId, + }, + }); + }, + onSuccess: (markers, lastReadId) => { + if (markers?.notifications) { + queryClient.setQueryData(['markers', 'notifications'], markers.notifications); + return; + } + + if (!lastReadId) return; + + queryClient.setQueryData(['markers', 'notifications'], (marker) => { + if (!marker) return undefined; + return { + ...marker, + last_read_id: lastReadId, + }; + }); + }, + }); +}; + +const countUnreadNotifications = ( + notifications: NotificationGroup[], + lastReadId?: string | null, +) => { + if (!lastReadId) return notifications.length; + + return notifications.reduce((count, notification) => { + if (compareId(notification.most_recent_notification_id, lastReadId) > 0) { + return count + 1; + } + + return count; + }, 0); +}; + +const useNotificationsUnreadCount = () => { + const { data: marker } = useNotificationsMarker(); + const { data: notifications = [] } = useNotifications('all'); + + return countUnreadNotifications(notifications, marker?.last_read_id); +}; + +const usePrefetchNotifications = () => { + const queryClient = useQueryClient(); + const { me } = useLoggedIn(); + const activeFilter = useActiveFilter(); + + useEffect(() => { + if (!me) return; + queryClient.prefetchInfiniteQuery(notificationsQueryOptions(activeFilter)); + }, [me]); +}; + +const filterUnique = ( + notification: NotificationGroup, + index: number, + notifications: Array, +) => notifications.findIndex(({ group_key }) => group_key === notification.group_key) === index; + +// For sorting the notifications +const comparator = ( + a: Pick, + b: Pick, +) => { + const length = Math.max( + a.most_recent_notification_id.length, + b.most_recent_notification_id.length, + ); + return b.most_recent_notification_id + .padStart(length, '0') + .localeCompare(a.most_recent_notification_id.padStart(length, '0')); +}; + +const prependNotification = (notification: NotificationGroup, filter: FilterType) => { + queryClient.setQueryData>>( + ['notifications', filter], + (data) => { + if (!data || !data.pages.length) return data; + + const [firstPage, ...restPages] = data.pages; + + return { + ...data, + pages: [ + { + ...firstPage, + items: [notification, ...firstPage.items].toSorted(comparator).filter(filterUnique), + }, + ...restPages, + ], + }; + }, + ); +}; + +export { + FILTER_TYPES, + type FilterType, + useMarkNotificationsReadMutation, + useNotifications, + useNotificationsMarker, + useNotificationsUnreadCount, + usePrefetchNotifications, + usePrefetchNotificationsMarker, + useProcessStreamNotification, +}; diff --git a/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts b/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts index 8730fada6..a3ca72f5a 100644 --- a/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts +++ b/packages/pl-fe/src/queries/utils/make-paginated-response-query-options.ts @@ -2,37 +2,59 @@ import { type InfiniteData, infiniteQueryOptions, type QueryKey } from '@tanstac import { store } from '@/store'; -import { PaginatedResponseArray } from './make-paginated-response-query'; +import { + PaginatedResponseArray, + type PaginatedResponseQueryResult, +} from './make-paginated-response-query'; import type { PaginatedResponse, PlApiClient } from 'pl-api'; const makePaginatedResponseQueryOptions = - , T2, T3 = PaginatedResponseArray>( + < + T1 extends Array, + T2, + IsArray extends boolean = true, + T3 = PaginatedResponseQueryResult, + >( queryKey: QueryKey | ((...params: T1) => QueryKey), - queryFn: (client: PlApiClient, params: T1) => Promise>, - select?: (data: InfiniteData>) => T3, + queryFn: (client: PlApiClient, params: T1) => Promise>, + select?: (data: InfiniteData>) => T3, ) => (...params: T1) => infiniteQueryOptions({ queryKey: typeof queryKey === 'object' ? queryKey : queryKey(...params), queryFn: ({ pageParam }) => pageParam.next?.() ?? queryFn(store.getState().auth.client, params), - initialPageParam: { previous: null, next: null, items: [], partial: false } as Awaited< - ReturnType - >, + initialPageParam: { + previous: null, + next: null, + items: [] as unknown as PaginatedResponse['items'], + partial: false, + } as Awaited>, getNextPageParam: (page) => (page.next ? page : undefined), select: select ?? ((data) => { - const items = new PaginatedResponseArray(...data.pages.map((page) => page.items).flat()); - const lastPage = data.pages.at(-1); - if (lastPage) { - items.total = lastPage.total; - items.partial = lastPage.partial; + + if (!lastPage) { + return new PaginatedResponseArray() as T3; } - return items as T3; + if (Array.isArray(lastPage.items)) { + const items = new PaginatedResponseArray( + ...data.pages.flatMap((page) => + Array.isArray(page.items) ? page.items : [page.items], + ), + ); + + items.total = lastPage.total; + items.partial = lastPage.partial; + + return items as T3; + } + + return lastPage.items as T3; }), }); diff --git a/packages/pl-fe/src/queries/utils/make-paginated-response-query.ts b/packages/pl-fe/src/queries/utils/make-paginated-response-query.ts index ea7703b11..4bc1c3306 100644 --- a/packages/pl-fe/src/queries/utils/make-paginated-response-query.ts +++ b/packages/pl-fe/src/queries/utils/make-paginated-response-query.ts @@ -10,11 +10,22 @@ class PaginatedResponseArray extends Array { partial?: boolean; } +type PaginatedResponseQueryResult = IsArray extends true + ? PaginatedResponseArray + : T extends Array + ? PaginatedResponseArray + : T; + const makePaginatedResponseQuery = - , T2, T3 = PaginatedResponseArray>( + < + T1 extends Array, + T2, + IsArray extends boolean = true, + T3 = PaginatedResponseQueryResult, + >( queryKey: QueryKey | ((...params: T1) => QueryKey), - queryFn: (client: PlApiClient, params: T1) => Promise>, - select?: (data: InfiniteData>) => T3, + queryFn: (client: PlApiClient, params: T1) => Promise>, + select?: (data: InfiniteData>) => T3, enabled?: ((...params: T1) => boolean) | 'isLoggedIn' | 'isAdmin', ) => (...params: T1) => { @@ -24,22 +35,36 @@ const makePaginatedResponseQuery = return useInfiniteQuery({ queryKey: typeof queryKey === 'object' ? queryKey : queryKey(...params), queryFn: ({ pageParam }) => pageParam.next?.() ?? queryFn(client, params), - initialPageParam: { previous: null, next: null, items: [], partial: false } as Awaited< - ReturnType - >, + initialPageParam: { + previous: null, + next: null, + items: [] as unknown as PaginatedResponse['items'], + partial: false, + } as Awaited>, getNextPageParam: (page) => (page.next ? page : undefined), select: select ?? ((data) => { - const items = new PaginatedResponseArray(...data.pages.map((page) => page.items).flat()); - const lastPage = data.pages.at(-1); - if (lastPage) { - items.total = lastPage.total; - items.partial = lastPage.partial; + + if (!lastPage) { + return new PaginatedResponseArray() as T3; } - return items as T3; + if (Array.isArray(lastPage.items)) { + const items = new PaginatedResponseArray( + ...data.pages.flatMap((page) => + Array.isArray(page.items) ? page.items : [page.items], + ), + ); + + items.total = lastPage.total; + items.partial = lastPage.partial; + + return items as T3; + } + + return lastPage.items as T3; }), enabled: enabled === 'isLoggedIn' @@ -50,4 +75,4 @@ const makePaginatedResponseQuery = }); }; -export { makePaginatedResponseQuery, PaginatedResponseArray }; +export { makePaginatedResponseQuery, PaginatedResponseArray, type PaginatedResponseQueryResult }; diff --git a/packages/pl-fe/src/queries/utils/minify-list.ts b/packages/pl-fe/src/queries/utils/minify-list.ts index 3d66a8934..976a2b895 100644 --- a/packages/pl-fe/src/queries/utils/minify-list.ts +++ b/packages/pl-fe/src/queries/utils/minify-list.ts @@ -10,25 +10,35 @@ import type { BlockedAccount, Conversation, Group, + GroupedNotificationsResults, MutedAccount, + NotificationGroup, PaginatedResponse, Status, } from 'pl-api'; -const minifyList = ( - { previous, next, items, ...response }: PaginatedResponse, +const minifyList = ( + { previous, next, items, ...response }: PaginatedResponse, minifier: (value: T1) => T2, - importer?: (items: Array) => void, -): PaginatedResponse => { + importer?: (items: PaginatedResponse['items']) => void, + isArray: IsArray = true as IsArray, +): PaginatedResponse => { importer?.(items); + const minifiedItems = ( + isArray ? (items as T1[]).map(minifier) : minifier(items as T1) + ) as PaginatedResponse['items']; + return { ...response, previous: previous - ? () => previous().then((list) => minifyList(list, minifier, importer)) + ? () => + previous().then((list) => minifyList(list, minifier, importer, isArray)) : null, - next: next ? () => next().then((list) => minifyList(list, minifier, importer)) : null, - items: items.map(minifier), + next: next + ? () => next().then((list) => minifyList(list, minifier, importer, isArray)) + : null, + items: minifiedItems, }; }; @@ -103,6 +113,25 @@ const minifyConversationList = (response: PaginatedResponse) => ); }); +const minifyGroupedNotifications = ( + response: PaginatedResponse, +): PaginatedResponse => + minifyList( + response, + (results) => results.notification_groups, + (results) => { + const { accounts, statuses } = results; + + store.dispatch( + importEntities({ + accounts, + statuses, + }) as any, + ); + }, + false, + ); + const minifyAdminAccount = ({ account, ...adminAccount }: AdminAccount) => { store.dispatch(importEntities({ accounts: [account] }) as any); queryClient.setQueryData(['admin', 'accounts', adminAccount.id], adminAccount); @@ -182,6 +211,7 @@ export { minifyGroupList, minifyConversation, minifyConversationList, + minifyGroupedNotifications, minifyAdminAccount, minifyAdminAccountList, minifyAdminReport, diff --git a/packages/pl-fe/src/reducers/index.ts b/packages/pl-fe/src/reducers/index.ts index 9dd303b14..91aa36555 100644 --- a/packages/pl-fe/src/reducers/index.ts +++ b/packages/pl-fe/src/reducers/index.ts @@ -12,7 +12,6 @@ import frontendConfig from './frontend-config'; import instance from './instance'; import me from './me'; import meta from './meta'; -import notifications from './notifications'; import push_notifications from './push-notifications'; import statuses from './statuses'; import timelines from './timelines'; @@ -27,7 +26,6 @@ const reducers = { instance, me, meta, - notifications, push_notifications, statuses, timelines, diff --git a/packages/pl-fe/src/reducers/notifications.ts b/packages/pl-fe/src/reducers/notifications.ts deleted file mode 100644 index 85a8a17ce..000000000 --- a/packages/pl-fe/src/reducers/notifications.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { create } from 'mutative'; - -import { - ACCOUNT_BLOCK_SUCCESS, - ACCOUNT_MUTE_SUCCESS, - type AccountsAction, -} from '../actions/accounts'; -import { MARKER_FETCH_SUCCESS, MARKER_SAVE_SUCCESS, type MarkersAction } from '../actions/markers'; -import { - NOTIFICATIONS_UPDATE, - NOTIFICATIONS_EXPAND_SUCCESS, - NOTIFICATIONS_EXPAND_REQUEST, - NOTIFICATIONS_EXPAND_FAIL, - NOTIFICATIONS_FILTER_SET, - NOTIFICATIONS_SCROLL_TOP, - type NotificationsAction, -} from '../actions/notifications'; -import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines'; - -import type { - GroupedNotificationsResults, - Markers, - NotificationGroup, - PaginatedResponse, - Relationship, -} from 'pl-api'; - -interface State { - items: Array; - hasMore: boolean; - top: boolean; - unread: number; - isLoading: boolean; - lastRead: string | -1; -} - -const initialState: State = { - items: [], - hasMore: true, - top: false, - unread: 0, - isLoading: false, - lastRead: -1, -}; - -const filterUnique = ( - notification: NotificationGroup, - index: number, - notifications: Array, -) => notifications.findIndex(({ group_key }) => group_key === notification.group_key) === index; - -// For sorting the notifications -const comparator = ( - a: Pick, - b: Pick, -) => { - const length = Math.max( - a.most_recent_notification_id.length, - b.most_recent_notification_id.length, - ); - return b.most_recent_notification_id - .padStart(length, '0') - .localeCompare(a.most_recent_notification_id.padStart(length, '0')); -}; - -// Count how many notifications appear after the given ID (for unread count) -const countFuture = (notifications: Array, lastId: string | number) => - notifications.reduce((acc, notification) => { - const length = Math.max( - notification.most_recent_notification_id.length, - lastId.toString().length, - ); - if ( - notification.most_recent_notification_id - .padStart(length, '0') - .localeCompare(lastId.toString().padStart(length, '0')) === 1 - ) { - return acc + 1; - } else { - return acc; - } - }, 0); - -const importNotification = (state: State, notification: NotificationGroup) => - create(state, (draft) => { - const top = draft.top; - if (!top) draft.unread += 1; - - draft.items = [notification, ...draft.items].toSorted(comparator).filter(filterUnique); - }); - -const expandNormalizedNotifications = ( - state: State, - notifications: NotificationGroup[], - next: (() => Promise>) | null, -) => - create(state, (draft) => { - draft.items = [...notifications, ...draft.items].toSorted(comparator).filter(filterUnique); - - if (!next) draft.hasMore = false; - draft.isLoading = false; - }); - -const filterNotifications = (state: State, relationship: Relationship) => - create(state, (draft) => { - draft.items = draft.items.filter((item) => !item.sample_account_ids.includes(relationship.id)); - }); - -// const filterNotificationIds = (state: State, accountIds: Array, type?: string) => -// create(state, (draft) => { -// const helper = (list: Array) => list.filter(item => !(accountIds.includes(item.sample_account_ids[0]) && (type === undefined || type === item.type))); -// draft.items = helper(draft.items); -// }); - -const updateTop = (state: State, top: boolean) => - create(state, (draft) => { - if (top) draft.unread = 0; - draft.top = top; - }); - -const deleteByStatus = (state: State, statusId: string) => - create(state, (draft) => { - // @ts-ignore - draft.items = draft.items.filterNot((item) => item !== null && item.status_id === statusId); - }); - -const importMarker = (state: State, marker: Markers) => { - const lastReadId = marker.notifications?.last_read_id || (-1 as string | -1); - - if (!lastReadId) { - return state; - } - - return create(state, (draft) => { - const notifications = draft.items; - const unread = countFuture(notifications, lastReadId); - - draft.unread = unread; - draft.lastRead = lastReadId; - }); -}; - -const notifications = ( - state: State = initialState, - action: AccountsAction | MarkersAction | NotificationsAction | TimelineAction, -): State => { - switch (action.type) { - case NOTIFICATIONS_EXPAND_REQUEST: - return create(state, (draft) => { - draft.isLoading = true; - }); - case NOTIFICATIONS_EXPAND_FAIL: - if ((action.error as any)?.message === 'canceled') return state; - return create(state, (draft) => { - draft.isLoading = false; - }); - case NOTIFICATIONS_FILTER_SET: - return create(state, (draft) => { - draft.items = []; - draft.hasMore = true; - }); - case NOTIFICATIONS_SCROLL_TOP: - return updateTop(state, action.top); - case NOTIFICATIONS_UPDATE: - return importNotification(state, action.notification); - case NOTIFICATIONS_EXPAND_SUCCESS: - return expandNormalizedNotifications(state, action.notifications, action.next); - case ACCOUNT_BLOCK_SUCCESS: - return filterNotifications(state, action.relationship); - case ACCOUNT_MUTE_SUCCESS: - return action.relationship.muting_notifications - ? filterNotifications(state, action.relationship) - : state; - // case FOLLOW_REQUEST_AUTHORIZE_SUCCESS: - // case FOLLOW_REQUEST_REJECT_SUCCESS: - // return filterNotificationIds(state, [action.accountId], 'follow_request'); - case MARKER_FETCH_SUCCESS: - case MARKER_SAVE_SUCCESS: - return importMarker(state, action.marker); - case TIMELINE_DELETE: - return deleteByStatus(state, action.statusId); - default: - return state; - } -}; - -export { notifications as default }; diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index 2e7c8122d..b5b029ee9 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -107,7 +107,10 @@ const settingsSchema = v.object({ notifications: coerceObject({ quickFilter: coerceObject({ - active: v.optional(v.string(), 'all'), + active: v.optional( + v.picklist(['all', 'mention', 'favourite', 'reblog', 'poll', 'status', 'follow', 'events']), + 'all', + ), advanced: v.optional(v.boolean(), false), show: v.optional(v.boolean(), true), }),