229 lines
6.8 KiB
TypeScript
229 lines
6.8 KiB
TypeScript
import type { ComponentPropsWithRef } from 'react';
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
useId,
|
|
} from 'react';
|
|
|
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
|
|
import type { Map as ImmutableMap } from 'immutable';
|
|
import { List as ImmutableList } from 'immutable';
|
|
|
|
import type { AnimatedProps } from '@react-spring/web';
|
|
import { animated, useSpring } from '@react-spring/web';
|
|
import { useDrag } from '@use-gesture/react';
|
|
|
|
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
|
|
import { Icon } from '@/mastodon/components/icon';
|
|
import { IconButton } from '@/mastodon/components/icon_button';
|
|
import StatusContainer from '@/mastodon/containers/status_container';
|
|
import { usePrevious } from '@/mastodon/hooks/usePrevious';
|
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
|
|
|
const messages = defineMessages({
|
|
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
|
|
next: { id: 'featured_carousel.next', defaultMessage: 'Next' },
|
|
slide: {
|
|
id: 'featured_carousel.slide',
|
|
defaultMessage: '{index} of {total}',
|
|
},
|
|
});
|
|
|
|
export const FeaturedCarousel: React.FC<{
|
|
accountId: string;
|
|
tagged?: string;
|
|
}> = ({ accountId, tagged }) => {
|
|
const intl = useIntl();
|
|
const accessibilityId = useId();
|
|
|
|
// Load pinned statuses
|
|
const dispatch = useAppDispatch();
|
|
useEffect(() => {
|
|
if (accountId) {
|
|
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
|
}
|
|
}, [accountId, dispatch, tagged]);
|
|
const pinnedStatuses = useAppSelector(
|
|
(state) =>
|
|
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
|
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
|
|
ImmutableList(),
|
|
) as ImmutableList<string>,
|
|
);
|
|
|
|
// Handle slide change
|
|
const [slideIndex, setSlideIndex] = useState(0);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const handleSlideChange = useCallback(
|
|
(direction: number) => {
|
|
setSlideIndex((prev) => {
|
|
const max = pinnedStatuses.size - 1;
|
|
let newIndex = prev + direction;
|
|
if (newIndex < 0) {
|
|
newIndex = max;
|
|
} else if (newIndex > max) {
|
|
newIndex = 0;
|
|
}
|
|
const slide = wrapperRef.current?.children[newIndex];
|
|
if (slide) {
|
|
setCurrentSlideHeight(slide.scrollHeight);
|
|
}
|
|
return newIndex;
|
|
});
|
|
},
|
|
[pinnedStatuses.size],
|
|
);
|
|
|
|
// Handle slide heights
|
|
const [currentSlideHeight, setCurrentSlideHeight] = useState(
|
|
wrapperRef.current?.scrollHeight ?? 0,
|
|
);
|
|
const previousSlideHeight = usePrevious(currentSlideHeight);
|
|
const observerRef = useRef<ResizeObserver>(
|
|
new ResizeObserver(() => {
|
|
handleSlideChange(0);
|
|
}),
|
|
);
|
|
const wrapperStyles = useSpring({
|
|
x: `-${slideIndex * 100}%`,
|
|
height: currentSlideHeight,
|
|
// Don't animate from zero to the height of the initial slide
|
|
immediate: !previousSlideHeight,
|
|
});
|
|
useLayoutEffect(() => {
|
|
// Update slide height when the component mounts
|
|
if (currentSlideHeight === 0) {
|
|
handleSlideChange(0);
|
|
}
|
|
}, [currentSlideHeight, handleSlideChange]);
|
|
|
|
// Handle swiping animations
|
|
const bind = useDrag(({ swipe: [swipeX] }) => {
|
|
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
|
|
});
|
|
const handlePrev = useCallback(() => {
|
|
handleSlideChange(-1);
|
|
}, [handleSlideChange]);
|
|
const handleNext = useCallback(() => {
|
|
handleSlideChange(1);
|
|
}, [handleSlideChange]);
|
|
|
|
if (!accountId || pinnedStatuses.isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className='featured-carousel'
|
|
{...bind()}
|
|
aria-roledescription='carousel'
|
|
aria-labelledby={`${accessibilityId}-title`}
|
|
role='region'
|
|
>
|
|
<div className='featured-carousel__header'>
|
|
<h4
|
|
className='featured-carousel__title'
|
|
id={`${accessibilityId}-title`}
|
|
>
|
|
<Icon id='thumb-tack' icon={PushPinIcon} />
|
|
<FormattedMessage
|
|
id='featured_carousel.header'
|
|
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
|
values={{ count: pinnedStatuses.size }}
|
|
/>
|
|
</h4>
|
|
{pinnedStatuses.size > 1 && (
|
|
<>
|
|
<IconButton
|
|
title={intl.formatMessage(messages.previous)}
|
|
icon='chevron-left'
|
|
iconComponent={ChevronLeftIcon}
|
|
onClick={handlePrev}
|
|
/>
|
|
<span aria-live='polite'>
|
|
<FormattedMessage
|
|
id='featured_carousel.post'
|
|
defaultMessage='Post'
|
|
>
|
|
{(text) => <span className='sr-only'>{text}</span>}
|
|
</FormattedMessage>
|
|
{slideIndex + 1} / {pinnedStatuses.size}
|
|
</span>
|
|
<IconButton
|
|
title={intl.formatMessage(messages.next)}
|
|
icon='chevron-right'
|
|
iconComponent={ChevronRightIcon}
|
|
onClick={handleNext}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
<animated.div
|
|
className='featured-carousel__slides'
|
|
ref={wrapperRef}
|
|
style={wrapperStyles}
|
|
aria-atomic='false'
|
|
aria-live='polite'
|
|
>
|
|
{pinnedStatuses.map((statusId, index) => (
|
|
<FeaturedCarouselItem
|
|
key={`f-${statusId}`}
|
|
data-index={index}
|
|
aria-label={intl.formatMessage(messages.slide, {
|
|
index: index + 1,
|
|
total: pinnedStatuses.size,
|
|
})}
|
|
statusId={statusId}
|
|
observer={observerRef.current}
|
|
active={index === slideIndex}
|
|
/>
|
|
))}
|
|
</animated.div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FeaturedCarouselItemProps {
|
|
statusId: string;
|
|
active: boolean;
|
|
observer: ResizeObserver;
|
|
}
|
|
|
|
const FeaturedCarouselItem: React.FC<
|
|
FeaturedCarouselItemProps & AnimatedProps<ComponentPropsWithRef<'div'>>
|
|
> = ({ statusId, active, observer, ...props }) => {
|
|
const handleRef = useCallback(
|
|
(instance: HTMLDivElement | null) => {
|
|
if (instance) {
|
|
observer.observe(instance);
|
|
}
|
|
},
|
|
[observer],
|
|
);
|
|
|
|
return (
|
|
<animated.div
|
|
className='featured-carousel__slide'
|
|
// @ts-expect-error inert in not in this version of React
|
|
inert={!active ? 'true' : undefined}
|
|
aria-roledescription='slide'
|
|
role='group'
|
|
ref={handleRef}
|
|
{...props}
|
|
>
|
|
<StatusContainer
|
|
// @ts-expect-error inferred props are wrong
|
|
id={statusId}
|
|
contextType='account'
|
|
withCounters
|
|
/>
|
|
</animated.div>
|
|
);
|
|
};
|