diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index 3f8d2011e..2b4c8f4e9 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -1,4 +1,4 @@ -import api from '../api'; +import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatuses } from './importer'; @@ -83,7 +83,9 @@ const submitSearch = (filter?: SearchFilter) => dispatch(importFetchedStatuses(response.data.statuses)); } - dispatch(fetchSearchSuccess(response.data, value, type)); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(fetchSearchSuccess(response.data, value, type, next ? next.uri : null)); dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); @@ -95,11 +97,12 @@ const fetchSearchRequest = (value: string) => ({ value, }); -const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ +const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({ type: SEARCH_FETCH_SUCCESS, results, searchTerm, searchType, + next, }); const fetchSearchFail = (error: AxiosError) => ({ @@ -125,17 +128,26 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: ( dispatch(expandSearchRequest(type)); - const params: Record = { - q: value, - type, - offset, - }; + let url = getState().search.next as string; + let params: Record = {}; - if (accountId) params.account_id = accountId; + // if no URL was extracted from the Link header, + // fall back on querying with the offset + if (!url) { + url = '/api/v2/search'; + params = { + q: value, + type, + offset, + }; + if (accountId) params.account_id = accountId; + } - api(getState).get('/api/v2/search', { + api(getState).get(url, { params, - }).then(({ data }) => { + }).then(response => { + const data = response.data; + if (data.accounts) { dispatch(importFetchedAccounts(data.accounts)); } @@ -144,7 +156,9 @@ const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: ( dispatch(importFetchedStatuses(data.statuses)); } - dispatch(expandSearchSuccess(data, value, type)); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(expandSearchSuccess(data, value, type, next ? next.uri : null)); dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); }).catch(error => { dispatch(expandSearchFail(error)); @@ -156,11 +170,12 @@ const expandSearchRequest = (searchType: SearchFilter) => ({ searchType, }); -const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ +const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter, next: string | null) => ({ type: SEARCH_EXPAND_SUCCESS, results, searchTerm, searchType, + next, }); const expandSearchFail = (error: AxiosError) => ({ diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 1df112dae..7f0d87542 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -221,29 +221,29 @@ const expandHomeTimeline = ({ url, accountId, maxId }: ExpandHomeTimelineOpts = return expandTimeline('home', endpoint, params, done); }; -const expandPublicTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => - expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); +const expandPublicTimeline = ({ url, maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`public${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { max_id: maxId, only_media: !!onlyMedia }, done); -const expandRemoteTimeline = (instance: string, { maxId, onlyMedia }: Record = {}, done = noOp) => - expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); +const expandRemoteTimeline = (instance: string, { url, maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, url || '/api/v1/timelines/public', url ? {} : { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); -const expandCommunityTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => - expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); +const expandCommunityTimeline = ({ url, maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`community${onlyMedia ? ':media' : ''}`, url || '/api/v1/timelines/public', url ? {} : { local: true, max_id: maxId, only_media: !!onlyMedia }, done); -const expandDirectTimeline = ({ maxId }: Record = {}, done = noOp) => - expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); +const expandDirectTimeline = ({ url, maxId }: Record = {}, done = noOp) => + expandTimeline('direct', url || '/api/v1/timelines/direct', url ? {} : { max_id: maxId }, done); -const expandAccountTimeline = (accountId: string, { maxId, withReplies }: Record = {}) => - expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); +const expandAccountTimeline = (accountId: string, { url, maxId, withReplies }: Record = {}) => + expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); const expandAccountFeaturedTimeline = (accountId: string) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); -const expandAccountMediaTimeline = (accountId: string | number, { maxId }: Record = {}) => - expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); +const expandAccountMediaTimeline = (accountId: string | number, { url, maxId }: Record = {}) => + expandTimeline(`account:${accountId}:media`, url || `/api/v1/accounts/${accountId}/statuses`, url ? {} : { max_id: maxId, only_media: true, limit: 40, with_muted: true }); -const expandListTimeline = (id: string, { maxId }: Record = {}, done = noOp) => - expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); +const expandListTimeline = (id: string, { url, maxId }: Record = {}, done = noOp) => + expandTimeline(`list:${id}`, url || `/api/v1/timelines/list/${id}`, url ? {} : { max_id: maxId }, done); const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); @@ -257,8 +257,8 @@ const expandGroupTimelineFromTag = (id: string, tagName: string, { maxId }: Reco const expandGroupMediaTimeline = (id: string | number, { maxId }: Record = {}) => expandTimeline(`group:${id}:media`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); -const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { - return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { +const expandHashtagTimeline = (hashtag: string, { url, maxId, tags }: Record = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, url || `/api/v1/timelines/tag/${hashtag}`, url ? {} : { max_id: maxId, any: parseTags(tags, 'any'), all: parseTags(tags, 'all'), diff --git a/app/soapbox/features/account-gallery/index.tsx b/app/soapbox/features/account-gallery/index.tsx index 7cee5c569..86a832a36 100644 --- a/app/soapbox/features/account-gallery/index.tsx +++ b/app/soapbox/features/account-gallery/index.tsx @@ -64,6 +64,7 @@ const AccountGallery = () => { const attachments: ImmutableList = useAppSelector((state) => getAccountGallery(state, accountId as string)); const isLoading = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.isLoading); const hasMore = useAppSelector((state) => state.timelines.get(`account:${accountId}:media`)?.hasMore); + const next = useAppSelector(state => state.timelines.get(`account:${accountId}:media`)?.next); const node = useRef(null); @@ -75,7 +76,7 @@ const AccountGallery = () => { const handleLoadMore = (maxId: string | null) => { if (accountId && accountId !== -1) { - dispatch(expandAccountMediaTimeline(accountId, { maxId })); + dispatch(expandAccountMediaTimeline(accountId, { url: next, maxId })); } }; diff --git a/app/soapbox/features/account-timeline/index.tsx b/app/soapbox/features/account-timeline/index.tsx index 4f8ccc211..e3f5ca3e7 100644 --- a/app/soapbox/features/account-timeline/index.tsx +++ b/app/soapbox/features/account-timeline/index.tsx @@ -40,6 +40,7 @@ const AccountTimeline: React.FC = ({ params, withReplies = fal const patronEnabled = soapboxConfig.getIn(['extensions', 'patron', 'enabled']) === true; const isLoading = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'isLoading']) === true); const hasMore = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'hasMore']) === true); + const next = useAppSelector(state => state.timelines.get(`account:${path}`)?.next); const accountUsername = account?.username || params.username; @@ -69,7 +70,7 @@ const AccountTimeline: React.FC = ({ params, withReplies = fal const handleLoadMore = (maxId: string) => { if (account) { - dispatch(expandAccountTimeline(account.id, { maxId, withReplies })); + dispatch(expandAccountTimeline(account.id, { url: next, maxId, withReplies })); } }; diff --git a/app/soapbox/features/community-timeline/index.tsx b/app/soapbox/features/community-timeline/index.tsx index 3fca53cc5..387297a80 100644 --- a/app/soapbox/features/community-timeline/index.tsx +++ b/app/soapbox/features/community-timeline/index.tsx @@ -5,7 +5,7 @@ import { connectCommunityStream } from 'soapbox/actions/streaming'; import { expandCommunityTimeline } from 'soapbox/actions/timelines'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { Column } from 'soapbox/components/ui'; -import { useAppDispatch, useSettings } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; @@ -19,11 +19,12 @@ const CommunityTimeline = () => { const settings = useSettings(); const onlyMedia = settings.getIn(['community', 'other', 'onlyMedia']); + const next = useAppSelector(state => state.timelines.get('community')?.next); const timelineId = 'community'; const handleLoadMore = (maxId: string) => { - dispatch(expandCommunityTimeline({ maxId, onlyMedia })); + dispatch(expandCommunityTimeline({ url: next, maxId, onlyMedia })); }; const handleRefresh = () => { diff --git a/app/soapbox/features/direct-timeline/index.tsx b/app/soapbox/features/direct-timeline/index.tsx index aef932516..eee31a829 100644 --- a/app/soapbox/features/direct-timeline/index.tsx +++ b/app/soapbox/features/direct-timeline/index.tsx @@ -6,7 +6,7 @@ import { connectDirectStream } from 'soapbox/actions/streaming'; import { expandDirectTimeline } from 'soapbox/actions/timelines'; import AccountSearch from 'soapbox/components/account-search'; import { Column } from 'soapbox/components/ui'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; @@ -18,6 +18,7 @@ const messages = defineMessages({ const DirectTimeline = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const next = useAppSelector(state => state.timelines.get('direct')?.next); useEffect(() => { dispatch(expandDirectTimeline()); @@ -33,7 +34,7 @@ const DirectTimeline = () => { }; const handleLoadMore = (maxId: string) => { - dispatch(expandDirectTimeline({ maxId })); + dispatch(expandDirectTimeline({ url: next, maxId })); }; return ( diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index e448bef8a..bf906ce01 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -39,6 +39,7 @@ export const HashtagTimeline: React.FC = ({ params }) => { const dispatch = useAppDispatch(); const disconnects = useRef<(() => void)[]>([]); const tag = useAppSelector((state) => state.tags.get(id)); + const next = useAppSelector(state => state.timelines.get(`hashtag:${id}`)?.next); // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 @@ -89,7 +90,7 @@ export const HashtagTimeline: React.FC = ({ params }) => { }; const handleLoadMore = (maxId: string) => { - dispatch(expandHashtagTimeline(id, { maxId, tags })); + dispatch(expandHashtagTimeline(id, { url: next, maxId, tags })); }; const handleFollow = () => { diff --git a/app/soapbox/features/list-timeline/index.tsx b/app/soapbox/features/list-timeline/index.tsx index 9751f2440..f16acf18b 100644 --- a/app/soapbox/features/list-timeline/index.tsx +++ b/app/soapbox/features/list-timeline/index.tsx @@ -17,6 +17,7 @@ const ListTimeline: React.FC = () => { const { id } = useParams<{ id: string }>(); const list = useAppSelector((state) => state.lists.get(id)); + const next = useAppSelector(state => state.timelines.get(`list:${id}`)?.next); useEffect(() => { dispatch(fetchList(id)); @@ -30,7 +31,7 @@ const ListTimeline: React.FC = () => { }, [id]); const handleLoadMore = (maxId: string) => { - dispatch(expandListTimeline(id, { maxId })); + dispatch(expandListTimeline(id, { url: next, maxId })); }; const handleEditClick = () => { diff --git a/app/soapbox/features/public-timeline/index.tsx b/app/soapbox/features/public-timeline/index.tsx index 8f96e432d..cad8cd7f6 100644 --- a/app/soapbox/features/public-timeline/index.tsx +++ b/app/soapbox/features/public-timeline/index.tsx @@ -7,7 +7,7 @@ import { connectPublicStream } from 'soapbox/actions/streaming'; import { expandPublicTimeline } from 'soapbox/actions/timelines'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { Accordion, Column } from 'soapbox/components/ui'; -import { useAppDispatch, useInstance, useSettings } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch, useInstance, useSettings } from 'soapbox/hooks'; import PinnedHostsPicker from '../remote-timeline/components/pinned-hosts-picker'; import Timeline from '../ui/components/timeline'; @@ -24,6 +24,7 @@ const CommunityTimeline = () => { const instance = useInstance(); const settings = useSettings(); const onlyMedia = settings.getIn(['public', 'other', 'onlyMedia']); + const next = useAppSelector(state => state.timelines.get('public')?.next); const timelineId = 'public'; @@ -39,7 +40,7 @@ const CommunityTimeline = () => { }; const handleLoadMore = (maxId: string) => { - dispatch(expandPublicTimeline({ maxId, onlyMedia })); + dispatch(expandPublicTimeline({ url: next, maxId, onlyMedia })); }; const handleRefresh = () => { diff --git a/app/soapbox/features/remote-timeline/index.tsx b/app/soapbox/features/remote-timeline/index.tsx index 3283078af..b0afd38a8 100644 --- a/app/soapbox/features/remote-timeline/index.tsx +++ b/app/soapbox/features/remote-timeline/index.tsx @@ -6,7 +6,7 @@ import { connectRemoteStream } from 'soapbox/actions/streaming'; import { expandRemoteTimeline } from 'soapbox/actions/timelines'; import IconButton from 'soapbox/components/icon-button'; import { Column, HStack, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useSettings } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch, useSettings } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; @@ -30,6 +30,7 @@ const RemoteTimeline: React.FC = ({ params }) => { const timelineId = 'remote'; const onlyMedia = !!settings.getIn(['remote', 'other', 'onlyMedia']); + const next = useAppSelector(state => state.timelines.get('remote')?.next); const pinned: boolean = (settings.getIn(['remote_timeline', 'pinnedHosts']) as any).includes(instance); @@ -44,7 +45,7 @@ const RemoteTimeline: React.FC = ({ params }) => { }; const handleLoadMore = (maxId: string) => { - dispatch(expandRemoteTimeline(instance, { maxId, onlyMedia })); + dispatch(expandRemoteTimeline(instance, { url: next, maxId, onlyMedia })); }; useEffect(() => { diff --git a/app/soapbox/reducers/search.ts b/app/soapbox/reducers/search.ts index abc633533..f4e299290 100644 --- a/app/soapbox/reducers/search.ts +++ b/app/soapbox/reducers/search.ts @@ -47,6 +47,7 @@ const ReducerRecord = ImmutableRecord({ results: ResultsRecord(), filter: 'accounts' as SearchFilter, accountId: null as string | null, + next: null as string | null, }); type State = ReturnType; @@ -57,7 +58,7 @@ const toIds = (items: APIEntities = []) => { return ImmutableOrderedSet(items.map(item => item.id)); }; -const importResults = (state: State, results: APIEntity, searchTerm: string, searchType: SearchFilter) => { +const importResults = (state: State, results: APIEntity, searchTerm: string, searchType: SearchFilter, next: string | null) => { return state.withMutations(state => { if (state.value === searchTerm && state.filter === searchType) { state.set('results', ResultsRecord({ @@ -76,15 +77,17 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea })); state.set('submitted', true); + state.set('next', next); } }); }; -const paginateResults = (state: State, searchType: SearchFilter, results: APIEntity, searchTerm: string) => { +const paginateResults = (state: State, searchType: SearchFilter, results: APIEntity, searchTerm: string, next: string | null) => { return state.withMutations(state => { if (state.value === searchTerm) { state.setIn(['results', `${searchType}HasMore`], results[searchType].length >= 20); state.setIn(['results', `${searchType}Loaded`], true); + state.set('next', next); state.updateIn(['results', searchType], items => { const data = results[searchType]; // Hashtags are a list of maps. Others are IDs. @@ -129,13 +132,13 @@ export default function search(state = ReducerRecord(), action: AnyAction) { case SEARCH_FETCH_REQUEST: return handleSubmitted(state, action.value); case SEARCH_FETCH_SUCCESS: - return importResults(state, action.results, action.searchTerm, action.searchType); + return importResults(state, action.results, action.searchTerm, action.searchType, action.next); case SEARCH_FILTER_SET: return state.set('filter', action.value); case SEARCH_EXPAND_REQUEST: return state.setIn(['results', `${action.searchType}Loaded`], false); case SEARCH_EXPAND_SUCCESS: - return paginateResults(state, action.searchType, action.results, action.searchTerm); + return paginateResults(state, action.searchType, action.results, action.searchTerm, action.next); case SEARCH_ACCOUNT_SET: if (!action.accountId) return state.merge({ results: ResultsRecord(),