diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index 9cc88a6c3..f4c4690a9 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -61,11 +61,15 @@ describe('', () => { __stub((mock) => { mock.onGet('/api/v1/truth/carousels/avatars') .reply(200, [ - { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, - { account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' }, - { account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' }, - { account_id: '4', acct: 'd', 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', seen: false }, + { 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', seen: false }, ]); + + mock.onGet('/api/v1/accounts/1/statuses').reply(200, [], { + link: '; rel=\'prev\'', + }); }); }); @@ -74,6 +78,29 @@ describe('', () => { await waitFor(() => { expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); + expect(screen.queryAllByTestId('carousel-item')).toHaveLength(4); + }); + }); + + it('should handle the "seen" state', async() => { + render(, 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'); }); }); }); diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index 621c48374..b30ecba0a 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -4,15 +4,17 @@ import { FormattedMessage } from 'react-intl'; import { replaceHomeTimeline } from 'soapbox/actions/timelines'; 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 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 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 [isFetching, setLoading] = useState(false); @@ -27,17 +29,25 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { if (isSelected) { dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false))); } else { + onViewed(avatar.account_id); + markAsSeen.mutate(avatar.account_id); dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false))); } }; return ( -
+
{isSelected && (
- +
)} @@ -45,10 +55,12 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { src={avatar.account_avatar} className={classNames({ '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-accent-500': !seen && !isSelected, })} alt={avatar.acct} + data-testid='carousel-item-avatar' />
@@ -63,6 +75,7 @@ const FeedCarousel = () => { const [cardRef, setCardRef, { width }] = useDimensions(); + const [seenAccountIds, setSeenAccountIds] = useState([]); const [pageSize, setPageSize] = useState(0); const [currentPage, setCurrentPage] = useState(1); @@ -75,6 +88,20 @@ const FeedCarousel = () => { const handleNextPage = () => 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(() => { if (width) { setPageSize(Math.round(width / widthPerAvatar)); @@ -130,6 +157,8 @@ const FeedCarousel = () => { )) )} diff --git a/app/soapbox/queries/__tests__/carousels.test.ts b/app/soapbox/queries/__tests__/carousels.test.ts index 9ee5fa4c2..eb9501638 100644 --- a/app/soapbox/queries/__tests__/carousels.test.ts +++ b/app/soapbox/queries/__tests__/carousels.test.ts @@ -1,7 +1,7 @@ import { __stub } from 'soapbox/api'; import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; -import useCarouselAvatars from '../carousels'; +import { useCarouselAvatars } from '../carousels'; describe('useCarouselAvatars', () => { describe('with a successful query', () => { diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts index aa352abe9..cfd8cbb8a 100644 --- a/app/soapbox/queries/__tests__/suggestions.test.ts +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -3,7 +3,7 @@ import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; import { useOnboardingSuggestions } from '../suggestions'; -describe('useCarouselAvatars', () => { +describe('useOnboardingSuggestions', () => { describe('with a successful query', () => { beforeEach(() => { __stub((mock) => { diff --git a/app/soapbox/queries/carousels.ts b/app/soapbox/queries/carousels.ts index 7d295183e..c908b352a 100644 --- a/app/soapbox/queries/carousels.ts +++ b/app/soapbox/queries/carousels.ts @@ -1,14 +1,19 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useApi } from 'soapbox/hooks'; -type Avatar = { +export type Avatar = { account_id: 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 getCarouselAvatars = async() => { @@ -16,8 +21,9 @@ export default function useCarouselAvatars() { return data; }; - const result = useQuery(['carouselAvatars'], getCarouselAvatars, { + const result = useQuery(CarouselKeys.avatars, getCarouselAvatars, { placeholderData: [], + keepPreviousData: true, }); const avatars = result.data; @@ -27,3 +33,13 @@ export default function useCarouselAvatars() { 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 }; \ No newline at end of file