Use React Query for suggestions

This commit is contained in:
Justin 2022-09-26 15:22:00 -04:00
parent 097954d2f1
commit 1d69b66e4b
9 changed files with 241 additions and 39 deletions

View file

@ -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

View file

@ -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>
))}
</>
);
};

View file

@ -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,44 +18,40 @@ 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) => (
<AccountContainer
key={suggestion.account}
// @ts-ignore: TS thinks `id` is passed to <Account>, but it isn't
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))}
{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
id={suggestion.account}
actionIcon={require('@tabler/icons/x.svg')}
actionTitle={intl.formatMessage(messages.dismissSuggestion)}
onActionClick={handleDismiss}
/>
))
)}
</Widget>
);
};

View file

@ -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' />

View file

@ -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' />

View file

@ -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' />

View file

@ -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' />

View file

@ -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 };

View 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,
};