Remove Truth Social feed carousel
This commit is contained in:
@ -1,165 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { __stub } from 'soapbox/api';
|
||||
|
||||
import { render, screen, waitFor } from '../../../jest/test-helpers';
|
||||
import FeedCarousel from '../feed-carousel';
|
||||
|
||||
vi.mock('../../../hooks/useDimensions', () => ({
|
||||
useDimensions: () => [{ scrollWidth: 190 }, null, { width: 300 }],
|
||||
}));
|
||||
|
||||
(window as any).ResizeObserver = class ResizeObserver {
|
||||
|
||||
observe() { }
|
||||
disconnect() { }
|
||||
|
||||
};
|
||||
|
||||
describe('<FeedCarousel />', () => {
|
||||
let store: any;
|
||||
|
||||
describe('with "carousel" enabled', () => {
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
instance: {
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
pleroma: ImmutableMap({
|
||||
metadata: ImmutableMap({
|
||||
features: [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('with avatars', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/avatars')
|
||||
.reply(200, [
|
||||
{ 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: '<https://example.com/api/v1/accounts/1/statuses?since_id=1>; rel=\'prev\'',
|
||||
});
|
||||
|
||||
mock.onPost('/api/v1/truth/carousels/avatars/seen').reply(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the Carousel', async() => {
|
||||
render(<FeedCarousel />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
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');
|
||||
});
|
||||
|
||||
// HACK: wait for state change
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with 0 avatars', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').reply(200, []));
|
||||
});
|
||||
|
||||
it('renders nothing', async() => {
|
||||
render(<FeedCarousel />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a failed request to the API', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => mock.onGet('/api/v1/truth/carousels/avatars').networkError());
|
||||
});
|
||||
|
||||
it('renders the error message', async() => {
|
||||
render(<FeedCarousel />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple pages of avatars', () => {
|
||||
beforeEach(() => {
|
||||
__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' },
|
||||
]);
|
||||
});
|
||||
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => {
|
||||
return {
|
||||
width: 200,
|
||||
height: 120,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => null,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the correct prev/next buttons', async() => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedCarousel />, undefined, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prev-page')).toHaveAttribute('disabled');
|
||||
expect(screen.getByTestId('next-page')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId('next-page'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prev-page')).not.toHaveAttribute('disabled');
|
||||
// expect(screen.getByTestId('next-page')).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,266 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { replaceHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import { useAppDispatch, useAppSelector, useDimensions } from 'soapbox/hooks';
|
||||
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 = React.forwardRef((
|
||||
{ avatar, seen, onViewed, onPinned }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void, onPinned?: (avatar: null | Avatar) => void },
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const markAsSeen = useMarkAsSeen();
|
||||
|
||||
const selectedAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId']) as string);
|
||||
const isSelected = avatar.account_id === selectedAccountId;
|
||||
|
||||
const [isFetching, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (isSelected) {
|
||||
dispatch(replaceHomeTimeline(undefined, { maxId: null }, () => setLoading(false)));
|
||||
|
||||
if (onPinned) {
|
||||
onPinned(null);
|
||||
}
|
||||
} else {
|
||||
if (onPinned) {
|
||||
onPinned(avatar);
|
||||
}
|
||||
|
||||
if (!seen) {
|
||||
onViewed(avatar.account_id);
|
||||
markAsSeen.mutate(avatar.account_id);
|
||||
}
|
||||
|
||||
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-disabled={isFetching}
|
||||
onClick={handleClick}
|
||||
className='cursor-pointer py-4'
|
||||
role='filter-feed-by-user'
|
||||
data-testid='carousel-item'
|
||||
>
|
||||
<Stack className='h-auto w-14' space={3}>
|
||||
<div className='relative mx-auto block h-12 w-12 rounded-full'>
|
||||
{isSelected && (
|
||||
<div className='absolute inset-0 flex items-center justify-center rounded-full bg-primary-600/50'>
|
||||
<Icon src={require('@tabler/icons/check.svg')} className='h-6 w-6 text-white' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={avatar.account_avatar}
|
||||
className={clsx({
|
||||
'w-12 h-12 min-w-[48px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true,
|
||||
'ring-transparent': !isSelected && seen,
|
||||
'ring-primary-600': isSelected,
|
||||
'ring-accent-500': !seen && !isSelected,
|
||||
})}
|
||||
alt={avatar.acct}
|
||||
data-testid='carousel-item-avatar'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text theme='muted' size='sm' truncate align='center' className='pb-0.5 leading-3'>{avatar.acct}</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const FeedCarousel = () => {
|
||||
const { data: avatars, isFetching, isFetched, isError } = useCarouselAvatars();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_ref, setContainerRef, { width }] = useDimensions();
|
||||
const carouselItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]);
|
||||
const [pageSize, setPageSize] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pinnedAvatar, setPinnedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const avatarsToList = useMemo(() => {
|
||||
let list: (Avatar | null)[] = avatars.filter((avatar) => avatar.account_id !== pinnedAvatar?.account_id);
|
||||
|
||||
// If we have an Avatar pinned, let's create a new array with "null"
|
||||
// in the first position of each page.
|
||||
if (pinnedAvatar) {
|
||||
const index = (currentPage - 1) * pageSize;
|
||||
list = [
|
||||
...list.slice(0, index),
|
||||
null,
|
||||
...list.slice(index),
|
||||
];
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [avatars, pinnedAvatar, currentPage, pageSize]);
|
||||
|
||||
const numberOfPages = Math.ceil(avatars.length / pageSize);
|
||||
const widthPerAvatar = width / (Math.floor(width / 80));
|
||||
|
||||
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
|
||||
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
|
||||
|
||||
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 !== false)
|
||||
.map((avatar) => avatar.account_id),
|
||||
);
|
||||
}
|
||||
}, [avatars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width) {
|
||||
setPageSize(Math.round(width / widthPerAvatar));
|
||||
}
|
||||
}, [width, widthPerAvatar]);
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card variant='rounded' size='lg' data-testid='feed-carousel-error'>
|
||||
<Text align='center'>
|
||||
<FormattedMessage id='common.error' defaultMessage="Something isn't right. Try reloading the page." />
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFetched && avatars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='overflow-hidden rounded-xl bg-white shadow-lg dark:bg-primary-900 dark:shadow-none'
|
||||
data-testid='feed-carousel'
|
||||
>
|
||||
<HStack alignItems='stretch'>
|
||||
<div className='z-10 flex w-8 items-center justify-center self-stretch rounded-l-xl bg-white dark:bg-primary-900'>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
onClick={handlePrevPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasPrevPage}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-left.svg')} className='h-5 w-5 text-black dark:text-white' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='relative w-full overflow-hidden'>
|
||||
{pinnedAvatar ? (
|
||||
<div
|
||||
className='absolute inset-y-0 left-0 z-10 flex items-center justify-center bg-white dark:bg-primary-900'
|
||||
style={{
|
||||
width: widthPerAvatar || 'auto',
|
||||
}}
|
||||
>
|
||||
<CarouselItem
|
||||
avatar={pinnedAvatar}
|
||||
seen={seenAccountIds?.includes(pinnedAvatar.account_id)}
|
||||
onViewed={markAsSeen}
|
||||
onPinned={(avatar) => setPinnedAvatar(avatar)}
|
||||
ref={carouselItemRef}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HStack
|
||||
alignItems='center'
|
||||
style={{
|
||||
transform: `translateX(-${(currentPage - 1) * 100}%)`,
|
||||
}}
|
||||
className='transition-all duration-500 ease-out'
|
||||
ref={setContainerRef}
|
||||
>
|
||||
{isFetching ? (
|
||||
new Array(20).fill(0).map((_, idx) => (
|
||||
<div
|
||||
className='flex shrink-0 justify-center'
|
||||
style={{ width: widthPerAvatar || 'auto' }}
|
||||
key={idx}
|
||||
>
|
||||
<PlaceholderAvatar size={56} withText className='py-3' />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
avatarsToList.map((avatar: any, index) => (
|
||||
<div
|
||||
key={avatar?.account_id || index}
|
||||
className='flex shrink-0 justify-center'
|
||||
style={{
|
||||
width: widthPerAvatar || 'auto',
|
||||
}}
|
||||
>
|
||||
{avatar === null ? (
|
||||
<Stack
|
||||
className='h-auto w-14 py-4'
|
||||
space={3}
|
||||
style={{ height: carouselItemRef.current?.clientHeight }}
|
||||
>
|
||||
<div className='relative mx-auto block h-16 w-16 rounded-full'>
|
||||
<div className='h-16 w-16' />
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<CarouselItem
|
||||
avatar={avatar}
|
||||
seen={seenAccountIds?.includes(avatar.account_id)}
|
||||
onPinned={(avatar) => {
|
||||
setPinnedAvatar(null);
|
||||
setTimeout(() => {
|
||||
setPinnedAvatar(avatar);
|
||||
}, 1);
|
||||
}}
|
||||
onViewed={markAsSeen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div className='z-10 flex w-8 items-center justify-center self-stretch rounded-r-xl bg-white dark:bg-primary-900'>
|
||||
<button
|
||||
data-testid='next-page'
|
||||
onClick={handleNextPage}
|
||||
className='flex h-full w-7 items-center justify-center transition-opacity duration-500 disabled:opacity-25'
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/chevron-right.svg')} className='h-5 w-5 text-black dark:text-white' />
|
||||
</button>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedCarousel;
|
||||
@ -6,7 +6,7 @@ import { fetchSuggestions } from 'soapbox/actions/suggestions';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested Profiles' },
|
||||
@ -15,7 +15,6 @@ const messages = defineMessages({
|
||||
const FollowRecommendations: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const features = useFeatures();
|
||||
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const hasMore = useAppSelector((state) => !!state.suggestions.next);
|
||||
@ -53,25 +52,13 @@ const FollowRecommendations: React.FC = () => {
|
||||
hasMore={hasMore}
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{features.truthSuggestions ? (
|
||||
suggestions.map((suggestedProfile) => (
|
||||
<AccountContainer
|
||||
key={suggestedProfile.account}
|
||||
id={suggestedProfile.account}
|
||||
withAccountNote
|
||||
showProfileHoverCard={false}
|
||||
actionAlignment='top'
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<AccountContainer
|
||||
key={suggestion.account}
|
||||
id={suggestion.account}
|
||||
withAccountNote
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{suggestions.map((suggestion) => (
|
||||
<AccountContainer
|
||||
key={suggestion.account}
|
||||
id={suggestion.account}
|
||||
withAccountNote
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
</Column>
|
||||
|
||||
@ -2,9 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchRelationships } from 'soapbox/actions/accounts';
|
||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||
import { expandHomeTimeline, clearFeedAccountId } from 'soapbox/actions/timelines';
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
import Timeline from 'soapbox/features/ui/components/timeline';
|
||||
@ -23,12 +21,10 @@ const HomeTimeline: React.FC = () => {
|
||||
const polling = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
||||
const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId as string | undefined);
|
||||
const currentAccountRelationship = useAppSelector(state => currentAccountId ? state.relationships.get(currentAccountId) : null);
|
||||
const next = useAppSelector(state => state.timelines.get('home')?.next);
|
||||
|
||||
const handleLoadMore = (maxId: string) => {
|
||||
dispatch(expandHomeTimeline({ url: next, maxId, accountId: currentAccountId }));
|
||||
dispatch(expandHomeTimeline({ url: next, maxId }));
|
||||
};
|
||||
|
||||
// Mastodon generates the feed in Redis, and can return a partial timeline
|
||||
@ -51,7 +47,7 @@ const HomeTimeline: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(expandHomeTimeline({ accountId: currentAccountId }));
|
||||
return dispatch(expandHomeTimeline());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -62,25 +58,6 @@ const HomeTimeline: React.FC = () => {
|
||||
};
|
||||
}, [isPartial]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check to see if we still follow the user that is selected in the Feed Carousel.
|
||||
if (currentAccountId) {
|
||||
dispatch(fetchRelationships([currentAccountId]));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If we unfollowed the currently selected user from the Feed Carousel,
|
||||
// let's clear the feed filter and refetch fresh timeline data.
|
||||
if (currentAccountRelationship && !currentAccountRelationship?.following) {
|
||||
dispatch(clearFeedAccountId());
|
||||
|
||||
dispatch(expandHomeTimeline({}, () => {
|
||||
dispatch(fetchSuggestionsForTimeline());
|
||||
}));
|
||||
}
|
||||
}, [currentAccountId]);
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.title)} transparent withHeader={false}>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
|
||||
@ -36,13 +36,8 @@ const Timeline: React.FC<ITimeline> = ({
|
||||
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
|
||||
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
|
||||
const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0);
|
||||
const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId);
|
||||
|
||||
const handleDequeueTimeline = () => {
|
||||
if (isFilteringFeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(dequeueTimeline(timelineId, onLoadMore));
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user