Merge remote-tracking branch 'soapbox/develop' into cleanup
This commit is contained in:
commit
8d95a4040b
6 changed files with 145 additions and 91 deletions
|
@ -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).
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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')) {
|
||||||
|
|
Loading…
Reference in a new issue