diff --git a/packages/pl-fe/src/actions/filters.ts b/packages/pl-fe/src/actions/filters.ts index ebecf102a..1c416aa31 100644 --- a/packages/pl-fe/src/actions/filters.ts +++ b/packages/pl-fe/src/actions/filters.ts @@ -1,106 +1,7 @@ -import { defineMessages } from 'react-intl'; - -import toast from '@/toast'; -import { isLoggedIn } from '@/utils/auth'; - -import { getClient } from '../api'; - -import type { AppDispatch, RootState } from '@/store'; -import type { Filter, FilterContext } from 'pl-api'; +import type { Filter } from 'pl-api'; const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS' as const; -const messages = defineMessages({ - added: { id: 'filters.added', defaultMessage: 'Filter added.' }, - updated: { id: 'filters.updated', defaultMessage: 'Filter updated.' }, - removed: { id: 'filters.removed', defaultMessage: 'Filter deleted.' }, -}); - -type FilterKeywords = { keyword: string; whole_word: boolean }[]; - -const fetchFilters = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (!isLoggedIn(getState)) return; - - return getClient(getState) - .filtering.getFilters() - .then((data) => - dispatch({ - type: FILTERS_FETCH_SUCCESS, - filters: data, - }), - ) - .catch((error) => ({ - error, - })); -}; - -const fetchFilter = (filterId: string) => (dispatch: AppDispatch, getState: () => RootState) => - getClient(getState).filtering.getFilter(filterId); - -const createFilter = - ( - title: string, - expires_in: number | undefined, - context: Array, - filter_action: Filter['filter_action'], - keywords_attributes: FilterKeywords, - ) => - (dispatch: AppDispatch, getState: () => RootState) => - getClient(getState) - .filtering.createFilter({ - title, - context, - filter_action, - expires_in, - keywords_attributes, - }) - .then((response) => { - toast.success(messages.added); - - return response; - }); - -const updateFilter = - ( - filterId: string, - title: string, - expires_in: number | undefined, - context: Array, - filter_action: Filter['filter_action'], - keywords_attributes: FilterKeywords, - ) => - (dispatch: AppDispatch, getState: () => RootState) => - getClient(getState) - .filtering.updateFilter(filterId, { - title, - context, - filter_action, - expires_in, - keywords_attributes, - }) - .then((response) => { - toast.success(messages.updated); - - return response; - }); - -const deleteFilter = (filterId: string) => (dispatch: AppDispatch, getState: () => RootState) => - getClient(getState) - .filtering.deleteFilter(filterId) - .then((response) => { - toast.success(messages.removed); - - return response; - }); - type FiltersAction = { type: typeof FILTERS_FETCH_SUCCESS; filters: Array }; -export { - FILTERS_FETCH_SUCCESS, - fetchFilters, - fetchFilter, - createFilter, - updateFilter, - deleteFilter, - type FiltersAction, -}; +export { FILTERS_FETCH_SUCCESS, type FiltersAction }; 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 a8d120098..972ea4dbc 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,6 +1,5 @@ import { useCallback } from 'react'; -import { fetchFilters } from '@/actions/filters'; import { MARKER_FETCH_SUCCESS } from '@/actions/markers'; import { updateNotificationsQueue } from '@/actions/notifications'; import { getLocale } from '@/actions/settings'; @@ -134,7 +133,7 @@ const useUserStream = () => { updateConversations(event.payload); break; case 'filters_changed': - dispatch(fetchFilters()); + queryClient.invalidateQueries({ queryKey: ['filters'] }); break; case 'chat_update': dispatch((_dispatch, getState) => { diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index dd352411f..dc49bd4ce 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -4,7 +4,6 @@ import React, { Suspense, useEffect, useRef } from 'react'; import { Toaster } from 'react-hot-toast'; import { fetchConfig } from '@/actions/admin'; -import { fetchFilters } from '@/actions/filters'; import { fetchMarker } from '@/actions/markers'; import { expandNotifications } from '@/actions/notifications'; import { register as registerPushNotifications } from '@/actions/push-notifications/registerer'; @@ -23,6 +22,7 @@ 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 { useFilters } from '@/queries/settings/use-filters'; import { scheduledStatusesQueryOptions } from '@/queries/statuses/scheduled-statuses'; import { useSettings } from '@/stores/settings'; import { useShoutboxSubscription } from '@/stores/shoutbox'; @@ -38,11 +38,11 @@ import { DropdownNavigation, StatusHoverCard, } from './util/async-components'; -import GlobalHotkeys from './util/global-hotkeys'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. import '@/components/status'; +import GlobalHotkeys from './util/global-hotkeys'; const UI: React.FC = React.memo(() => { const navigate = useNavigate(); @@ -60,6 +60,7 @@ const UI: React.FC = React.memo(() => { const standalone = useAppSelector(isStandalone); useShoutboxSubscription(); + useFilters(); const { isDragging } = useDraggedFiles(node); @@ -100,10 +101,6 @@ const UI: React.FC = React.memo(() => { dispatch(fetchConfig()); } - if (features.filters || features.filtersV2) { - setTimeout(() => dispatch(fetchFilters()), 500); - } - if (account.locked) { setTimeout(() => prefetchFollowRequests(client), 700); } diff --git a/packages/pl-fe/src/pages/settings/edit-filter.tsx b/packages/pl-fe/src/pages/settings/edit-filter.tsx index 54c2d2873..e654f7c95 100644 --- a/packages/pl-fe/src/pages/settings/edit-filter.tsx +++ b/packages/pl-fe/src/pages/settings/edit-filter.tsx @@ -3,7 +3,6 @@ import { Filter, type FilterContext } from 'pl-api'; import React, { useEffect, useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { createFilter, fetchFilter, updateFilter } from '@/actions/filters'; import List, { ListItem } from '@/components/list'; import MissingIndicator from '@/components/missing-indicator'; import Button from '@/components/ui/button'; @@ -20,8 +19,8 @@ import Text from '@/components/ui/text'; import Toggle from '@/components/ui/toggle'; import { SelectDropdown } from '@/features/forms'; import { editFilterRoute } from '@/features/ui/router'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useFeatures } from '@/hooks/use-features'; +import { useCreateFilter, useFilter, useUpdateFilter } from '@/queries/settings/use-filters'; import toast from '@/toast'; import type { StreamfieldComponent } from '@/components/ui/streamfield'; @@ -76,7 +75,7 @@ const messages = defineMessages({ }, add_new: { id: 'column.filters.add_new', defaultMessage: 'Add new filter' }, edit: { id: 'column.filters.edit', defaultMessage: 'Edit filter' }, - create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + createError: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, expiration_never: { id: 'column.filters.expiration.never', defaultMessage: 'Never' }, expiration_1800: { id: 'column.filters.expiration.1800', defaultMessage: '30 minutes' }, expiration_3600: { id: 'column.filters.expiration.3600', defaultMessage: '1 hour' }, @@ -123,11 +122,15 @@ const EditFilterPage: React.FC = () => { const intl = useIntl(); const navigate = useNavigate(); - const dispatch = useAppDispatch(); const features = useFeatures(); - const [loading, setLoading] = useState(false); - const [notFound, setNotFound] = useState(false); + const { + data: filter, + isFetching: isFetchingFilter, + isError: notFound, + } = useFilter(filterId !== 'new' ? filterId : undefined); + const { mutate: createFilter, isPending: isCreating } = useCreateFilter(); + const { mutate: updateFilter, isPending: isUpdating } = useUpdateFilter(filterId); const [title, setTitle] = useState(''); const [expiresIn, setExpiresIn] = useState(); @@ -176,17 +179,23 @@ const EditFilterPage: React.FC = () => { context.push('account'); } - dispatch( - filterId !== 'new' - ? updateFilter(filterId, title, expiresIn, context, filterAction, keywords) - : createFilter(title, expiresIn, context, filterAction, keywords), - ) - .then(() => { - navigate({ to: '/filters' }); - }) - .catch(() => { - toast.error(intl.formatMessage(messages.create_error)); - }); + (filterId !== 'new' ? updateFilter : createFilter)( + { + title, + expires_in: expiresIn, + context, + filter_action: filterAction, + keywords_attributes: keywords, + }, + { + onSuccess: () => { + navigate({ to: '/filters' }); + }, + onError: () => { + toast.error(intl.formatMessage(messages.createError)); + }, + }, + ); }; const handleChangeKeyword = (keywords: { keyword: string; whole_word: boolean }[]) => { @@ -206,25 +215,17 @@ const EditFilterPage: React.FC = () => { }; useEffect(() => { - if (filterId !== 'new') { - setLoading(true); - dispatch(fetchFilter(filterId))?.then((filter) => { - if (filter) { - setTitle(filter.title); - setHomeTimeline(filter.context.includes('home')); - setPublicTimeline(filter.context.includes('public')); - setNotifications(filter.context.includes('notifications')); - setConversations(filter.context.includes('thread')); - setAccounts(filter.context.includes('account')); - setFilterAction(filter.filter_action); - setKeywords(filter.keywords); - } else { - setNotFound(true); - } - setLoading(false); - }); + if (filter) { + setTitle(filter.title); + setHomeTimeline(filter.context.includes('home')); + setPublicTimeline(filter.context.includes('public')); + setNotifications(filter.context.includes('notifications')); + setConversations(filter.context.includes('thread')); + setAccounts(filter.context.includes('account')); + setFilterAction(filter.filter_action); + setKeywords(filter.keywords); } - }, [filterId]); + }, [isFetchingFilter]); if (notFound) return ; @@ -363,7 +364,11 @@ const EditFilterPage: React.FC = () => { {features.filtersV2 && keywordsField} - diff --git a/packages/pl-fe/src/pages/settings/filters.tsx b/packages/pl-fe/src/pages/settings/filters.tsx index 264d0e862..5fe517f5e 100644 --- a/packages/pl-fe/src/pages/settings/filters.tsx +++ b/packages/pl-fe/src/pages/settings/filters.tsx @@ -1,7 +1,6 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { fetchFilters, deleteFilter } from '@/actions/filters'; import RelativeTimestamp from '@/components/relative-timestamp'; import ScrollableList from '@/components/scrollable-list'; import Button from '@/components/ui/button'; @@ -9,26 +8,25 @@ import Column from '@/components/ui/column'; import HStack from '@/components/ui/hstack'; import Stack from '@/components/ui/stack'; import Text from '@/components/ui/text'; -import { useAppDispatch } from '@/hooks/use-app-dispatch'; -import { useAppSelector } from '@/hooks/use-app-selector'; import { useFeatures } from '@/hooks/use-features'; +import { useDeleteFilter, useFilters } from '@/queries/settings/use-filters'; import toast from '@/toast'; const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, - home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, - public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, + homeTimeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, + publicTimeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, accounts: { id: 'column.filters.accounts', defaultMessage: 'Accounts' }, - delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, + deleteError: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, edit: { id: 'column.filters.edit', defaultMessage: 'Edit filter' }, delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); const contexts = { - home: messages.home_timeline, - public: messages.public_timeline, + home: messages.homeTimeline, + public: messages.publicTimeline, notifications: messages.notifications, thread: messages.conversations, account: messages.accounts, @@ -36,23 +34,19 @@ const contexts = { const FiltersPage = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); const { filtersV2 } = useFeatures(); - const filters = useAppSelector((state) => state.filters); + const { data: filters = [] } = useFilters(); + const { mutate: deleteFilter } = useDeleteFilter(); const handleFilterDelete = (id: string) => () => { - dispatch(deleteFilter(id)) - .then(() => dispatch(fetchFilters())) - .catch(() => { - toast.error(intl.formatMessage(messages.delete_error)); - }); + deleteFilter(id, { + onError: () => { + toast.error(intl.formatMessage(messages.deleteError)); + }, + }); }; - useEffect(() => { - dispatch(fetchFilters()); - }, []); - const emptyMessage = ( ); diff --git a/packages/pl-fe/src/queries/client.ts b/packages/pl-fe/src/queries/client.ts index 9846dbd32..a3e7a6bc2 100644 --- a/packages/pl-fe/src/queries/client.ts +++ b/packages/pl-fe/src/queries/client.ts @@ -4,6 +4,7 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, + refetchOnReconnect: false, staleTime: 60000, // 1 minute gcTime: Infinity, retry: false, diff --git a/packages/pl-fe/src/queries/settings/use-filters.ts b/packages/pl-fe/src/queries/settings/use-filters.ts new file mode 100644 index 000000000..13de4f84b --- /dev/null +++ b/packages/pl-fe/src/queries/settings/use-filters.ts @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { type FiltersAction, FILTERS_FETCH_SUCCESS } from '@/actions/filters'; +import { useAppDispatch } from '@/hooks/use-app-dispatch'; +import { useClient } from '@/hooks/use-client'; +import { useFeatures } from '@/hooks/use-features'; + +import type { CreateFilterParams, Filter, UpdateFilterParams } from 'pl-api'; + +const useFilters = () => { + const client = useClient(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + + return useQuery({ + queryKey: ['filters'], + queryFn: async () => { + const response = await client.filtering.getFilters(); + + dispatch({ + type: FILTERS_FETCH_SUCCESS, + filters: response, + }); + + return response; + }, + enabled: features.filters || features.filtersV2, + staleTime: 30 * 60 * 1000, + }); +}; + +const useFilter = (filterId?: string) => { + const client = useClient(); + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ['filters', filterId], + queryFn: () => { + if (!filterId) return undefined; + return client.filtering.getFilter(filterId); + }, + enabled: !!filterId, + placeholderData: () => { + queryClient + .getQueryData>(['filters']) + ?.find((filter) => filter.id === filterId); + }, + }); +}; + +const useCreateFilter = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['filters', 'create'], + mutationFn: (data: CreateFilterParams) => client.filtering.createFilter(data), + onSettled: (data) => { + queryClient.invalidateQueries({ queryKey: ['filters'] }); + if (data) queryClient.setQueryData(['filters', data.id], data); + }, + }); +}; + +const useUpdateFilter = (filterId: string) => { + const client = useClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['filters', filterId, 'update'], + mutationFn: (data: UpdateFilterParams) => client.filtering.updateFilter(filterId, data), + onSettled: (data) => { + queryClient.invalidateQueries({ queryKey: ['filters'] }); + if (data) queryClient.setQueryData(['filters', filterId], data); + }, + }); +}; + +const useDeleteFilter = () => { + const client = useClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['filters', 'delete'], + mutationFn: (filterId: string) => client.filtering.deleteFilter(filterId), + onSettled: (_, __, filterId) => { + queryClient.invalidateQueries({ queryKey: ['filters'] }); + queryClient.invalidateQueries({ queryKey: ['filters', filterId] }); + }, + }); +}; + +export { useFilters, useFilter, useCreateFilter, useUpdateFilter, useDeleteFilter }; diff --git a/packages/pl-fe/src/queries/trends.ts b/packages/pl-fe/src/queries/trends.ts index d405fa272..1d1f2bdf9 100644 --- a/packages/pl-fe/src/queries/trends.ts +++ b/packages/pl-fe/src/queries/trends.ts @@ -15,7 +15,7 @@ const useTrends = () => { queryKey: ['trends', 'tags'], queryFn: () => client.trends.getTrendingTags(), placeholderData: [], - staleTime: 600000, // 10 minutes + staleTime: 10 * 60 * 1000, // 10 minutes enabled: isLoggedIn && features.trends, }); };