diff --git a/app/soapbox/components/ui/widget/widget.tsx b/app/soapbox/components/ui/widget/widget.tsx index 7b966a6ef..c4be654b3 100644 --- a/app/soapbox/components/ui/widget/widget.tsx +++ b/app/soapbox/components/ui/widget/widget.tsx @@ -42,7 +42,7 @@ const Widget: React.FC = ({ }): JSX.Element => { return ( - + {action || (onActionClick && ( { + const length = randomIntFromInterval(15, 3); + const acctLength = randomIntFromInterval(15, 3); + + return ( + <> + {new Array(limit).fill(undefined).map((_, idx) => ( + + +
+ + + +

{generateText(length)}

+

{generateText(acctLength)}

+
+ + ))} + + ); +}; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/who-to-follow-panel.tsx b/app/soapbox/features/ui/components/who-to-follow-panel.tsx index 2458997f4..418d2143c 100644 --- a/app/soapbox/features/ui/components/who-to-follow-panel.tsx +++ b/app/soapbox/features/ui/components/who-to-follow-panel.tsx @@ -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 ( } - // onAction={handleAction} + action={ + + View all + + } > - {suggestionsToRender.map((suggestion) => ( - , but it isn't - id={suggestion.account} - actionIcon={require('@tabler/icons/x.svg')} - actionTitle={intl.formatMessage(messages.dismissSuggestion)} - onActionClick={handleDismiss} - /> - ))} + {isFetching ? ( + + ) : ( + suggestionsToRender.map((suggestion: any) => ( + , but it isn't + id={suggestion.account} + actionIcon={require('@tabler/icons/x.svg')} + actionTitle={intl.formatMessage(messages.dismissSuggestion)} + onActionClick={handleDismiss} + /> + )) + )} ); }; diff --git a/app/soapbox/pages/default_page.tsx b/app/soapbox/pages/default_page.tsx index b64cf452d..c013eb63d 100644 --- a/app/soapbox/pages/default_page.tsx +++ b/app/soapbox/pages/default_page.tsx @@ -41,7 +41,7 @@ const DefaultPage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx index 23bd3e3c7..dc8c2c9f4 100644 --- a/app/soapbox/pages/home_page.tsx +++ b/app/soapbox/pages/home_page.tsx @@ -105,7 +105,7 @@ const HomePage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/profile_page.tsx b/app/soapbox/pages/profile_page.tsx index f4ff974e0..9f57e23ab 100644 --- a/app/soapbox/pages/profile_page.tsx +++ b/app/soapbox/pages/profile_page.tsx @@ -139,7 +139,7 @@ const ProfilePage: React.FC = ({ params, children }) => { ) : features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/pages/status_page.tsx b/app/soapbox/pages/status_page.tsx index 2c35947ad..414df6783 100644 --- a/app/soapbox/pages/status_page.tsx +++ b/app/soapbox/pages/status_page.tsx @@ -45,7 +45,7 @@ const StatusPage: React.FC = ({ children }) => { )} {features.suggestions && ( - {Component => } + {Component => } )} diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts index 433a0c940..8c9e2ec20 100644 --- a/app/soapbox/queries/suggestions.ts +++ b/app/soapbox/queries/suggestions.ts @@ -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> => { + const endpoint = pageParam?.link || '/api/v2/suggestions'; + const response = await api.get(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> => { + const endpoint = pageParam?.link || '/api/v1/truth/carousels/suggestions'; + const response = await api.get(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( + (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 }; \ No newline at end of file diff --git a/app/soapbox/utils/queries.ts b/app/soapbox/utils/queries.ts new file mode 100644 index 000000000..d066f0855 --- /dev/null +++ b/app/soapbox/utils/queries.ts @@ -0,0 +1,61 @@ +import { queryClient } from 'soapbox/queries/client'; + +import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query'; + +export interface PaginatedResult { + result: T[], + hasMore: boolean, + link?: string, +} + +/** Flatten paginated results into a single array. */ +const flattenPages = (queryInfo: UseInfiniteQueryResult>) => { + return queryInfo.data?.pages.reduce( + (prev: T[], curr) => [...prev, ...curr.result], + [], + ); +}; + +/** Traverse pages and update the item inside if found. */ +const updatePageItem = (queryKey: QueryKey, newItem: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(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 = (queryKey: QueryKey, newItem: T) => { + queryClient.setQueryData>>(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 = (queryKey: QueryKey, itemToRemove: T, isItem: (item: T, newItem: T) => boolean) => { + queryClient.setQueriesData>>(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, +};