Add FeedCarousel

This commit is contained in:
Justin 2022-06-22 11:20:10 -04:00
parent bdee28fd07
commit 1f3785c920
5 changed files with 187 additions and 6 deletions

View file

@ -26,6 +26,8 @@ const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
const TIMELINE_REPLACE = 'TIMELINE_REPLACE';
const MAX_QUEUED_ITEMS = 40;
const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) =>
@ -134,6 +136,14 @@ const parseTags = (tags: Record<string, any[]> = {}, mode: 'any' | 'all' | 'none
});
};
const replaceHomeTimeline = (
accountId: string | null,
{ maxId }: Record<string, any> = {},
) => (dispatch: AppDispatch, _getState: () => RootState) => {
dispatch({ type: TIMELINE_REPLACE, accountId });
dispatch(expandHomeTimeline({ accountId, maxId }));
};
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const timeline = getState().timelines.get(timelineId) || {} as Record<string, any>;
@ -163,8 +173,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record<string,
});
};
const expandHomeTimeline = ({ maxId }: Record<string, any> = {}, done = noOp) =>
expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => {
const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home';
const params: any = { max_id: maxId };
if (accountId) {
params.exclude_replies = true;
params.with_muted = true;
}
return expandTimeline('home', endpoint, params, done);
};
const expandPublicTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
@ -253,6 +271,7 @@ export {
TIMELINE_EXPAND_FAIL,
TIMELINE_CONNECT,
TIMELINE_DISCONNECT,
TIMELINE_REPLACE,
MAX_QUEUED_ITEMS,
processTimelineUpdate,
updateTimeline,
@ -261,6 +280,7 @@ export {
deleteFromTimelines,
clearTimeline,
expandTimeline,
replaceHomeTimeline,
expandHomeTimeline,
expandPublicTimeline,
expandRemoteTimeline,

View file

@ -0,0 +1,139 @@
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { fetchCarouselAvatars } from 'soapbox/actions/carousels';
import { replaceHomeTimeline } from 'soapbox/actions/timelines';
import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks';
import { Card, HStack, Icon, Stack, Text } from '../../components/ui';
import PlaceholderAvatar from '../placeholder/components/placeholder_avatar';
const CarouselItem = ({ avatar }: { avatar: any }) => {
const dispatch = useAppDispatch();
const selectedAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId']));
const isSelected = avatar.account_id === selectedAccountId;
const handleClick = () =>
isSelected
? dispatch(replaceHomeTimeline(null, { maxId: null }))
: dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }));
return (
<div onClick={handleClick} className='cursor-pointer' role='filter-feed-by-user'>
<Stack className='w-16 h-auto' space={3}>
<div className='block mx-auto relative w-14 h-14 rounded-full'>
{isSelected && (
<div className='absolute inset-0 bg-primary-600 bg-opacity-50 rounded-full flex items-center justify-center'>
<Icon src={require('@tabler/icons/icons/x.svg')} className='text-white h-6 w-6' />
</div>
)}
<img
src={avatar.account_avatar}
className={classNames({
' w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-slate-800': true,
'ring-transparent': !isSelected,
'ring-primary-600': isSelected,
})}
alt={avatar.username}
/>
</div>
<Text theme='muted' size='sm' truncate align='center' className='leading-3'>{avatar.username}</Text>
</Stack>
</div>
);
};
const FeedCarousel = () => {
const dispatch = useAppDispatch();
const features = useFeatures();
const [cardRef, { width }] = useDimensions();
const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const avatars = useAppSelector((state) => state.carousels.avatars);
const isLoading = useAppSelector((state) => state.carousels.isLoading);
const numberOfPages = Math.floor(avatars.length / pageSize);
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
const hasPrevPage = currentPage > 1 && numberOfPages > 1;
const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1);
const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1);
useEffect(() => {
if (width) {
setPageSize(Math.round(width / (80 + 15)));
}
}, [width]);
useEffect(() => {
if (features.feedUserFiltering) {
dispatch(fetchCarouselAvatars());
}
}, []);
if (!features.feedUserFiltering) {
return null;
}
return (
<Card variant='rounded' size='lg' ref={cardRef} className='relative'>
<div>
{hasPrevPage && (
<div>
<div className='z-10 absolute left-5 top-1/2 -mt-4'>
<button
onClick={handlePrevPage}
className='bg-white/85 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
>
<Icon src={require('@tabler/icons/icons/chevron-left.svg')} className='text-black dark:text-white h-6 w-6' />
</button>
</div>
</div>
)}
<HStack
alignItems='center'
space={8}
className='z-0 flex transition-all duration-200 ease-linear scroll'
style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }}
>
{isLoading ? (
new Array(pageSize).fill(0).map((_, idx) => (
<div className='w-16 text-center' key={idx}>
<PlaceholderAvatar size={56} withText />
</div>
))
) : (
avatars.map((avatar) => (
<CarouselItem
key={avatar.account_id}
avatar={avatar}
/>
))
)}
</HStack>
{hasNextPage && (
<div>
<div className='z-10 absolute right-5 top-1/2 -mt-4'>
<button
onClick={handleNextPage}
className='bg-white/85 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
>
<Icon src={require('@tabler/icons/icons/chevron-right.svg')} className='text-black dark:text-white h-6 w-6' />
</button>
</div>
</div>
)}
</div>
</Card>
);
};
export default FeedCarousel;

