Add FeedCarousel
This commit is contained in:
parent
bdee28fd07
commit
1f3785c920
5 changed files with 187 additions and 6 deletions
|
@ -26,6 +26,8 @@ const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
|
||||||
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
|
||||||
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
|
||||||
|
|
||||||
|
const TIMELINE_REPLACE = 'TIMELINE_REPLACE';
|
||||||
|
|
||||||
const MAX_QUEUED_ITEMS = 40;
|
const MAX_QUEUED_ITEMS = 40;
|
||||||
|
|
||||||
const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) =>
|
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) =>
|
const expandTimeline = (timelineId: string, path: string, params: Record<string, any> = {}, done = noOp) =>
|
||||||
(dispatch: AppDispatch, getState: () => RootState) => {
|
(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
const timeline = getState().timelines.get(timelineId) || {} as Record<string, any>;
|
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) =>
|
const expandHomeTimeline = ({ accountId, maxId }: Record<string, any> = {}, done = noOp) => {
|
||||||
expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
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) =>
|
const expandPublicTimeline = ({ maxId, onlyMedia }: Record<string, any> = {}, done = noOp) =>
|
||||||
expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
|
expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
|
||||||
|
@ -253,6 +271,7 @@ export {
|
||||||
TIMELINE_EXPAND_FAIL,
|
TIMELINE_EXPAND_FAIL,
|
||||||
TIMELINE_CONNECT,
|
TIMELINE_CONNECT,
|
||||||
TIMELINE_DISCONNECT,
|
TIMELINE_DISCONNECT,
|
||||||
|
TIMELINE_REPLACE,
|
||||||
MAX_QUEUED_ITEMS,
|
MAX_QUEUED_ITEMS,
|
||||||
processTimelineUpdate,
|
processTimelineUpdate,
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
|
@ -261,6 +280,7 @@ export {
|
||||||
deleteFromTimelines,
|
deleteFromTimelines,
|
||||||
clearTimeline,
|
clearTimeline,
|
||||||
expandTimeline,
|
expandTimeline,
|
||||||
|
replaceHomeTimeline,
|
||||||
expandHomeTimeline,
|
expandHomeTimeline,
|
||||||
expandPublicTimeline,
|
expandPublicTimeline,
|
||||||
expandRemoteTimeline,
|
expandRemoteTimeline,
|
||||||
|
|
139
app/soapbox/features/feed-filtering/feed-carousel.tsx
Normal file
139
app/soapbox/features/feed-filtering/feed-carousel.tsx
Normal 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;
|
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
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 Timeline from 'soapbox/features/ui/components/timeline';
|
||||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
@ -17,10 +17,11 @@ const HomeTimeline: React.FC = () => {
|
||||||
const polling = useRef<NodeJS.Timer | null>(null);
|
const polling = useRef<NodeJS.Timer | null>(null);
|
||||||
|
|
||||||
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
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 siteTitle = useAppSelector(state => state.instance.title);
|
||||||
|
|
||||||
const handleLoadMore = (maxId: string) => {
|
const handleLoadMore = (maxId: string) => {
|
||||||
dispatch(expandHomeTimeline({ maxId }));
|
dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mastodon generates the feed in Redis, and can return a partial timeline
|
// Mastodon generates the feed in Redis, and can return a partial timeline
|
||||||
|
@ -43,7 +44,7 @@ const HomeTimeline: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
return dispatch(expandHomeTimeline());
|
return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -62,7 +63,17 @@ const HomeTimeline: React.FC = () => {
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
timelineId='home'
|
timelineId='home'
|
||||||
divideType='space'
|
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'>
|
||||||
|
You’re not following anyone yet
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text theme='muted' align='center'>
|
||||||
|
{siteTitle} gets more interesting once you follow other users.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
import {
|
import {
|
||||||
WhoToFollowPanel,
|
WhoToFollowPanel,
|
||||||
|
@ -56,6 +57,8 @@ const HomePage: React.FC = ({ children }) => {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FeedCarousel />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{!me && (
|
{!me && (
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
TIMELINE_DEQUEUE,
|
TIMELINE_DEQUEUE,
|
||||||
MAX_QUEUED_ITEMS,
|
MAX_QUEUED_ITEMS,
|
||||||
TIMELINE_SCROLL_TOP,
|
TIMELINE_SCROLL_TOP,
|
||||||
|
TIMELINE_REPLACE,
|
||||||
} from '../actions/timelines';
|
} from '../actions/timelines';
|
||||||
|
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
|
@ -46,6 +47,7 @@ const TimelineRecord = ImmutableRecord({
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
items: ImmutableOrderedSet<string>(),
|
items: ImmutableOrderedSet<string>(),
|
||||||
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS
|
||||||
|
feedAccountId: null,
|
||||||
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
|
totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+
|
||||||
loadingFailed: false,
|
loadingFailed: false,
|
||||||
isPartial: false,
|
isPartial: false,
|
||||||
|
@ -345,6 +347,12 @@ export default function timelines(state: State = initialState, action: AnyAction
|
||||||
return timelineDisconnect(state, action.timeline);
|
return timelineDisconnect(state, action.timeline);
|
||||||
case GROUP_REMOVE_STATUS_SUCCESS:
|
case GROUP_REMOVE_STATUS_SUCCESS:
|
||||||
return removeStatusFromGroup(state, action.groupId, action.id);
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue