Build Feed Filtering feature into the Home Timeline See merge request soapbox-pub/soapbox-fe!1558environments/review-lint-0wd0oy/deployments/440
commit
7ecb8f8d35
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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 };
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in new issue