View file

@ -3,7 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { expandHomeTimeline } from 'soapbox/actions/timelines';
import { Column } from 'soapbox/components/ui';
import { Column, Stack, Text } from 'soapbox/components/ui';
import Timeline from 'soapbox/features/ui/components/timeline';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
@ -17,10 +17,11 @@ const HomeTimeline: React.FC = () => {
const polling = useRef<NodeJS.Timer | null>(null);
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
const currentAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId']));
const siteTitle = useAppSelector(state => state.instance.title);
const handleLoadMore = (maxId: string) => {
dispatch(expandHomeTimeline({ maxId }));
dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId }));
};
// Mastodon generates the feed in Redis, and can return a partial timeline
@ -43,7 +44,7 @@ const HomeTimeline: React.FC = () => {
};
const handleRefresh = () => {
return dispatch(expandHomeTimeline());
return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId }));
};
useEffect(() => {
@ -62,7 +63,17 @@ const HomeTimeline: React.FC = () => {
onRefresh={handleRefresh}
timelineId='home'
divideType='space'
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} to get started and meet other users.' values={{ public: <Link to='/timeline/local'><FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} /></Link> }} />}
emptyMessage={
<Stack space={1}>
<Text size='xl' weight='medium' align='center'>
Youre not following anyone yet
</Text>
<Text theme='muted' align='center'>
{siteTitle} gets more interesting once you follow other users.
</Text>
</Stack>
}
/>
</Column>
);

View file

@ -1,6 +1,7 @@
import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel';
import LinkFooter from 'soapbox/features/ui/components/link_footer';
import {
WhoToFollowPanel,
@ -56,6 +57,8 @@ const HomePage: React.FC = ({ children }) => {
</Card>
)}
<FeedCarousel />
{children}
{!me && (

View file

@ -29,6 +29,7 @@ import {
TIMELINE_DEQUEUE,
MAX_QUEUED_ITEMS,
TIMELINE_SCROLL_TOP,
TIMELINE_REPLACE,
} from '../actions/timelines';
import type { AnyAction } from 'redux';
@ -46,6 +47,7 @@ const TimelineRecord = ImmutableRecord({
hasMore: true,
items: ImmutableOrderedSet<string>(),
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
feedAccountId: null,
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
loadingFailed: false,
isPartial: false,
@ -345,6 +347,12 @@ export default function timelines(state: State = initialState, action: AnyAction
return timelineDisconnect(state, action.timeline);
case GROUP_REMOVE_STATUS_SUCCESS:
return removeStatusFromGroup(state, action.groupId, action.id);
case TIMELINE_REPLACE:
return state
.update('home', TimelineRecord(), timeline => timeline.withMutations(timeline => {
timeline.set('items', ImmutableOrderedSet([]));
}))
.update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', action.accountId));
default:
return state;
}