Use React Query for suggestions
This commit is contained in:
parent
097954d2f1
commit
1d69b66e4b
9 changed files with 241 additions and 39 deletions
|
@ -42,7 +42,7 @@ const Widget: React.FC<IWidget> = ({
|
|||
}): JSX.Element => {
|
||||
return (
|
||||
<Stack space={2}>
|
||||
<HStack alignItems='center'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<WidgetTitle title={title} />
|
||||
{action || (onActionClick && (
|
||||
<IconButton
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
|
||||
import { randomIntFromInterval, generateText } from '../utils';
|
||||
|
||||
export default ({ limit }: { limit: number }) => {
|
||||
const length = randomIntFromInterval(15, 3);
|
||||
const acctLength = randomIntFromInterval(15, 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
{new Array(limit).fill(undefined).map((_, idx) => (
|
||||
<HStack alignItems='center' space={2} className='animate-pulse'>
|
||||
<Stack space={3} className='text-center'>
|
||||
<div
|
||||
className='w-9 h-9 block mx-auto rounded-full bg-primary-200 dark:bg-primary-700'
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack className='text-primary-200 dark:text-primary-700'>
|
||||
<p>{generateText(length)}</p>
|
||||
<p>{generateText(acctLength)}</p>
|
||||
</Stack>
|
||||
</HStack>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchSuggestions, dismissSuggestion } from 'soapbox/actions/suggestions';
|
||||
import { Widget } from 'soapbox/components/ui';
|
||||
import { Text, Widget } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import PlaceholderSidebarSuggestions from 'soapbox/features/placeholder/components/placeholder-sidebar-suggestions';
|
||||
import { useDismissSuggestion, useSuggestions } from 'soapbox/queries/suggestions';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
|
@ -18,35 +18,30 @@ interface IWhoToFollowPanel {
|
|||
}
|
||||
|
||||
const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const { data: suggestions, isFetching } = useSuggestions();
|
||||
const dismissSuggestion = useDismissSuggestion();
|
||||
|
||||
const suggestionsToRender = suggestions.slice(0, limit);
|
||||
|
||||
const handleDismiss = (account: AccountEntity) => {
|
||||
dispatch(dismissSuggestion(account.id));
|
||||
dismissSuggestion.mutate(account.id);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchSuggestions());
|
||||
}, []);
|
||||
|
||||
if (suggestionsToRender.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: This page actually doesn't look good right now
|
||||
// const handleAction = () => {
|
||||
// history.push('/suggestions');
|
||||
// };
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={<FormattedMessage id='who_to_follow.title' defaultMessage='People To Follow' />}
|
||||
// onAction={handleAction}
|
||||
action={
|
||||
<Link to='/suggestions'>
|
||||
<Text tag='span' theme='primary' size='sm' className='hover:underline'>View all</Text>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{suggestionsToRender.map((suggestion) => (
|
||||
{isFetching ? (
|
||||
<PlaceholderSidebarSuggestions limit={limit} />
|
||||
) : (
|
||||
suggestionsToRender.map((suggestion: any) => (
|
||||
<AccountContainer
|
||||
key={suggestion.account}
|
||||
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
|
||||
|
@ -55,7 +50,8 @@ const WhoToFollowPanel = ({ limit }: IWhoToFollowPanel) => {
|
|||
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
|
||||
onActionClick={handleDismiss}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -41,7 +41,7 @@ const DefaultPage: React.FC = ({ children }) => {
|
|||
)}
|
||||
{features.suggestions && (
|
||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||
{Component => <Component limit={5} key='wtf-panel' />}
|
||||
{Component => <Component limit={3} key='wtf-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
|
|
|
@ -105,7 +105,7 @@ const HomePage: React.FC = ({ children }) => {
|
|||
)}
|
||||
{features.suggestions && (
|
||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||
{Component => <Component limit={5} />}
|
||||
{Component => <Component limit={3} />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
|
|
|
@ -139,7 +139,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
|||
</BundleContainer>
|
||||
) : features.suggestions && (
|
||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||
{Component => <Component limit={5} key='wtf-panel' />}
|
||||
{Component => <Component limit={3} key='wtf-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
|
|
|
@ -45,7 +45,7 @@ const StatusPage: React.FC<IStatusPage> = ({ children }) => {
|
|||
)}
|
||||
{features.suggestions && (
|
||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||
{Component => <Component limit={5} key='wtf-panel' />}
|
||||
{Component => <Component limit={3} key='wtf-panel' />}
|
||||
</BundleContainer>
|
||||
)}
|
||||
<LinkFooter key='link-footer' />
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { importFetchedAccounts } from 'soapbox/actions/importer';
|
||||
import { SuggestedProfile } from 'soapbox/actions/suggestions';
|
||||
import { getLinks } from 'soapbox/api';
|
||||
import { useApi, useAppDispatch } from 'soapbox/hooks';
|
||||
import { useApi, useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import { PaginatedResult, removePageItem } from '../utils/queries';
|
||||
|
||||
type Account = {
|
||||
acct: string
|
||||
|
@ -35,7 +38,118 @@ type Suggestion = {
|
|||
account: Account
|
||||
}
|
||||
|
||||
export default function useOnboardingSuggestions() {
|
||||
type TruthSuggestion = {
|
||||
account_avatar: string
|
||||
account_id: string
|
||||
acct: string
|
||||
display_name: string
|
||||
note: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
type PageParam = {
|
||||
link?: string
|
||||
}
|
||||
|
||||
const suggestionKeys = {
|
||||
suggestions: ['suggestions'] as const,
|
||||
};
|
||||
|
||||
const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({
|
||||
id: suggestedProfile.account_id,
|
||||
avatar: suggestedProfile.account_avatar,
|
||||
avatar_static: suggestedProfile.account_avatar,
|
||||
acct: suggestedProfile.acct,
|
||||
display_name: suggestedProfile.display_name,
|
||||
note: suggestedProfile.note,
|
||||
verified: suggestedProfile.verified,
|
||||
});
|
||||
|
||||
const useSuggestions = () => {
|
||||
const account = useOwnAccount();
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const getV2Suggestions = async(pageParam: PageParam): Promise<PaginatedResult<TruthSuggestion | Suggestion>> => {
|
||||
const endpoint = pageParam?.link || '/api/v2/suggestions';
|
||||
const response = await api.get<Suggestion[]>(endpoint);
|
||||
const hasMore = !!response.headers.link;
|
||||
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
||||
|
||||
const accounts = response.data.map(({ account }) => account);
|
||||
const accountIds = accounts.map((account) => account.id);
|
||||
dispatch(importFetchedAccounts(accounts));
|
||||
dispatch(fetchRelationships(accountIds));
|
||||
|
||||
return {
|
||||
result: response.data,
|
||||
link: nextLink,
|
||||
hasMore,
|
||||
};
|
||||
};
|
||||
|
||||
const getTruthSuggestions = async(pageParam: PageParam): Promise<PaginatedResult<TruthSuggestion | Suggestion>> => {
|
||||
const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions';
|
||||
const response = await api.get<TruthSuggestion[]>(endpoint);
|
||||
const hasMore = !!response.headers.link;
|
||||
const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
||||
|
||||
const accounts = response.data.map(mapSuggestedProfileToAccount);
|
||||
dispatch(importFetchedAccounts(accounts, { should_refetch: true }));
|
||||
|
||||
return {
|
||||
result: response.data.map((x) => ({ ...x, account: x.account_id })),
|
||||
link: nextLink,
|
||||
hasMore,
|
||||
};
|
||||
};
|
||||
|
||||
const getSuggestions = (pageParam: PageParam) => {
|
||||
if (features.truthSuggestions) {
|
||||
return getTruthSuggestions(pageParam);
|
||||
} else {
|
||||
return getV2Suggestions(pageParam);
|
||||
}
|
||||
};
|
||||
|
||||
const result = useInfiniteQuery(
|
||||
suggestionKeys.suggestions,
|
||||
({ pageParam }: any) => getSuggestions(pageParam),
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: !!account,
|
||||
getNextPageParam: (config) => {
|
||||
if (config?.hasMore) {
|
||||
return { nextLink: config?.link };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const data: any = result.data?.pages.reduce<Suggestion[]>(
|
||||
(prev: any, curr: any) => [...prev, ...curr.result],
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: data || [],
|
||||
};
|
||||
};
|
||||
|
||||
const useDismissSuggestion = () => {
|
||||
const api = useApi();
|
||||
|
||||
return useMutation((accountId: string) => api.delete(`/api/v1/suggestions/${accountId}`), {
|
||||
onMutate(accountId: string) {
|
||||
removePageItem(suggestionKeys.suggestions, accountId, (o: any, n: any) => o.account_id === n);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function useOnboardingSuggestions() {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
@ -78,3 +192,5 @@ export default function useOnboardingSuggestions() {
|
|||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export { useOnboardingSuggestions as default, useSuggestions, useDismissSuggestion };
|
61
app/soapbox/utils/queries.ts
Normal file
61
app/soapbox/utils/queries.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { queryClient } from 'soapbox/queries/client';
|
||||
|
||||
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
result: T[],
|
||||
hasMore: boolean,
|
||||
link?: string,
|
||||
}
|
||||
|
||||
/** Flatten paginated results into a single array. */
|
||||
const flattenPages = <T>(queryInfo: UseInfiniteQueryResult<PaginatedResult<T>>) => {
|
||||
return queryInfo.data?.pages.reduce<T[]>(
|
||||
(prev: T[], curr) => [...prev, ...curr.result],
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
/** Traverse pages and update the item inside if found. */
|
||||
const updatePageItem = <T>(queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => {
|
||||
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = data.pages.map(page => {
|
||||
const result = page.result.map(item => isItem(item, newItem) ? newItem : item);
|
||||
return { ...page, result };
|
||||
});
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Insert the new item at the beginning of the first page. */
|
||||
const appendPageItem = <T>(queryKey: QueryKey, newItem: T) => {
|
||||
queryClient.setQueryData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = [...data.pages];
|
||||
pages[0] = { ...pages[0], result: [...pages[0].result, newItem] };
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Remove an item inside if found. */
|
||||
const removePageItem = <T>(queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => {
|
||||
queryClient.setQueriesData<InfiniteData<PaginatedResult<T>>>(queryKey, (data) => {
|
||||
if (data) {
|
||||
const pages = data.pages.map(page => {
|
||||
const result = page.result.filter(item => !isItem(item, itemToRemove));
|
||||
return { ...page, result };
|
||||
});
|
||||
return { ...data, pages };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
flattenPages,
|
||||
updatePageItem,
|
||||
appendPageItem,
|
||||
removePageItem,
|
||||
};
|
Loading…
Reference in a new issue