Merge remote-tracking branch 'soapbox/develop' into cleanup

This commit is contained in:
marcin mikołajczak 2022-12-27 21:13:06 +01:00
commit 8d95a4040b
6 changed files with 145 additions and 91 deletions

View file

@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
### Changed
### Fixed
## [3.0.0] - 2022-12-25
### Added ### Added
- Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon). - Editing: ability to edit posts and view edit history (on Rebased, Pleroma, and Mastodon).
- Events: ability to create, view, and comment on Events (on Rebased). - Events: ability to create, view, and comment on Events (on Rebased).

View file

@ -8,7 +8,7 @@ import { render, screen, waitFor } from '../../../jest/test-helpers';
import FeedCarousel from '../feed-carousel'; import FeedCarousel from '../feed-carousel';
jest.mock('../../../hooks/useDimensions', () => ({ jest.mock('../../../hooks/useDimensions', () => ({
useDimensions: () => [{ scrollWidth: 190 }, null, { width: 100 }], useDimensions: () => [{ scrollWidth: 190 }, null, { width: 300 }],
})); }));
(window as any).ResizeObserver = class ResizeObserver { (window as any).ResizeObserver = class ResizeObserver {
@ -21,27 +21,6 @@ jest.mock('../../../hooks/useDimensions', () => ({
describe('<FeedCarousel />', () => { describe('<FeedCarousel />', () => {
let store: any; let store: any;
describe('with "carousel" 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 />, undefined, store);
expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0);
});
});
describe('with "carousel" enabled', () => { describe('with "carousel" enabled', () => {
beforeEach(() => { beforeEach(() => {
store = { store = {
@ -167,15 +146,15 @@ describe('<FeedCarousel />', () => {
render(<FeedCarousel />, undefined, store); render(<FeedCarousel />, undefined, store);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('next-page')).toBeInTheDocument(); expect(screen.getByTestId('prev-page')).toHaveAttribute('disabled');
expect(screen.queryAllByTestId('prev-page')).toHaveLength(0); expect(screen.getByTestId('next-page')).not.toHaveAttribute('disabled');
}); });
await user.click(screen.getByTestId('next-page')); await user.click(screen.getByTestId('next-page'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('prev-page')).toBeInTheDocument(); expect(screen.getByTestId('prev-page')).not.toHaveAttribute('disabled');
expect(screen.queryAllByTestId('next-page')).toHaveLength(0); expect(screen.getByTestId('next-page')).toHaveAttribute('disabled');
}); });
}); });
}); });

View file

@ -1,5 +1,5 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { replaceHomeTimeline } from 'soapbox/actions/timelines'; import { replaceHomeTimeline } from 'soapbox/actions/timelines';
@ -9,7 +9,10 @@ import { Avatar, useCarouselAvatars, useMarkAsSeen } from 'soapbox/queries/carou
import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; import { Card, HStack, Icon, Stack, Text } from '../../components/ui';
import PlaceholderAvatar from '../placeholder/components/placeholder-avatar'; import PlaceholderAvatar from '../placeholder/components/placeholder-avatar';
const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void }) => { const CarouselItem = React.forwardRef((
{ avatar, seen, onViewed, onPinned }: { avatar: Avatar, seen: boolean, onViewed: (account_id: string) => void, onPinned?: (avatar: null | Avatar) => void },
ref: any,
) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const markAsSeen = useMarkAsSeen(); const markAsSeen = useMarkAsSeen();
@ -28,7 +31,15 @@ const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolea
if (isSelected) { if (isSelected) {
dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false))); dispatch(replaceHomeTimeline(null, { maxId: null }, () => setLoading(false)));
if (onPinned) {
onPinned(null);
}
} else { } else {
if (onPinned) {
onPinned(avatar);
}
onViewed(avatar.account_id); onViewed(avatar.account_id);
markAsSeen.mutate(avatar.account_id); markAsSeen.mutate(avatar.account_id);
dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false))); dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null }, () => setLoading(false)));
@ -37,14 +48,15 @@ const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolea
return ( return (
<div <div
ref={ref}
aria-disabled={isFetching} aria-disabled={isFetching}
onClick={handleClick} onClick={handleClick}
className='cursor-pointer' className='cursor-pointer snap-start py-4'
role='filter-feed-by-user' role='filter-feed-by-user'
data-testid='carousel-item' data-testid='carousel-item'
> >
<Stack className='w-16 h-auto' space={3}> <Stack className='w-14 h-auto' space={3}>
<div className='block mx-auto relative w-14 h-14 rounded-full'> <div className='block mx-auto relative w-12 h-12 rounded-full'>
{isSelected && ( {isSelected && (
<div className='absolute inset-0 bg-primary-600 bg-opacity-50 rounded-full flex items-center justify-center'> <div className='absolute inset-0 bg-primary-600 bg-opacity-50 rounded-full flex items-center justify-center'>
<Icon src={require('@tabler/icons/check.svg')} className='text-white h-6 w-6' /> <Icon src={require('@tabler/icons/check.svg')} className='text-white h-6 w-6' />
@ -54,7 +66,7 @@ const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolea
<img <img
src={avatar.account_avatar} src={avatar.account_avatar}
className={classNames({ className={classNames({
'w-14 h-14 min-w-[56px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true, 'w-12 h-12 min-w-[48px] rounded-full ring-2 ring-offset-4 dark:ring-offset-primary-900': true,
'ring-transparent': !isSelected && seen, 'ring-transparent': !isSelected && seen,
'ring-primary-600': isSelected, 'ring-primary-600': isSelected,
'ring-accent-500': !seen && !isSelected, 'ring-accent-500': !seen && !isSelected,
@ -68,19 +80,30 @@ const CarouselItem = ({ avatar, seen, onViewed }: { avatar: Avatar, seen: boolea
</Stack> </Stack>
</div> </div>
); );
}; });
const FeedCarousel = () => { const FeedCarousel = () => {
const { data: avatars, isFetching, isError } = useCarouselAvatars(); const { data: avatars, isFetching, isFetched, isError } = useCarouselAvatars();
const [cardRef, setCardRef, { width }] = useDimensions(); // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_ref, setContainerRef, { width }] = useDimensions();
const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]); const [seenAccountIds, setSeenAccountIds] = useState<string[]>([]);
const [pageSize, setPageSize] = useState<number>(0); const [pageSize, setPageSize] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [pinnedAvatar, setPinnedAvatar] = useState<Avatar | null>(null);
const avatarsToList = useMemo(() => {
const list = avatars.filter((avatar) => avatar.account_id !== pinnedAvatar?.account_id);
if (pinnedAvatar) {
return [null, ...list];
}
return list;
}, [avatars, pinnedAvatar]);
const numberOfPages = Math.ceil(avatars.length / pageSize); const numberOfPages = Math.ceil(avatars.length / pageSize);
const widthPerAvatar = (cardRef?.scrollWidth || 0) / avatars.length; const widthPerAvatar = width / (Math.floor(width / 80));
const hasNextPage = currentPage < numberOfPages && numberOfPages > 1; const hasNextPage = currentPage < numberOfPages && numberOfPages > 1;
const hasPrevPage = currentPage > 1 && numberOfPages > 1; const hasPrevPage = currentPage > 1 && numberOfPages > 1;
@ -118,67 +141,104 @@ const FeedCarousel = () => {
); );
} }
if (avatars.length === 0) { if (isFetched && avatars.length === 0) {
return null; return null;
} }
return ( return (
<Card variant='rounded' size='lg' className='relative' data-testid='feed-carousel'> <div
<div> className='rounded-xl bg-white dark:bg-primary-900 shadow-lg dark:shadow-none overflow-hidden'
{hasPrevPage && ( data-testid='feed-carousel'
<div> >
<div className='z-10 absolute left-5 top-1/2 -mt-4'> <HStack alignItems='stretch'>
<button <div className='z-10 rounded-l-xl bg-white dark:bg-gray-900 w-8 flex self-stretch items-center justify-center'>
data-testid='prev-page' <button
onClick={handlePrevPage} data-testid='prev-page'
className='bg-white/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center' onClick={handlePrevPage}
> className='h-7 w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
<Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-6 w-6' /> disabled={!hasPrevPage}
</button> >
</div> <Icon src={require('@tabler/icons/chevron-left.svg')} className='text-black dark:text-white h-5 w-5' />
</div> </button>
)} </div>
<HStack <div className='overflow-hidden relative'>
alignItems='center' {pinnedAvatar ? (
space={8} <div
className='z-0 flex transition-all duration-200 ease-linear scroll' className='z-10 flex items-center justify-center absolute left-0 top-0 bottom-0 bg-white dark:bg-primary-900'
style={{ transform: `translateX(-${(currentPage - 1) * 100}%)` }} style={{
ref={setCardRef} width: widthPerAvatar,
> }}
{isFetching ? ( >
new Array(pageSize).fill(0).map((_, idx) => (
<div className='w-16 text-center' key={idx}>
<PlaceholderAvatar size={56} withText />
</div>
))
) : (
avatars.map((avatar) => (
<CarouselItem <CarouselItem
key={avatar.account_id} avatar={pinnedAvatar}
avatar={avatar} seen={seenAccountIds?.includes(pinnedAvatar.account_id)}
seen={seenAccountIds?.includes(avatar.account_id)}
onViewed={markAsSeen} onViewed={markAsSeen}
onPinned={(avatar) => setPinnedAvatar(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/50 dark:bg-gray-900/50 backdrop-blur rounded-full h-8 w-8 flex items-center justify-center'
>
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-6 w-6' />
</button>
</div> </div>
</div> ) : null}
)}
</div> <HStack
</Card> alignItems='center'
style={{
transform: `translateX(-${(currentPage - 1) * 100}%)`,
}}
className='transition-all ease-out duration-500'
ref={setContainerRef}
>
{isFetching ? (
new Array(20).fill(0).map((_, idx) => (
<div className='flex flex-shrink-0 justify-center' style={{ width: widthPerAvatar }} key={idx}>
<PlaceholderAvatar size={56} withText />
</div>
))
) : (
avatarsToList.map((avatar: any, index) => (
<div
key={avatar?.account_id || index}
className='flex flex-shrink-0 justify-center'
style={{
width: widthPerAvatar,
}}
>
{avatar === null ? (
<Stack className='w-14 snap-start py-4 h-auto' space={3}>
<div className='block mx-auto relative w-16 h-16 rounded-full'>
<div className='w-16 h-16' />
</div>
</Stack>
) : (
<CarouselItem
avatar={avatar}
seen={seenAccountIds?.includes(avatar.account_id)}
onPinned={(avatar) => {
setPinnedAvatar(null);
setTimeout(() => {
setPinnedAvatar(avatar);
}, 1);
}}
onViewed={markAsSeen}
/>
)}
</div>
))
)}
</HStack>
</div>
<div className='z-10 rounded-r-xl bg-white dark:bg-gray-900 w-8 self-stretch flex items-center justify-center'>
<button
data-testid='next-page'
onClick={handleNextPage}
className='h-7 w-7 flex items-center justify-center disabled:opacity-25 transition-opacity duration-500'
disabled={!hasNextPage}
>
<Icon src={require('@tabler/icons/chevron-right.svg')} className='text-black dark:text-white h-5 w-5' />
</button>
</div>
</HStack>
</div>
); );
}; };

View file

@ -21,14 +21,14 @@ const PlaceholderAvatar: React.FC<IPlaceholderAvatar> = ({ size, withText = fals
}, [size]); }, [size]);
return ( return (
<Stack space={3} className='animate-pulse text-center'> <Stack space={2} className='animate-pulse text-center py-3'>
<div <div
className='block mx-auto rounded-full bg-primary-50 dark:bg-primary-800' className='block mx-auto rounded-full bg-primary-50 dark:bg-primary-800'
style={style} style={style}
/> />
{withText && ( {withText && (
<div style={{ width: size, height: 20 }} className='rounded-full bg-primary-50 dark:bg-primary-800' /> <div style={{ width: size, height: 15 }} className='mx-auto rounded-full bg-primary-50 dark:bg-primary-800' />
)} )}
</Stack> </Stack>
); );

View file

@ -1,7 +1,7 @@
{ {
"name": "soapbox", "name": "soapbox",
"displayName": "Soapbox", "displayName": "Soapbox",
"version": "3.0.0", "version": "3.1.0",
"description": "Soapbox frontend for the Fediverse.", "description": "Soapbox frontend for the Fediverse.",
"homepage": "https://soapbox.pub/", "homepage": "https://soapbox.pub/",
"repository": { "repository": {

View file

@ -131,6 +131,12 @@ const configuration: Configuration = {
'/socket', '/socket',
'/static', '/static',
'/unsubscribe', '/unsubscribe',
'/images',
'/favicon.ico',
'/favicon.png',
'/apple-touch-icon.png',
'/browserconfig.xml',
'/robots.txt',
]; ];
if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed')) { if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed')) {