Merge remote-tracking branch 'soapbox/develop' into ts
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
8e8329a7b0
38 changed files with 761 additions and 104 deletions
|
@ -159,7 +159,7 @@
|
|||
"empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.",
|
||||
|
@ -637,7 +637,7 @@
|
|||
"empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.",
|
||||
|
|
58
app/soapbox/actions/__tests__/carousels.test.ts
Normal file
58
app/soapbox/actions/__tests__/carousels.test.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { __stub } from 'soapbox/api';
|
||||
import { mockStore, rootState } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { fetchCarouselAvatars } from '../carousels';
|
||||
|
||||
describe('fetchCarouselAvatars()', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(rootState);
|
||||
});
|
||||
|
||||
describe('with a successful API request', () => {
|
||||
let avatars;
|
||||
|
||||
beforeEach(() => {
|
||||
avatars = [
|
||||
{ account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' },
|
||||
];
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch the users from the API', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'CAROUSEL_AVATAR_REQUEST' },
|
||||
{ type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars },
|
||||
];
|
||||
|
||||
await store.dispatch(fetchCarouselAvatars());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an unsuccessful API request', () => {
|
||||
beforeEach(() => {
|
||||
__stub((mock) => {
|
||||
mock.onGet('/api/v1/truth/carousels/avatars').networkError();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch failed action', async() => {
|
||||
const expectedActions = [
|
||||
{ type: 'CAROUSEL_AVATAR_REQUEST' },
|
||||
{ type: 'CAROUSEL_AVATAR_FAIL' },
|
||||
];
|
||||
|
||||
await store.dispatch(fetchCarouselAvatars());
|
||||
const actions = store.getActions();
|
||||
|
||||
expect(actions).toEqual(expectedActions);
|
||||
});
|
||||
});
|
||||
});
|
25
app/soapbox/actions/carousels.ts
Normal file
25
app/soapbox/actions/carousels.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { AxiosResponse } from 'axios';
|
||||
|
||||
import { AppDispatch, RootState } from 'soapbox/store';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST';
|
||||
const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS';
|
||||
const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL';
|
||||
|
||||
const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
dispatch({ type: CAROUSEL_AVATAR_REQUEST });
|
||||
|
||||
return api(getState)
|
||||
.get('/api/v1/truth/carousels/avatars')
|
||||
.then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data }))
|
||||
.catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL }));
|
||||
};
|
||||
|
||||
export {
|
||||
CAROUSEL_AVATAR_REQUEST,
|
||||
CAROUSEL_AVATAR_SUCCESS,
|
||||
CAROUSEL_AVATAR_FAIL,
|
||||
fetchCarouselAvatars,
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -29,6 +29,6 @@ const ForkAwesomeIcon: React.FC<IForkAwesomeIcon> = ({ id, className, fixedWidth
|
|||
{...rest}
|
||||
/>
|
||||
);
|
||||
};``
|
||||
};
|
||||
|
||||
export default ForkAwesomeIcon;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -67,13 +66,13 @@ const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
|
|||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{poll.multiple && (
|
||||
{!showResults && poll.multiple && (
|
||||
<Text theme='muted' size='sm'>
|
||||
{intl.formatMessage(messages.multiple)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack space={4} className={classNames('mt-4')}>
|
||||
<Stack space={4} className='mt-4'>
|
||||
<Stack space={2}>
|
||||
{poll.options.map((option, i) => (
|
||||
<PollOption
|
||||
|
|
|
@ -52,7 +52,7 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
|
|||
} else {
|
||||
setScrolled(false);
|
||||
}
|
||||
}, 150, { trailing: true }), [autoload, threshold, autoloadThreshold]);
|
||||
}, 150, { trailing: true }), [autoload, threshold, autoloadThreshold, onClick]);
|
||||
|
||||
const scrollUp = () => {
|
||||
window.scrollTo({ top: 0 });
|
||||
|
@ -69,7 +69,7 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
|
|||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
}, [onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
maybeUnload();
|
||||
|
|
|
@ -7,7 +7,7 @@ import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
|||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import LoadMore from './load_more';
|
||||
import { Spinner, Text } from './ui';
|
||||
import { Card, Spinner, Text } from './ui';
|
||||
|
||||
/** Custom Viruoso component context. */
|
||||
type Context = {
|
||||
|
@ -157,13 +157,13 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
|||
<div className='mt-2'>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<div className='bg-primary-50 dark:bg-slate-700 mt-2 rounded-lg text-center p-8'>
|
||||
<Card variant='rounded' size='lg'>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Text>{emptyMessage}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,6 +23,7 @@ const spaces = {
|
|||
3: 'space-x-3',
|
||||
4: 'space-x-4',
|
||||
6: 'space-x-6',
|
||||
8: 'space-x-8',
|
||||
};
|
||||
|
||||
interface IHStack {
|
||||
|
@ -33,7 +34,7 @@ interface IHStack {
|
|||
/** Horizontal alignment of children. */
|
||||
justifyContent?: 'between' | 'center' | 'start' | 'end',
|
||||
/** Size of the gap between elements. */
|
||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6,
|
||||
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
|
||||
/** Whether to let the flexbox grow. */
|
||||
grow?: boolean,
|
||||
/** Extra CSS styles for the <div> */
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
import React from 'react';
|
||||
|
||||
import { __stub } from '../../../api';
|
||||
import { render, screen, waitFor } from '../../../jest/test-helpers';
|
||||
import FeedCarousel from '../feed-carousel';
|
||||
|
||||
jest.mock('../../../hooks/useDimensions', () => ({
|
||||
useDimensions: () => [null, { width: 200 }],
|
||||
}));
|
||||
|
||||
(window as any).ResizeObserver = class ResizeObserver {
|
||||
|
||||
observe() { }
|
||||
disconnect() { }
|
||||
|
||||
};
|
||||
|
||||
describe('<FeedCarousel />', () => {
|
||||
let store;
|
||||
|
||||
describe('with "feedUserFiltering" disabled', () => {
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
instance: {
|
||||
version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)',
|
||||
pleroma: ImmutableMap({
|
||||
metadata: ImmutableMap({
|
||||
features: [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should render nothing', () => {
|
||||
render(<FeedCarousel />, null, store);
|
||||
|
||||
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with "feedUserFiltering" enabled', () => {
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
instance: {
|
||||
version: '3.4.1 (compatible; TruthSocial 1.0.0)',
|
||||
pleroma: ImmutableMap({
|
||||
metadata: ImmutableMap({
|
||||
features: [],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should render the Carousel', () => {
|
||||
render(<FeedCarousel />, null, store);
|
||||
|
||||
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('with a failed request to the API', () => {
|
||||
beforeEach(() => {
|
||||
store.carousels = {
|
||||
avatars: [],
|
||||
error: true,
|
||||
};
|
||||
});
|
||||
|
||||
it('renders the error message', () => {
|
||||
render(<FeedCarousel />, null, store);
|
||||
|
||||
expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple pages of avatars', () => {
|
||||
beforeEach(() => {
|
||||
store.carousels = {
|
||||
error: false,
|
||||
avatars: [],
|
||||
};
|
||||
|
||||
__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 = jest.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 />, null, store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('next-page')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('prev-page')).toHaveLength(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
user.click(screen.getByTestId('next-page'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('prev-page')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('next-page')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
153
app/soapbox/features/feed-filtering/feed-carousel.tsx
Normal file
153
app/soapbox/features/feed-filtering/feed-carousel.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
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.get('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.acct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text theme='muted' size='sm' truncate align='center' className='leading-3'>{avatar.acct}</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 hasError = useAppSelector((state) => state.carousels.error);
|
||||
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;
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant='rounded' size='lg' ref={cardRef} className='relative' data-testid='feed-carousel'>
|
||||
<div>
|
||||
{hasPrevPage && (
|
||||
<div>
|
||||
<div className='z-10 absolute left-5 top-1/2 -mt-4'>
|
||||
<button
|
||||
data-testid='prev-page'
|
||||
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
|
||||
data-testid='next-page'
|
||||
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,9 +3,9 @@ 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';
|
||||
import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||
|
@ -14,13 +14,16 @@ const messages = defineMessages({
|
|||
const HomeTimeline: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const polling = useRef<NodeJS.Timer | null>(null);
|
||||
|
||||
const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true);
|
||||
const currentAccountId = useAppSelector(state => state.timelines.get('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 +46,7 @@ const HomeTimeline: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
return dispatch(expandHomeTimeline());
|
||||
return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -62,7 +65,40 @@ 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'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.title'
|
||||
defaultMessage="You're not following anyone yet"
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home.subtitle'
|
||||
defaultMessage='{siteTitle} gets more interesting once you follow other users.'
|
||||
values={{ siteTitle }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{features.federating && (
|
||||
<Text theme='muted' align='center'>
|
||||
<FormattedMessage
|
||||
id='empty_column.home'
|
||||
defaultMessage='Or you can visit {public} to get started and meet other users.'
|
||||
values={{
|
||||
public: (
|
||||
<Link to='/timeline/local' className='text-primary-600 dark:text-primary-400 hover:underline'>
|
||||
<FormattedMessage id='empty_column.home.local_tab' defaultMessage='the {site_title} tab' values={{ site_title: siteTitle }} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { Stack } from 'soapbox/components/ui';
|
||||
|
||||
interface IPlaceholderAvatar {
|
||||
size: number,
|
||||
size: number
|
||||
withText?: boolean
|
||||
}
|
||||
|
||||
/** Fake avatar to display while data is loading. */
|
||||
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size }) => {
|
||||
const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = false }) => {
|
||||
const style = React.useMemo(() => {
|
||||
if (!size) {
|
||||
return {};
|
||||
|
@ -18,10 +21,16 @@ const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size }) => {
|
|||
}, [size]);
|
||||
|
||||
return (
|
||||
<Stack space={3} className='animate-pulse text-center'>
|
||||
<div
|
||||
className='rounded-full bg-slate-200 dark:bg-slate-700'
|
||||
className='block mx-auto rounded-full bg-slate-200 dark:bg-slate-700'
|
||||
style={style}
|
||||
/>
|
||||
|
||||
{withText && (
|
||||
<div style={{ width: size, height: 20 }} className='rounded-full bg-slate-200 dark:bg-slate-700' />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ const PlaceholderStatus: React.FC<IPlaceholderStatus> = ({ thread = false }) =>
|
|||
<div
|
||||
className={classNames({
|
||||
'status-placeholder bg-white dark:bg-slate-800': true,
|
||||
'shadow-xl dark:shadow-inset sm:rounded-xl px-4 py-6 sm:p-6': !thread,
|
||||
'shadow-xl dark:shadow-inset sm:rounded-xl px-4 py-6 sm:p-5': !thread,
|
||||
})}
|
||||
>
|
||||
<div className='w-full animate-pulse overflow-hidden'>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Route, Switch } from 'react-router-dom';
|
|||
import { render, screen, waitFor } from '../../../jest/test-helpers';
|
||||
import { normalizeAccount } from '../../../normalizers';
|
||||
import UI from '../index';
|
||||
import { WrappedRoute } from '../util/react_router_helpers';
|
||||
|
||||
const TestableComponent = () => (
|
||||
<Switch>
|
||||
|
@ -12,6 +13,9 @@ const TestableComponent = () => (
|
|||
<Route path='/@:username/media' exact><UI /></Route>
|
||||
<Route path='/@:username' exact><UI /></Route>
|
||||
<Route path='/login' exact><span data-testid='sign-in'>Sign in</span></Route>
|
||||
|
||||
{/* WrappedRount will redirect to /login for logged out users... which will resolve to the route above! */}
|
||||
<WrappedRoute path='/notifications' />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
|
@ -33,11 +37,6 @@ describe('<UI />', () => {
|
|||
});
|
||||
|
||||
describe('when logged out', () => {
|
||||
describe('with guest experience disabled', () => {
|
||||
beforeEach(() => {
|
||||
store = { ...store, soapbox: ImmutableMap({ guestExperience: false }) };
|
||||
});
|
||||
|
||||
describe('when viewing a Profile Page', () => {
|
||||
it('should render the Profile page', async() => {
|
||||
render(
|
||||
|
@ -68,13 +67,13 @@ describe('<UI />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when viewing any other page', () => {
|
||||
describe('when viewing Notifications', () => {
|
||||
it('should redirect to the login page', async() => {
|
||||
render(
|
||||
<TestableComponent />,
|
||||
{},
|
||||
store,
|
||||
{ initialEntries: ['/@username/media'] },
|
||||
{ initialEntries: ['/notifications'] },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -84,4 +83,3 @@ describe('<UI />', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,8 +33,13 @@ 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));
|
||||
};
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||
import { HotKeys } from 'react-hotkeys';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Switch, useHistory, useLocation, matchPath, Redirect } from 'react-router-dom';
|
||||
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
||||
|
||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||
|
@ -34,7 +34,6 @@ import ProfilePage from 'soapbox/pages/profile_page';
|
|||
import RemoteInstancePage from 'soapbox/pages/remote_instance_page';
|
||||
import StatusPage from 'soapbox/pages/status_page';
|
||||
import { getAccessToken, getVapidKey } from 'soapbox/utils/auth';
|
||||
import { cacheCurrentUrl } from 'soapbox/utils/redirect';
|
||||
import { isStandalone } from 'soapbox/utils/state';
|
||||
// import GroupSidebarPanel from '../groups/sidebar_panel';
|
||||
|
||||
|
@ -258,7 +257,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => {
|
|||
|
||||
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
|
||||
|
||||
<WrappedRoute path='/search' publicRoute page={DefaultPage} component={Search} content={children} />
|
||||
<WrappedRoute path='/search' page={DefaultPage} component={Search} content={children} />
|
||||
{features.suggestions && <WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
|
||||
{features.profileDirectory && <WrappedRoute path='/directory' publicRoute page={DefaultPage} component={Directory} content={children} />}
|
||||
|
||||
|
@ -329,7 +328,6 @@ const UI: React.FC = ({ children }) => {
|
|||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const { guestExperience } = useSoapboxConfig();
|
||||
|
||||
const [draggingOver, setDraggingOver] = useState<boolean>(false);
|
||||
const [mobile, setMobile] = useState<boolean>(isMobile(window.innerWidth));
|
||||
|
@ -608,23 +606,6 @@ const UI: React.FC = ({ children }) => {
|
|||
// Wait for login to succeed or fail
|
||||
if (me === null) return null;
|
||||
|
||||
const isProfileOrStatusPage = !!matchPath(
|
||||
history.location.pathname,
|
||||
[
|
||||
'/@:username',
|
||||
'/@:username/posts/:statusId',
|
||||
'/users/:username',
|
||||
'/users/:username/statuses/:statusId',
|
||||
],
|
||||
);
|
||||
|
||||
// Require login if Guest Experience is disabled and we're not trying
|
||||
// to render a Profile or Status.
|
||||
if (!me && (!guestExperience && !isProfileOrStatusPage)) {
|
||||
cacheCurrentUrl(history.location);
|
||||
return <Redirect to='/login' />;
|
||||
}
|
||||
|
||||
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
||||
|
||||
const handlers: HotkeyHandlers = {
|
||||
|
|
80
app/soapbox/hooks/__tests__/useDimensions.test.ts
Normal file
80
app/soapbox/hooks/__tests__/useDimensions.test.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
import { useDimensions } from '../useDimensions';
|
||||
|
||||
let listener: ((rect: any) => void) | undefined = undefined;
|
||||
|
||||
(window as any).ResizeObserver = class ResizeObserver {
|
||||
|
||||
constructor(ls) {
|
||||
listener = ls;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
|
||||
};
|
||||
|
||||
describe('useDimensions()', () => {
|
||||
it('defaults to 0', () => {
|
||||
const { result } = renderHook(() => useDimensions());
|
||||
|
||||
act(() => {
|
||||
const div = document.createElement('div');
|
||||
(result.current[0] as any)(div);
|
||||
});
|
||||
|
||||
expect(result.current[1]).toMatchObject({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('measures the dimensions of a DOM element', () => {
|
||||
const { result } = renderHook(() => useDimensions());
|
||||
|
||||
act(() => {
|
||||
const div = document.createElement('div');
|
||||
(result.current[0] as any)(div);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
listener!([
|
||||
{
|
||||
contentRect: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(result.current[1]).toMatchObject({
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('disconnects on unmount', () => {
|
||||
const disconnect = jest.fn();
|
||||
(window as any).ResizeObserver = class ResizeObserver {
|
||||
|
||||
observe() {}
|
||||
disconnect() {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const { result, unmount } = renderHook(() => useDimensions());
|
||||
|
||||
act(() => {
|
||||
const div = document.createElement('div');
|
||||
(result.current[0] as any)(div);
|
||||
});
|
||||
|
||||
expect(disconnect).toHaveBeenCalledTimes(0);
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
export { useAccount } from './useAccount';
|
||||
export { useAppDispatch } from './useAppDispatch';
|
||||
export { useAppSelector } from './useAppSelector';
|
||||
export { useDimensions } from './useDimensions';
|
||||
export { useFeatures } from './useFeatures';
|
||||
export { useOnScreen } from './useOnScreen';
|
||||
export { useOwnAccount } from './useOwnAccount';
|
||||
|
|
38
app/soapbox/hooks/useDimensions.ts
Normal file
38
app/soapbox/hooks/useDimensions.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Ref, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type UseDimensionsRect = { width: number, height: number };
|
||||
type UseDimensionsResult = [Ref<HTMLDivElement>, any]
|
||||
|
||||
const defaultState: UseDimensionsRect = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const useDimensions = (): UseDimensionsResult => {
|
||||
const [element, ref] = useState<Element | null>(null);
|
||||
const [rect, setRect] = useState<UseDimensionsRect>(defaultState);
|
||||
|
||||
const observer = useMemo(
|
||||
() =>
|
||||
new (window as any).ResizeObserver((entries: any) => {
|
||||
if (entries[0]) {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setRect({ width, height });
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect((): any => {
|
||||
if (!element) return null;
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
return [ref, rect];
|
||||
};
|
||||
|
||||
export { useDimensions };
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -3793,7 +3793,7 @@
|
|||
"id": "column.home"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"defaultMessage": "Or you can visit {public} to get started and meet other users.",
|
||||
"id": "empty_column.home"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -258,6 +258,7 @@
|
|||
"column_forbidden.title": "Forbidden",
|
||||
"column_header.show_settings": "Show settings",
|
||||
"common.cancel": "Cancel",
|
||||
"common.error": "Something isn't right. Try reloading the page.",
|
||||
"community.column_settings.media_only": "Media Only",
|
||||
"community.column_settings.title": "Local timeline settings",
|
||||
"compose.character_counter.title": "Used {chars} out of {maxChars} characters",
|
||||
|
@ -453,7 +454,9 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.title": "You're not following anyone yet",
|
||||
"empty_column.home.subtitle": "{siteTitle} gets more interesting once you follow other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -450,7 +450,7 @@
|
|||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||
"empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.",
|
||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||
"empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.",
|
||||
"empty_column.home": "Or you can visit {public} to get started and meet other users.",
|
||||
"empty_column.home.local_tab": "the {site_title} tab",
|
||||
"empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.",
|
||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
||||
|
|
|
@ -115,7 +115,6 @@ export const SoapboxConfigRecord = ImmutableRecord({
|
|||
singleUserMode: false,
|
||||
singleUserModeProfile: '',
|
||||
linkFooterMessage: '',
|
||||
guestExperience: true,
|
||||
links: ImmutableMap<string, string>(),
|
||||
}, 'SoapboxConfig');
|
||||
|
||||
|
|
|
@ -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 && (
|
||||
|
|
50
app/soapbox/reducers/__tests__/carousels.test.ts
Normal file
50
app/soapbox/reducers/__tests__/carousels.test.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { AnyAction } from 'redux';
|
||||
|
||||
import {
|
||||
CAROUSEL_AVATAR_REQUEST,
|
||||
CAROUSEL_AVATAR_SUCCESS,
|
||||
CAROUSEL_AVATAR_FAIL,
|
||||
} from 'soapbox/actions/carousels';
|
||||
|
||||
import reducer from '../carousels';
|
||||
|
||||
describe('carousels reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
expect(reducer(undefined, {} as AnyAction)).toEqual({
|
||||
avatars: [],
|
||||
error: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAROUSEL_AVATAR_REQUEST', () => {
|
||||
it('sets "isLoading" to "true"', () => {
|
||||
const initialState = { isLoading: false, avatars: [] };
|
||||
const action = { type: CAROUSEL_AVATAR_REQUEST };
|
||||
expect(reducer(initialState, action).isLoading).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAROUSEL_AVATAR_SUCCESS', () => {
|
||||
it('sets the next state', () => {
|
||||
const initialState = { isLoading: true, avatars: [], error: false };
|
||||
const action = { type: CAROUSEL_AVATAR_SUCCESS, payload: [45] };
|
||||
const result = reducer(initialState, action);
|
||||
|
||||
expect(result.isLoading).toEqual(false);
|
||||
expect(result.avatars).toEqual([45]);
|
||||
expect(result.error).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAROUSEL_AVATAR_FAIL', () => {
|
||||
it('sets "isLoading" to "true"', () => {
|
||||
const initialState = { isLoading: true, avatars: [] };
|
||||
const action = { type: CAROUSEL_AVATAR_FAIL };
|
||||
const result = reducer(initialState, action);
|
||||
|
||||
expect(result.isLoading).toEqual(false);
|
||||
expect(result.error).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
38
app/soapbox/reducers/carousels.ts
Normal file
38
app/soapbox/reducers/carousels.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { AnyAction } from 'redux';
|
||||
|
||||
import {
|
||||
CAROUSEL_AVATAR_REQUEST,
|
||||
CAROUSEL_AVATAR_SUCCESS,
|
||||
CAROUSEL_AVATAR_FAIL,
|
||||
} from '../actions/carousels';
|
||||
|
||||
type Avatar = {
|
||||
account_id: string
|
||||
account_avatar: string
|
||||
username: string
|
||||
}
|
||||
|
||||
type CarouselsState = {
|
||||
avatars: Avatar[]
|
||||
isLoading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const initialState: CarouselsState = {
|
||||
avatars: [],
|
||||
isLoading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default function rules(state: CarouselsState = initialState, action: AnyAction): CarouselsState {
|
||||
switch (action.type) {
|
||||
case CAROUSEL_AVATAR_REQUEST:
|
||||
return { ...state, isLoading: true };
|
||||
case CAROUSEL_AVATAR_SUCCESS:
|
||||
return { ...state, isLoading: false, avatars: action.payload };
|
||||
case CAROUSEL_AVATAR_FAIL:
|
||||
return { ...state, isLoading: false, error: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@ import alerts from './alerts';
|
|||
import aliases from './aliases';
|
||||
import auth from './auth';
|
||||
import backups from './backups';
|
||||
import carousels from './carousels';
|
||||
import chat_message_lists from './chat_message_lists';
|
||||
import chat_messages from './chat_messages';
|
||||
import chats from './chats';
|
||||
|
@ -122,6 +123,7 @@ const reducers = {
|
|||
onboarding,
|
||||
rules,
|
||||
history,
|
||||
carousels,
|
||||
};
|
||||
|
||||
// Build a default state from all reducers: it has the key and `undefined`
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -271,6 +271,9 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
/** Whether the instance federates. */
|
||||
federating: federation.get('enabled', true) === true, // Assume true unless explicitly false
|
||||
|
||||
/** Whether or not to show the Feed Carousel for suggested Statuses */
|
||||
feedUserFiltering: v.software === TRUTHSOCIAL,
|
||||
|
||||
/**
|
||||
* Can edit and manage timeline filters (aka "muted words").
|
||||
* @see {@link https://docs.joinmastodon.org/methods/accounts/filters/}
|
||||
|
|
|
@ -188,7 +188,7 @@
|
|||
"tiny-queue": "^0.2.1",
|
||||
"ts-loader": "^9.3.0",
|
||||
"tslib": "^2.3.1",
|
||||
"twemoji": "https://github.com/twitter/twemoji#v13.0.2",
|
||||
"twemoji": "https://github.com/twitter/twemoji#v14.0.2",
|
||||
"typescript": "^4.4.4",
|
||||
"util": "^0.12.4",
|
||||
"uuid": "^8.3.2",
|
||||
|
@ -202,6 +202,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^14.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
|
|
31
yarn.lock
31
yarn.lock
|
@ -2329,6 +2329,14 @@
|
|||
lodash "^4.17.15"
|
||||
redent "^3.0.0"
|
||||
|
||||
"@testing-library/react-hooks@^8.0.1":
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12"
|
||||
integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
react-error-boundary "^3.1.0"
|
||||
|
||||
"@testing-library/react@^12.1.4":
|
||||
version "12.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0"
|
||||
|
@ -9616,6 +9624,13 @@ react-dom@^17.0.2:
|
|||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
|
||||
react-error-boundary@^3.1.0:
|
||||
version "3.1.4"
|
||||
resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
|
||||
integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
react-event-listener@^0.6.0:
|
||||
version "0.6.6"
|
||||
resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.6.tgz#758f7b991cad9086dd39fd29fad72127e1d8962a"
|
||||
|
@ -11340,18 +11355,18 @@ tsutils@^3.21.0:
|
|||
dependencies:
|
||||
tslib "^1.8.1"
|
||||
|
||||
twemoji-parser@13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.0.0.tgz#bd9d1b98474f1651dc174696b45cabefdfa405af"
|
||||
integrity sha512-zMaGdskpH8yKjT2RSE/HwE340R4Fm+fbie4AaqjDa4H/l07YUmAvxkSfNl6awVWNRRQ0zdzLQ8SAJZuY5MgstQ==
|
||||
twemoji-parser@14.0.0:
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62"
|
||||
integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==
|
||||
|
||||
"twemoji@https://github.com/twitter/twemoji#v13.0.2":
|
||||
version "13.0.2"
|
||||
resolved "https://github.com/twitter/twemoji#3946e782ac81821b6a78e4b6c73d4e787b34bcf0"
|
||||
"twemoji@https://github.com/twitter/twemoji#v14.0.2":
|
||||
version "14.0.2"
|
||||
resolved "https://github.com/twitter/twemoji#7a3dad4a4da30497093dab22eafba135f02308e1"
|
||||
dependencies:
|
||||
fs-extra "^8.0.1"
|
||||
jsonfile "^5.0.0"
|
||||
twemoji-parser "13.0.0"
|
||||
twemoji-parser "14.0.0"
|
||||
universalify "^0.1.2"
|
||||
|
||||
type-check@^0.4.0, type-check@~0.4.0:
|
||||
|
|
Loading…
Reference in a new issue