diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index d6cb24f52..a962b068c 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -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 = {}, mode: 'any' | 'all' | 'none }); }; +const replaceHomeTimeline = ( + accountId: string | null, + { maxId }: Record = {}, +) => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: TIMELINE_REPLACE, accountId }); + dispatch(expandHomeTimeline({ accountId, maxId })); +}; + const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { const timeline = getState().timelines.get(timelineId) || {} as Record; @@ -163,8 +173,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => - expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +const expandHomeTimeline = ({ accountId, maxId }: Record = {}, 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 = {}, 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, diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx new file mode 100644 index 000000000..dfd9ba2ea --- /dev/null +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -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 ( +
+ +
+ {isSelected && ( +
+ +
+ )} + + {avatar.username} +
+ + {avatar.username} +
+
+ ); +}; + +const FeedCarousel = () => { + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const [cardRef, { width }] = useDimensions(); + + const [pageSize, setPageSize] = useState(0); + const [currentPage, setCurrentPage] = useState(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 ( + +
+ {hasPrevPage && ( +
+
+ +
+
+ )} + + + {isLoading ? ( + new Array(pageSize).fill(0).map((_, idx) => ( +
+ +
+ )) + ) : ( + avatars.map((avatar) => ( + + )) + )} +
+ + {hasNextPage && ( +
+
+ +
+
+ )} +
+
+ ); +}; + +export default FeedCarousel; diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index ad3ee672d..6f19239ac 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -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(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={ }} />} + emptyMessage={ + + + You’re not following anyone yet + + + + {siteTitle} gets more interesting once you follow other users. + + + } /> ); diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx index 8e9482671..149fa90f4 100644 --- a/app/soapbox/pages/home_page.tsx +++ b/app/soapbox/pages/home_page.tsx @@ -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 }) => { )} + + {children} {!me && ( diff --git a/app/soapbox/reducers/timelines.ts b/app/soapbox/reducers/timelines.ts index 33ad2733e..a1f33417f 100644 --- a/app/soapbox/reducers/timelines.ts +++ b/app/soapbox/reducers/timelines.ts @@ -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(), queuedItems: ImmutableOrderedSet(), //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; }