Merge branch 'carousel-v2' into 'develop'

Support 'seen' property in the Feed Carousel

See merge request soapbox-pub/soapbox!1978
environments/review-develop-3zknud/deployments/1721
Chewbacca 2 years ago
commit fa4bd20d11

@ -61,11 +61,15 @@ describe('<FeedCarousel />', () => {
__stub((mock) => { __stub((mock) => {
mock.onGet('/api/v1/truth/carousels/avatars') mock.onGet('/api/v1/truth/carousels/avatars')
.reply(200, [ .reply(200, [
{ account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg', seen: false },
{ account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' }, { account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg', seen: false },
{ account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' }, { account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg', seen: false },
{ account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg' }, { account_id: '4', acct: 'd', account_avatar: 'https://example.com/some.jpg', seen: false },
]); ]);
mock.onGet('/api/v1/accounts/1/statuses').reply(200, [], {
link: '<https://example.com/api/v1/accounts/1/statuses?since_id=1>; rel=\'prev\'',
});
}); });
}); });
@ -74,6 +78,29 @@ describe('<FeedCarousel />', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
expect(screen.queryAllByTestId('carousel-item')).toHaveLength(4);
});
});
it('should handle the "seen" state', async() => {
render(<FeedCarousel />, undefined, store);
// Unseen
await waitFor(() => {
expect(screen.queryAllByTestId('carousel-item')).toHaveLength(4);
});
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-accent-500');
// Selected
await userEvent.click(screen.getAllByTestId('carousel-item-avatar')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-primary-600');
});
// Marked as seen, not selected
await userEvent.click(screen.getAllByTestId('carousel-item-avatar')[0]);
await waitFor(() => {
expect(screen.getAllByTestId('carousel-item-avatar')[0]).toHaveClass('ring-transparent');
}); });
}); });
}); });

@ -4,15 +4,17 @@ import { FormattedMessage } from 'react-intl';
import { replaceHomeTimeline } from 'soapbox/actions/timelines'; import { replaceHomeTimeline } from 'soapbox/actions/timelines';
import { useAppDispatch, useAppSelector, useDimensions } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useDimensions } from 'soapbox/hooks';
import useCarouselAvatars from 'soapbox/queries/carousels'; import { Avatar, useCarouselAvatars, useMarkAsSeen } from 'soapbox/queries/carousels';
import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; import { Card, HStack, Icon, Stack, Text } from '../../components/ui';
import PlaceholderAvatar from '../placeholder/components/placeholder-avatar'; import PlaceholderAvatar from '../placeholder/components/placeholder-avatar';
const CarouselItem = ({ avatar }: { avatar: any }) => { const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const selectedAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId); const markAsSeen = useMarkAsSeen();
const selectedAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId']) as string);
const isSelected = avatar.account_id === selectedAccountId; const isSelected = avatar.account_id === selectedAccountId;
const [isFetching, setLoading] = useState<boolean>(false); const [isFetching, setLoading] = useState<boolean>(false);
@ -27,17 +29,25 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
if (isSelected) { if (isSelected) {
dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false))); dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
} else { } else {
onViewed(avatar.account_id);
markAsSeen.mutate(avatar.account_id);
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false))); dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
} }
}; };
return ( return (
<div aria-disabled={isFetching} onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'> <div
aria-disabled={isFetching}
onClick={handleClick}
className='cursor-pointer'
role='filter-feed-by-user'
data-testid='carousel-item'
>
<Stack className='w-16 h-auto' space={3}> <Stack className='w-16 h-auto' space={3}>
<div className='block mx-auto relative w-14 h-14 rounded-full'> <div className='block mx-auto relative w-14 h-14 rounded-full'>
{isSelected && ( {isSelected && (
<div className='absolute inset-0 bg-primary-600 bg-opacity-50 rounded-full flex items-center justify-center'> <div className='absolute inset-0 bg-primary-600 bg-opacity-50 rounded-full flex items-center justify-center'>
<Icon src={require('@tabler/icons/x.svg')} className='text-white h-6 w-6' /> <Icon src={require('@tabler/icons/check.svg')} className='text-white h-6 w-6' />
</div> </div>
)} )}
@ -45,10 +55,12 @@ const CarouselItem = ({ avatar }: { avatar: any }) => {
src={avatar.account_avatar} src={avatar.account_avatar}
className={classNames({ className={classNames({
'w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true, 'w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true,
'ring-transparent': !isSelected, 'ring-transparent': !isSelected && seen,
'ring-primary-600': isSelected, 'ring-primary-600': isSelected,
'ring-accent-500': !seen && !isSelected,
})} })}
alt={avatar.acct} alt={avatar.acct}
data-testid='carousel-item-avatar'
/> />
</div> </div>
@ -63,6 +75,7 @@ const FeedCarousel = () => {
const [cardRef, setCardRef, { width }] = useDimensions(); const [cardRef, setCardRef, { width }] = useDimensions();
const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]);
const [pageSize, setPageSize] = useState<number>(0); const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
@ -75,6 +88,20 @@ const FeedCarousel = () => {
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1); const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1); const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
const markAsSeen = (account_id: string) => {
setSeenAccountIds((prev) => [...prev, account_id]);
};
useEffect(() => {
if (avatars.length > 0) {
setSeenAccountIds(
avatars
.filter((avatar) => avatar.seen)
.map((avatar) => avatar.account_id),
);
}
}, [avatars]);
useEffect(() => { useEffect(() => {
if (width) { if (width) {
setPageSize(Math.round(width / widthPerAvatar)); setPageSize(Math.round(width / widthPerAvatar));
@ -130,6 +157,8 @@ const FeedCarousel = () => {
<CarouselItem <CarouselItem
key={avatar.account_id} key={avatar.account_id}
avatar={avatar} avatar={avatar}
seen={seenAccountIds?.includes(avatar.account_id)}
onViewed={markAsSeen}
/> />
)) ))
)} )}

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

@ -3,7 +3,7 @@ import { renderHook, waitFor } from 'soapbox/jest/test-helpers';
import { useOnboardingSuggestions } from '../suggestions'; import { useOnboardingSuggestions } from '../suggestions';
describe('useCarouselAvatars', () => { describe('useOnboardingSuggestions', () => {
describe('with a successful query', () => { describe('with a successful query', () => {
beforeEach(() => { beforeEach(() => {
__stub((mock) => { __stub((mock) => {

@ -1,14 +1,19 @@
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useApi } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks';
type Avatar = { export type Avatar = {
account_id: string account_id: string
account_avatar: string account_avatar: string
username: string acct: string
seen: boolean
} }
export default function useCarouselAvatars() { const CarouselKeys = {
avatars: ['carouselAvatars'] as const,
};
function useCarouselAvatars() {
const api = useApi(); const api = useApi();
const getCarouselAvatars = async() => { const getCarouselAvatars = async() => {
@ -16,8 +21,9 @@ export default function useCarouselAvatars() {
return data; return data;
}; };
const result = useQuery<Avatar[]>(['carouselAvatars'], getCarouselAvatars, { const result = useQuery<Avatar[]>(CarouselKeys.avatars, getCarouselAvatars, {
placeholderData: [], placeholderData: [],
keepPreviousData: true,
}); });
const avatars = result.data; const avatars = result.data;
@ -27,3 +33,13 @@ export default function useCarouselAvatars() {
data: avatars || [], data: avatars || [],
}; };
} }
function useMarkAsSeen() {
const api = useApi();
return useMutation((account_id: string) => api.post('/api/v1/truth/carousels/avatars/seen', {
account_id,
}));
}
export { useCarouselAvatars, useMarkAsSeen };
Loading…
Cancel
Save