Merge branch 'carousel-v2' into 'develop'
Support 'seen' property in the Feed Carousel See merge request soapbox-pub/soapbox!1978
This commit is contained in:
commit
fa4bd20d11
5 changed files with 89 additions and 17 deletions
|
@ -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…
Reference in a new issue