diff --git a/packages/pl-fe/src/actions/status-quotes.test.ts b/packages/pl-fe/src/actions/status-quotes.test.ts deleted file mode 100644 index 13a030534..000000000 --- a/packages/pl-fe/src/actions/status-quotes.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { __stub } from 'pl-fe/api'; -import { mockStore, rootState } from 'pl-fe/jest/test-helpers'; -import { StatusListRecord } from 'pl-fe/reducers/status-lists'; - -import { fetchStatusQuotes, expandStatusQuotes } from './status-quotes'; - -const status = { - account: { - id: 'ABDSjI3Q0R8aDaz1U0', - }, - content: 'quoast', - id: 'AJsajx9hY4Q7IKQXEe', - pleroma: { - quote: { - content: '

10

', - id: 'AJmoVikzI3SkyITyim', - }, - }, -}; - -const statusId = 'AJmoVikzI3SkyITyim'; - -describe('fetchStatusQuotes()', () => { - let store: ReturnType; - - beforeEach(() => { - const state = { ...rootState, me: '1234' }; - store = mockStore(state); - }); - - describe('with a successful API request', () => { - beforeEach(async () => { - const quotes = await import('pl-fe/__fixtures__/status-quotes.json'); - - __stub((mock) => { - mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).reply(200, quotes, { - link: `; rel='prev'`, - }); - }); - }); - - it('should fetch quotes from the API', async() => { - const expectedActions = [ - { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId }, - { type: 'POLLS_IMPORT', polls: [] }, - { type: 'ACCOUNTS_IMPORT', accounts: [status.account] }, - { type: 'STATUSES_IMPORT', statuses: [status] }, - { type: 'STATUS_QUOTES_FETCH_SUCCESS', statusId, statuses: [status], next: null }, - ]; - await store.dispatch(fetchStatusQuotes(statusId)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet(`/api/v1/pleroma/statuses/${statusId}/quotes`).networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'STATUS_QUOTES_FETCH_REQUEST', statusId }, - { type: 'STATUS_QUOTES_FETCH_FAIL', statusId, error: new Error('Network Error') }, - ]; - await store.dispatch(fetchStatusQuotes(statusId)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('expandStatusQuotes()', () => { - let store: ReturnType; - - describe('without a url', () => { - beforeEach(() => { - const state = { - ...rootState, - me: '1234', - status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: null }) }), - }; - - store = mockStore(state); - }); - - it('should do nothing', async() => { - await store.dispatch(expandStatusQuotes(statusId)); - const actions = store.getActions(); - - expect(actions).toEqual([]); - }); - }); - - describe('with a url', () => { - beforeEach(() => { - const state = { - ...rootState, - status_lists: ImmutableMap({ [`quotes:${statusId}`]: StatusListRecord({ next: 'example' }) }), - me: '1234', - }; - - store = mockStore(state); - }); - - describe('with a successful API request', () => { - beforeEach(async () => { - const quotes = await import('pl-fe/__fixtures__/status-quotes.json'); - - __stub((mock) => { - mock.onGet('example').reply(200, quotes, { - link: `; rel='prev'`, - }); - }); - }); - - it('should fetch quotes from the API', async() => { - const expectedActions = [ - { type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId }, - { type: 'POLLS_IMPORT', polls: [] }, - { type: 'ACCOUNTS_IMPORT', accounts: [status.account] }, - { type: 'STATUSES_IMPORT', statuses: [status] }, - { type: 'STATUS_QUOTES_EXPAND_SUCCESS', statusId, statuses: [status], next: null }, - ]; - await store.dispatch(expandStatusQuotes(statusId)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - - describe('with an unsuccessful API request', () => { - beforeEach(() => { - __stub((mock) => { - mock.onGet('example').networkError(); - }); - }); - - it('should dispatch failed action', async() => { - const expectedActions = [ - { type: 'STATUS_QUOTES_EXPAND_REQUEST', statusId }, - { type: 'STATUS_QUOTES_EXPAND_FAIL', statusId, error: new Error('Network Error') }, - ]; - await store.dispatch(expandStatusQuotes(statusId)); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); - }); -}); diff --git a/packages/pl-fe/src/actions/status-quotes.ts b/packages/pl-fe/src/actions/status-quotes.ts deleted file mode 100644 index d5f0251b0..000000000 --- a/packages/pl-fe/src/actions/status-quotes.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { getClient } from '../api'; - -import { importEntities } from './importer'; - -import type { Status as BaseStatus, PaginatedResponse } from 'pl-api'; -import type { AppDispatch, RootState } from 'pl-fe/store'; - -const STATUS_QUOTES_FETCH_REQUEST = 'STATUS_QUOTES_FETCH_REQUEST' as const; -const STATUS_QUOTES_FETCH_SUCCESS = 'STATUS_QUOTES_FETCH_SUCCESS' as const; -const STATUS_QUOTES_FETCH_FAIL = 'STATUS_QUOTES_FETCH_FAIL' as const; - -const STATUS_QUOTES_EXPAND_REQUEST = 'STATUS_QUOTES_EXPAND_REQUEST' as const; -const STATUS_QUOTES_EXPAND_SUCCESS = 'STATUS_QUOTES_EXPAND_SUCCESS' as const; -const STATUS_QUOTES_EXPAND_FAIL = 'STATUS_QUOTES_EXPAND_FAIL' as const; - -const noOp = () => new Promise(f => f(null)); - -interface FetchStatusQuotesRequestAction { - type: typeof STATUS_QUOTES_FETCH_REQUEST; - statusId: string; -} - -interface FetchStatusQuotesSuccessAction { - type: typeof STATUS_QUOTES_FETCH_SUCCESS; - statusId: string; - statuses: Array; - next: (() => Promise>) | null; -} - -interface FetchStatusQuotesFailAction { - type: typeof STATUS_QUOTES_FETCH_FAIL; - statusId: string; - error: unknown; -} - -const fetchStatusQuotes = (statusId: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists[`quotes:${statusId}`]?.isLoading) { - return dispatch(noOp); - } - - dispatch({ type: STATUS_QUOTES_FETCH_REQUEST, statusId }); - - return getClient(getState).statuses.getStatusQuotes(statusId).then(response => { - dispatch(importEntities({ statuses: response.items })); - return dispatch({ - type: STATUS_QUOTES_FETCH_SUCCESS, - statusId, - statuses: response.items, - next: response.next, - }); - }).catch(error => { - dispatch({ - type: STATUS_QUOTES_FETCH_FAIL, - statusId, - error, - }); - }); - }; - -interface ExpandStatusQuotesRequestAction { - type: typeof STATUS_QUOTES_EXPAND_REQUEST; - statusId: string; -} - -interface ExpandStatusQuotesSuccessAction { - type: typeof STATUS_QUOTES_EXPAND_SUCCESS; - statusId: string; - statuses: Array; - next: (() => Promise>) | null; -} - -interface ExpandStatusQuotesFailAction { - type: typeof STATUS_QUOTES_EXPAND_FAIL; - statusId: string; - error: unknown; -} - -const expandStatusQuotes = (statusId: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - const next = getState().status_lists[`quotes:${statusId}`]?.next || null; - - if (next === null || getState().status_lists[`quotes:${statusId}`]?.isLoading) { - return dispatch(noOp); - } - - dispatch({ - type: STATUS_QUOTES_EXPAND_REQUEST, - statusId, - }); - - return next().then(response => { - dispatch(importEntities({ statuses: response.items })); - dispatch({ - type: STATUS_QUOTES_EXPAND_SUCCESS, - statusId, - statuses: response.items, - next: response.next, - }); - }).catch(error => { - dispatch({ - type: STATUS_QUOTES_EXPAND_FAIL, - statusId, - error, - }); - }); - }; - -type StatusQuotesAction = - | FetchStatusQuotesRequestAction - | FetchStatusQuotesSuccessAction - | FetchStatusQuotesFailAction - | ExpandStatusQuotesRequestAction - | ExpandStatusQuotesSuccessAction - | ExpandStatusQuotesFailAction; - -export { - STATUS_QUOTES_FETCH_REQUEST, - STATUS_QUOTES_FETCH_SUCCESS, - STATUS_QUOTES_FETCH_FAIL, - STATUS_QUOTES_EXPAND_REQUEST, - STATUS_QUOTES_EXPAND_SUCCESS, - STATUS_QUOTES_EXPAND_FAIL, - fetchStatusQuotes, - expandStatusQuotes, - type StatusQuotesAction, -}; diff --git a/packages/pl-fe/src/api/hooks/statuses/use-status-quotes.ts b/packages/pl-fe/src/api/hooks/statuses/use-status-quotes.ts new file mode 100644 index 000000000..0fc0d9edd --- /dev/null +++ b/packages/pl-fe/src/api/hooks/statuses/use-status-quotes.ts @@ -0,0 +1,20 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { minifyStatusList } from 'pl-fe/api/normalizers/status-list'; +import { useClient } from 'pl-fe/hooks/use-client'; + +import type { PaginatedResponse } from 'pl-api'; + +const useStatusQuotes = (statusId: string) => { + const client = useClient(); + + return useInfiniteQuery({ + queryKey: ['statusLists', 'quotes', statusId], + queryFn: ({ pageParam }) => pageParam.next?.() || client.statuses.getStatusQuotes(statusId).then(minifyStatusList), + initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse, + getNextPageParam: (page) => page.next ? page : undefined, + select: (data) => data.pages.map(page => page.items).flat(), + }); +}; + +export { useStatusQuotes }; diff --git a/packages/pl-fe/src/api/normalizers/status-list.ts b/packages/pl-fe/src/api/normalizers/status-list.ts new file mode 100644 index 000000000..9e8333462 --- /dev/null +++ b/packages/pl-fe/src/api/normalizers/status-list.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse, Status } from 'pl-api'; + +import { importEntities } from 'pl-fe/actions/importer'; +import { store } from 'pl-fe/store'; + +const minifyStatusList = ({ previous, next, items, ...response }: PaginatedResponse): PaginatedResponse => { + store.dispatch(importEntities({ statuses: items }) as any); + + return { + ...response, + previous: previous ? () => previous().then(minifyStatusList) : null, + next: next ? () => next().then(minifyStatusList) : null, + items: items.map(status => status.id), + }; +}; + +export { minifyStatusList }; diff --git a/packages/pl-fe/src/features/quotes/index.tsx b/packages/pl-fe/src/features/quotes/index.tsx index ecfdef70e..8560c31d9 100644 --- a/packages/pl-fe/src/features/quotes/index.tsx +++ b/packages/pl-fe/src/features/quotes/index.tsx @@ -1,13 +1,10 @@ -import debounce from 'lodash/debounce'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useParams } from 'react-router-dom'; -import { expandStatusQuotes, fetchStatusQuotes } from 'pl-fe/actions/status-quotes'; +import { useStatusQuotes } from 'pl-fe/api/hooks/statuses/use-status-quotes'; import StatusList from 'pl-fe/components/status-list'; import Column from 'pl-fe/components/ui/column'; -import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; -import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useIsMobile } from 'pl-fe/hooks/use-is-mobile'; import { useTheme } from 'pl-fe/hooks/use-theme'; @@ -15,23 +12,13 @@ const messages = defineMessages({ heading: { id: 'column.quotes', defaultMessage: 'Post quotes' }, }); -const handleLoadMore = debounce((statusId: string, dispatch: React.Dispatch) => - dispatch(expandStatusQuotes(statusId)), 300, { leading: true }); - const Quotes: React.FC = () => { - const dispatch = useAppDispatch(); const intl = useIntl(); const { statusId } = useParams<{ statusId: string }>(); const theme = useTheme(); const isMobile = useIsMobile(); - const statusIds = useAppSelector((state) => state.status_lists[`quotes:${statusId}`]?.items || []); - const isLoading = useAppSelector((state) => state.status_lists[`quotes:${statusId}`]?.isLoading !== false); - const hasMore = useAppSelector((state) => !!state.status_lists[`quotes:${statusId}`]?.next); - - React.useEffect(() => { - dispatch(fetchStatusQuotes(statusId)); - }, [statusId]); + const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId); const emptyMessage = ; @@ -42,9 +29,9 @@ const Quotes: React.FC = () => { loadMoreClassName='black:sm:mx-4' statusIds={statusIds} scrollKey={`quotes:${statusId}`} - hasMore={hasMore} + hasMore={hasNextPage} isLoading={typeof isLoading === 'boolean' ? isLoading : true} - onLoadMore={() => handleLoadMore(statusId, dispatch)} + onLoadMore={() => fetchNextPage({ cancelRefetch: false })} emptyMessage={emptyMessage} divideType={(theme === 'black' || isMobile) ? 'border' : 'space'} /> diff --git a/packages/pl-fe/src/reducers/status-lists.ts b/packages/pl-fe/src/reducers/status-lists.ts index 0bf038b6f..bdf663edc 100644 --- a/packages/pl-fe/src/reducers/status-lists.ts +++ b/packages/pl-fe/src/reducers/status-lists.ts @@ -54,15 +54,6 @@ import { SCHEDULED_STATUS_CANCEL_SUCCESS, type ScheduledStatusesAction, } from 'pl-fe/actions/scheduled-statuses'; -import { - STATUS_QUOTES_EXPAND_FAIL, - STATUS_QUOTES_EXPAND_REQUEST, - STATUS_QUOTES_EXPAND_SUCCESS, - STATUS_QUOTES_FETCH_FAIL, - STATUS_QUOTES_FETCH_REQUEST, - STATUS_QUOTES_FETCH_SUCCESS, - type StatusQuotesAction, -} from 'pl-fe/actions/status-quotes'; import { STATUS_CREATE_SUCCESS, type StatusesAction } from 'pl-fe/actions/statuses'; import type { PaginatedResponse, ScheduledStatus, Status } from 'pl-api'; @@ -155,7 +146,7 @@ const removeBookmarkFromLists = (state: State, status: Pick { +const statusLists = (state = initialState, action: BookmarksAction | EventsAction | FavouritesAction | InteractionsAction | PinStatusesAction | ScheduledStatusesAction | StatusesAction): State => { switch (action.type) { case FAVOURITED_STATUSES_FETCH_REQUEST: case FAVOURITED_STATUSES_EXPAND_REQUEST: @@ -214,16 +205,6 @@ const statusLists = (state = initialState, action: BookmarksAction | EventsActio case SCHEDULED_STATUS_CANCEL_REQUEST: case SCHEDULED_STATUS_CANCEL_SUCCESS: return create(state, draft => removeOneFromList(draft, 'scheduled_statuses', action.statusId)); - case STATUS_QUOTES_FETCH_REQUEST: - case STATUS_QUOTES_EXPAND_REQUEST: - return create(state, draft => setLoading(draft, `quotes:${action.statusId}`, true)); - case STATUS_QUOTES_FETCH_FAIL: - case STATUS_QUOTES_EXPAND_FAIL: - return create(state, draft => setLoading(draft, `quotes:${action.statusId}`, false)); - case STATUS_QUOTES_FETCH_SUCCESS: - return create(state, draft => normalizeList(draft, `quotes:${action.statusId}`, action.statuses, action.next)); - case STATUS_QUOTES_EXPAND_SUCCESS: - return create(state, draft => appendToList(draft, `quotes:${action.statusId}`, action.statuses, action.next)); case RECENT_EVENTS_FETCH_REQUEST: return create(state, draft => setLoading(draft, 'recent_events', true)); case RECENT_EVENTS_FETCH_FAIL: diff --git a/packages/pl-hooks/lib/hooks/statuses/use-status-quotes.ts b/packages/pl-hooks/lib/hooks/statuses/use-status-quotes.ts index 972a4e3df..1bf126e53 100644 --- a/packages/pl-hooks/lib/hooks/statuses/use-status-quotes.ts +++ b/packages/pl-hooks/lib/hooks/statuses/use-status-quotes.ts @@ -11,7 +11,7 @@ const useStatusQuotes = (statusId: string) => { const { client } = usePlHooksApiClient(); return useInfiniteQuery({ - queryKey: ['statusesLists', 'quotes', statusId], + queryKey: ['statusLists', 'quotes', statusId], queryFn: ({ pageParam }) => pageParam.next?.() || client.statuses.getStatusQuotes(statusId).then(minifyStatusList), initialPageParam: { previous: null, next: null, items: [], partial: false } as PaginatedResponse, getNextPageParam: (page) => page.next ? page : undefined,