Merge branch 'improve-people-to-follow' into 'develop'

Improve "People to Follow" widget

See merge request soapbox-pub/soapbox!1800
environments/review-develop-3zknud/deployments/1034
Chewbacca 2 years ago
commit 89057abc7b

@ -277,6 +277,7 @@ module.exports = {
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-undef': 'off', // https://stackoverflow.com/a/69155899
'space-before-function-paren': 'off',
},
parser: '@typescript-eslint/parser',
},

@ -41,8 +41,8 @@ const Widget: React.FC<IWidget> = ({
action,
}): JSX.Element => {
return (
<Stack space={2}>
<HStack alignItems='center'>
<Stack space={4}>
<HStack alignItems='center' justifyContent='between'>
<WidgetTitle title={title} />
{action || (onActionClick && (
<IconButton

@ -11,7 +11,7 @@ import ActionButton from '../ui/components/action-button';
import type { Account } from 'soapbox/types/entities';
const messages = defineMessages({
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'feed_suggestions.heading', defaultMessage: 'Suggested Profiles' },
viewAll: { id: 'feed_suggestions.view_all', defaultMessage: 'View all' },
});
@ -65,7 +65,7 @@ const FeedSuggestions = () => {
if (!isLoading && suggestedProfiles.size === 0) return null;
return (
<Card size='lg' variant='rounded' className='space-y-4'>
<Card size='lg' variant='rounded' className='space-y-6'>
<HStack justifyContent='between' alignItems='center'>
<CardTitle title={intl.formatMessage(messages.heading)} />

@ -10,7 +10,7 @@ import Column from 'soapbox/features/ui/components/column';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
const messages = defineMessages({
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' },
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
});
const FollowRecommendations: React.FC = () => {

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ScrollableList from 'soapbox/components/scrollable_list';
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account_container';
import useOnboardingSuggestions from 'soapbox/queries/suggestions';
import { useOnboardingSuggestions } from 'soapbox/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions();

@ -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 key={idx} 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,123 +1,201 @@
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react';
import { render, screen } from '../../../../jest/test-helpers';
import { normalizeAccount } from '../../../../normalizers';
import { __stub } from 'soapbox/api';
import { render, rootState, screen, waitFor } from '../../../../jest/test-helpers';
import { normalizeInstance } from '../../../../normalizers';
import WhoToFollowPanel from '../who-to-follow-panel';
describe('<WhoToFollow />', () => {
it('renders suggested accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([{
const buildTruthSuggestion = (id: string) => ({
account_avatar: 'avatar',
account_id: id,
acct: 'acct',
display_name: 'my name',
note: 'hello',
verified: true,
});
const buildSuggestion = (id: string) => ({
source: 'staff',
account: '1',
}]),
account: {
username: 'username',
verified: true,
id,
acct: 'acct',
avatar: 'avatar',
avatar_static: 'avatar',
display_name: 'my name',
},
};
});
describe('<WhoToFollow />', () => {
let store: any;
describe('using Truth Social software', () => {
beforeEach(() => {
store = rootState
.set('me', '1234')
.set('instance', normalizeInstance({
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
}));
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
it('renders multiple accounts', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
render(<WhoToFollowPanel limit={3} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
it('respects the limit prop', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([
{
source: 'staff',
account: '1',
},
{
source: 'staff',
account: '2',
},
]),
},
};
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [buildTruthSuggestion('1'), buildTruthSuggestion('2')], {
link: '<https://example.com/api/v1/truth/carousels/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
it('renders empty', () => {
const store = {
accounts: ImmutableMap({
'1': normalizeAccount({
id: '1',
acct: 'username',
display_name: 'My name',
avatar: 'test.jpg',
}),
'2': normalizeAccount({
id: '1',
acct: 'username2',
display_name: 'My other name',
avatar: 'test.jpg',
}),
}),
suggestions: {
items: ImmutableOrderedSet([]),
},
};
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v1/truth/carousels/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
describe('using Pleroma software', () => {
beforeEach(() => {
store = rootState.set('me', '1234');
});
describe('with a single suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.getByTestId('account')).toHaveTextContent(/my name/i);
});
});
});
describe('with a multiple suggestion', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('renders suggested accounts', async () => {
render(<WhoToFollowPanel limit={2} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(2);
});
});
});
describe('with a set limit', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [buildSuggestion('1'), buildSuggestion('2')], {
link: '<https://example.com/api/v2/suggestions?since_id=1>; rel=\'prev\'',
});
});
});
it('respects the limit prop', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(1);
});
});
});
describe('when the API returns an empty list', () => {
beforeEach(() => {
__stub((mock) => {
mock.onGet('/api/v2/suggestions')
.reply(200, [], {
link: '',
});
});
});
it('renders empty', async () => {
render(<WhoToFollowPanel limit={1} />, undefined, store);
await waitFor(() => {
expect(screen.queryAllByTestId('account')).toHaveLength(0);
});
});
});
});
});

@ -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,7 +1,7 @@
import { __stub } from 'soapbox/api';
import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import useOnboardingSuggestions from '../suggestions';
import { useOnboardingSuggestions } from '../suggestions';
describe('useCarouselAvatars', () => {
describe('with a successful query', () => {

@ -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 } from 'soapbox/hooks';
import { PaginatedResult, removePageItem } from '../utils/queries';
type Account = {
acct: string
@ -35,7 +38,120 @@ 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 Result = TruthSuggestion | {
account: string
}
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 api = useApi();
const dispatch = useAppDispatch();
const features = useFeatures();
const getV2Suggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
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.map(x => ({ ...x, account: x.account.id })),
link: nextLink,
hasMore,
};
};
const getTruthSuggestions = async (pageParam: PageParam): Promise<PaginatedResult<Result>> => {
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,
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 +194,5 @@ export default function useOnboardingSuggestions() {
data,
};
}
export { useOnboardingSuggestions, useSuggestions, useDismissSuggestion };

@ -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…
Cancel
Save