mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 08:19:05 +00:00
[Glitch] Refactor carousel components
Port e7cd5a430e to glitch-soc
Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { fn, userEvent, expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import type { CarouselProps } from './index';
|
||||||
|
import { Carousel } from './index';
|
||||||
|
|
||||||
|
interface TestSlideProps {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestSlide: FC<TestSlideProps & { active: boolean }> = ({
|
||||||
|
active,
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className='test-slide'
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? color : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const slides: TestSlideProps[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
text: 'first',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
text: 'second',
|
||||||
|
color: 'pink',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
text: 'third',
|
||||||
|
color: 'orange',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoryProps = Pick<
|
||||||
|
CarouselProps<TestSlideProps>,
|
||||||
|
'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Carousel',
|
||||||
|
args: {
|
||||||
|
items: slides,
|
||||||
|
renderItem(item, active) {
|
||||||
|
return <TestSlide {...item} active={active} key={item.id} />;
|
||||||
|
},
|
||||||
|
onChangeSlide: fn(),
|
||||||
|
emptyFallback: 'No slides available',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Carousel {...args} />
|
||||||
|
<style>
|
||||||
|
{`.test-slide {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
min-height: 100px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
background-color: black;
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
emptyFallback: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['test'],
|
||||||
|
} satisfies Meta<StoryProps>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
async play({ args, canvas }) {
|
||||||
|
const nextButton = await canvas.findByRole('button', { name: /next/i });
|
||||||
|
const slides = await canvas.findAllByRole('group');
|
||||||
|
await expect(slides).toHaveLength(slides.length);
|
||||||
|
|
||||||
|
await userEvent.click(nextButton);
|
||||||
|
await expect(args.onChangeSlide).toHaveBeenCalledWith(1, slides[1]);
|
||||||
|
|
||||||
|
await userEvent.click(nextButton);
|
||||||
|
await expect(args.onChangeSlide).toHaveBeenCalledWith(2, slides[2]);
|
||||||
|
|
||||||
|
// Wrap around
|
||||||
|
await userEvent.click(nextButton);
|
||||||
|
await expect(args.onChangeSlide).toHaveBeenCalledWith(0, slides[0]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DifferentHeights: Story = {
|
||||||
|
args: {
|
||||||
|
items: slides.map((props, index) => ({
|
||||||
|
...props,
|
||||||
|
styles: { height: 100 + index * 100 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoSlides: Story = {
|
||||||
|
args: {
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
244
app/javascript/flavours/glitch/components/carousel/index.tsx
Normal file
244
app/javascript/flavours/glitch/components/carousel/index.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
ComponentType,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { usePrevious } from '@dnd-kit/utilities';
|
||||||
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
|
import { useDrag } from '@use-gesture/react';
|
||||||
|
|
||||||
|
import type { CarouselPaginationProps } from './pagination';
|
||||||
|
import { CarouselPagination } from './pagination';
|
||||||
|
|
||||||
|
import './styles.scss';
|
||||||
|
|
||||||
|
const defaultMessages = defineMessages({
|
||||||
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
|
current: {
|
||||||
|
id: 'carousel.current',
|
||||||
|
defaultMessage: '<sr>Slide</sr> {current, number} / {max, number}',
|
||||||
|
},
|
||||||
|
slide: {
|
||||||
|
id: 'carousel.slide',
|
||||||
|
defaultMessage: 'Slide {current, number} of {max, number}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MessageKeys = keyof typeof defaultMessages;
|
||||||
|
|
||||||
|
export interface CarouselSlideProps {
|
||||||
|
id: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderSlideFn<
|
||||||
|
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||||
|
> = (item: SlideProps, active: boolean, index: number) => ReactElement;
|
||||||
|
|
||||||
|
export interface CarouselProps<
|
||||||
|
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||||
|
> {
|
||||||
|
items: SlideProps[];
|
||||||
|
renderItem: RenderSlideFn<SlideProps>;
|
||||||
|
onChangeSlide?: (index: number, ref: Element) => void;
|
||||||
|
paginationComponent?: ComponentType<CarouselPaginationProps> | null;
|
||||||
|
paginationProps?: Partial<CarouselPaginationProps>;
|
||||||
|
messages?: Record<MessageKeys, MessageDescriptor>;
|
||||||
|
emptyFallback?: ReactNode;
|
||||||
|
classNamePrefix?: string;
|
||||||
|
slideClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Carousel = <
|
||||||
|
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||||
|
>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
onChangeSlide,
|
||||||
|
paginationComponent: Pagination = CarouselPagination,
|
||||||
|
paginationProps = {},
|
||||||
|
messages = defaultMessages,
|
||||||
|
children,
|
||||||
|
emptyFallback = null,
|
||||||
|
className,
|
||||||
|
classNamePrefix = 'carousel',
|
||||||
|
slideClassName,
|
||||||
|
...wrapperProps
|
||||||
|
}: CarouselProps<SlideProps> & ComponentPropsWithoutRef<'div'>) => {
|
||||||
|
// Handle slide change
|
||||||
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const handleSlideChange = useCallback(
|
||||||
|
(direction: number) => {
|
||||||
|
setSlideIndex((prev) => {
|
||||||
|
const max = items.length - 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);
|
||||||
|
onChangeSlide?.(newIndex, slide);
|
||||||
|
if (slide instanceof HTMLElement) {
|
||||||
|
slide.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[items.length, onChangeSlide],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
},
|
||||||
|
{ pointer: { capture: false } },
|
||||||
|
);
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
handleSlideChange(-1);
|
||||||
|
}, [handleSlideChange]);
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
handleSlideChange(1);
|
||||||
|
}, [handleSlideChange]);
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return emptyFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...bind()}
|
||||||
|
aria-roledescription='carousel'
|
||||||
|
role='region'
|
||||||
|
className={classNames(classNamePrefix, className)}
|
||||||
|
{...wrapperProps}
|
||||||
|
>
|
||||||
|
<div className={`${classNamePrefix}__header`}>
|
||||||
|
{children}
|
||||||
|
{Pagination && items.length > 1 && (
|
||||||
|
<Pagination
|
||||||
|
current={slideIndex}
|
||||||
|
max={items.length}
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
className={`${classNamePrefix}__pagination`}
|
||||||
|
messages={messages}
|
||||||
|
{...paginationProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<animated.div
|
||||||
|
className={`${classNamePrefix}__slides`}
|
||||||
|
ref={wrapperRef}
|
||||||
|
style={wrapperStyles}
|
||||||
|
>
|
||||||
|
{items.map((itemsProps, index) => (
|
||||||
|
<CarouselSlideWrapper<SlideProps>
|
||||||
|
item={itemsProps}
|
||||||
|
renderItem={renderItem}
|
||||||
|
observer={observerRef.current}
|
||||||
|
index={index}
|
||||||
|
key={`slide-${itemsProps.id}`}
|
||||||
|
className={classNames(`${classNamePrefix}__slide`, slideClassName, {
|
||||||
|
active: index === slideIndex,
|
||||||
|
})}
|
||||||
|
active={index === slideIndex}
|
||||||
|
label={intl.formatMessage(messages.slide, {
|
||||||
|
current: index + 1,
|
||||||
|
max: items.length,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselSlideWrapperProps<SlideProps extends CarouselSlideProps> = {
|
||||||
|
observer: ResizeObserver;
|
||||||
|
className: string;
|
||||||
|
active: boolean;
|
||||||
|
item: SlideProps;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
} & Pick<CarouselProps<SlideProps>, 'renderItem'>;
|
||||||
|
|
||||||
|
const CarouselSlideWrapper = <SlideProps extends CarouselSlideProps>({
|
||||||
|
observer,
|
||||||
|
className,
|
||||||
|
active,
|
||||||
|
renderItem,
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
label,
|
||||||
|
}: CarouselSlideWrapperProps<SlideProps>) => {
|
||||||
|
const handleRef = useCallback(
|
||||||
|
(instance: HTMLDivElement | null) => {
|
||||||
|
if (instance) {
|
||||||
|
observer.observe(instance);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[observer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = useMemo(
|
||||||
|
() => renderItem(item, active, index),
|
||||||
|
[renderItem, item, active, index],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={handleRef}
|
||||||
|
className={className}
|
||||||
|
role='group'
|
||||||
|
aria-roledescription='slide'
|
||||||
|
aria-label={label}
|
||||||
|
inert={active ? undefined : ''}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-index={index}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { FC, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
|
||||||
|
import { IconButton } from '../icon_button';
|
||||||
|
|
||||||
|
import type { MessageKeys } from './index';
|
||||||
|
|
||||||
|
export interface CarouselPaginationProps {
|
||||||
|
onNext: MouseEventHandler;
|
||||||
|
onPrev: MouseEventHandler;
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
className?: string;
|
||||||
|
messages: Record<MessageKeys, MessageDescriptor>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CarouselPagination: FC<CarouselPaginationProps> = ({
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
current,
|
||||||
|
max,
|
||||||
|
className = '',
|
||||||
|
messages,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<IconButton
|
||||||
|
title={intl.formatMessage(messages.previous)}
|
||||||
|
icon='chevron-left'
|
||||||
|
iconComponent={ChevronLeftIcon}
|
||||||
|
onClick={onPrev}
|
||||||
|
/>
|
||||||
|
<span aria-live='polite'>
|
||||||
|
{intl.formatMessage(messages.current, {
|
||||||
|
current: current + 1,
|
||||||
|
max,
|
||||||
|
sr: (chunk) => <span className='sr-only'>{chunk}</span>,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
title={intl.formatMessage(messages.next)}
|
||||||
|
icon='chevron-right'
|
||||||
|
iconComponent={ChevronRightIcon}
|
||||||
|
onClick={onNext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.carousel {
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: pan-y;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slides {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,43 @@
|
|||||||
import type { ComponentPropsWithRef } from 'react';
|
import { useCallback, useEffect, useId } from 'react';
|
||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useId,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import { List as ImmutableList } 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 '@/flavours/glitch/actions/timelines';
|
import { expandAccountFeaturedTimeline } from '@/flavours/glitch/actions/timelines';
|
||||||
import { Icon } from '@/flavours/glitch/components/icon';
|
import { Icon } from '@/flavours/glitch/components/icon';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
|
||||||
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
|
import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted';
|
||||||
import { usePrevious } from '@/flavours/glitch/hooks/usePrevious';
|
import {
|
||||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
|
createAppSelector,
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
useAppDispatch,
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
useAppSelector,
|
||||||
|
} from '@/flavours/glitch/store';
|
||||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||||
|
|
||||||
|
import { Carousel } from './carousel';
|
||||||
|
|
||||||
|
const pinnedStatusesSelector = createAppSelector(
|
||||||
|
[
|
||||||
|
(state, accountId: string, tagged?: string) =>
|
||||||
|
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
||||||
|
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
|
||||||
|
ImmutableList(),
|
||||||
|
) as ImmutableList<string>,
|
||||||
|
],
|
||||||
|
(items) => items.toArray().map((id) => ({ id })),
|
||||||
|
);
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
next: { id: 'featured_carousel.next', defaultMessage: 'Next' },
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
|
current: {
|
||||||
|
id: 'featured_carousel.current',
|
||||||
|
defaultMessage: '<sr>Post</sr> {current, number} / {max, number}',
|
||||||
|
},
|
||||||
slide: {
|
slide: {
|
||||||
id: 'featured_carousel.slide',
|
id: 'featured_carousel.slide',
|
||||||
defaultMessage: '{index} of {total}',
|
defaultMessage: 'Post {current, number} of {max, number}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +45,6 @@ export const FeaturedCarousel: React.FC<{
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
tagged?: string;
|
tagged?: string;
|
||||||
}> = ({ accountId, tagged }) => {
|
}> = ({ accountId, tagged }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const accessibilityId = useId();
|
const accessibilityId = useId();
|
||||||
|
|
||||||
// Load pinned statuses
|
// Load pinned statuses
|
||||||
@@ -50,175 +54,37 @@ export const FeaturedCarousel: React.FC<{
|
|||||||
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||||
}
|
}
|
||||||
}, [accountId, dispatch, tagged]);
|
}, [accountId, dispatch, tagged]);
|
||||||
const pinnedStatuses = useAppSelector(
|
const pinnedStatuses = useAppSelector((state) =>
|
||||||
(state) =>
|
pinnedStatusesSelector(state, accountId, tagged),
|
||||||
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
|
||||||
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
|
|
||||||
ImmutableList(),
|
|
||||||
) as ImmutableList<string>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle slide change
|
const renderSlide = useCallback(
|
||||||
const [slideIndex, setSlideIndex] = useState(0);
|
({ id }: { id: string }) => (
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
<StatusQuoteManager id={id} contextType='account' withCounters />
|
||||||
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
|
if (!accountId || pinnedStatuses.length === 0) {
|
||||||
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Carousel
|
||||||
className='featured-carousel'
|
items={pinnedStatuses}
|
||||||
{...bind()}
|
renderItem={renderSlide}
|
||||||
aria-roledescription='carousel'
|
|
||||||
aria-labelledby={`${accessibilityId}-title`}
|
aria-labelledby={`${accessibilityId}-title`}
|
||||||
role='region'
|
classNamePrefix='featured-carousel'
|
||||||
>
|
messages={messages}
|
||||||
<div className='featured-carousel__header'>
|
|
||||||
<h4
|
|
||||||
className='featured-carousel__title'
|
|
||||||
id={`${accessibilityId}-title`}
|
|
||||||
>
|
>
|
||||||
|
<h4 className='featured-carousel__title' id={`${accessibilityId}-title`}>
|
||||||
<Icon id='thumb-tack' icon={PushPinIcon} />
|
<Icon id='thumb-tack' icon={PushPinIcon} />
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='featured_carousel.header'
|
id='featured_carousel.header'
|
||||||
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
||||||
values={{ count: pinnedStatuses.size }}
|
values={{ count: pinnedStatuses.length }}
|
||||||
/>
|
/>
|
||||||
</h4>
|
</h4>
|
||||||
{pinnedStatuses.size > 1 && (
|
</Carousel>
|
||||||
<>
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
|
||||||
</animated.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,24 +15,24 @@ export interface IAnnouncement extends ApiAnnouncementJSON {
|
|||||||
|
|
||||||
interface AnnouncementProps {
|
interface AnnouncementProps {
|
||||||
announcement: IAnnouncement;
|
announcement: IAnnouncement;
|
||||||
selected: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Announcement: FC<AnnouncementProps> = ({
|
export const Announcement: FC<AnnouncementProps> = ({
|
||||||
announcement,
|
announcement,
|
||||||
selected,
|
active,
|
||||||
}) => {
|
}) => {
|
||||||
const [unread, setUnread] = useState(!announcement.read);
|
const [unread, setUnread] = useState(!announcement.read);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only update `unread` marker once the announcement is out of view
|
// Only update `unread` marker once the announcement is out of view
|
||||||
if (!selected && unread !== !announcement.read) {
|
if (!active && unread !== !announcement.read) {
|
||||||
setUnread(!announcement.read);
|
setUnread(!announcement.read);
|
||||||
}
|
}
|
||||||
}, [announcement.read, selected, unread]);
|
}, [announcement.read, active, unread]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateEmojiProvider className='announcements__item'>
|
<AnimateEmojiProvider>
|
||||||
<strong className='announcements__item__range'>
|
<strong className='announcements__range'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='announcement.announcement'
|
id='announcement.announcement'
|
||||||
defaultMessage='Announcement'
|
defaultMessage='Announcement'
|
||||||
@@ -44,14 +44,14 @@ export const Announcement: FC<AnnouncementProps> = ({
|
|||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='announcements__item__content translate'
|
className='announcements__content translate'
|
||||||
htmlString={announcement.contentHtml}
|
htmlString={announcement.contentHtml}
|
||||||
extraEmojis={announcement.emojis}
|
extraEmojis={announcement.emojis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
||||||
|
|
||||||
{unread && <span className='announcements__item__unread' />}
|
{unread && <span className='announcements__unread' />}
|
||||||
</AnimateEmojiProvider>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,63 +1,50 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import type { Map, List } from 'immutable';
|
import type { Map, List } from 'immutable';
|
||||||
|
|
||||||
import ReactSwipeableViews from 'react-swipeable-views';
|
import type { RenderSlideFn } from '@/flavours/glitch/components/carousel';
|
||||||
|
import { Carousel } from '@/flavours/glitch/components/carousel';
|
||||||
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { mascot } from '@/flavours/glitch/initial_state';
|
||||||
import { mascot, reduceMotion } from '@/flavours/glitch/initial_state';
|
|
||||||
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
|
|
||||||
import type { IAnnouncement } from './announcement';
|
import type { IAnnouncement } from './announcement';
|
||||||
import { Announcement } from './announcement';
|
import { Announcement } from './announcement';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const announcementSelector = createAppSelector(
|
const announcementSelector = createAppSelector(
|
||||||
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
||||||
(announcements) =>
|
(announcements) =>
|
||||||
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
|
((announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [])
|
||||||
|
.map((announcement) => ({ announcement, id: announcement.id }))
|
||||||
|
.toReversed(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Announcements: FC = () => {
|
export const Announcements: FC = () => {
|
||||||
const intl = useIntl();
|
|
||||||
|
|
||||||
const announcements = useAppSelector(announcementSelector);
|
const announcements = useAppSelector(announcementSelector);
|
||||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||||
|
|
||||||
const [index, setIndex] = useState(0);
|
const renderSlide: RenderSlideFn<{
|
||||||
const handleChangeIndex = useCallback(
|
id: string;
|
||||||
(idx: number) => {
|
announcement: IAnnouncement;
|
||||||
setIndex(idx % announcements.length);
|
}> = useCallback(
|
||||||
},
|
(item, active) => (
|
||||||
[announcements.length],
|
<Announcement
|
||||||
|
announcement={item.announcement}
|
||||||
|
active={active}
|
||||||
|
key={item.id}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
const handleNextIndex = useCallback(() => {
|
|
||||||
setIndex((prevIndex) => (prevIndex + 1) % announcements.length);
|
|
||||||
}, [announcements.length]);
|
|
||||||
const handlePrevIndex = useCallback(() => {
|
|
||||||
setIndex((prevIndex) =>
|
|
||||||
prevIndex === 0 ? announcements.length - 1 : prevIndex - 1,
|
|
||||||
);
|
|
||||||
}, [announcements.length]);
|
|
||||||
|
|
||||||
if (announcements.length === 0) {
|
if (announcements.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='announcements'>
|
<div className='announcements__root'>
|
||||||
<img
|
<img
|
||||||
className='announcements__mastodon'
|
className='announcements__mastodon'
|
||||||
alt=''
|
alt=''
|
||||||
@@ -65,48 +52,13 @@ export const Announcements: FC = () => {
|
|||||||
src={mascot ?? elephantUIPlane}
|
src={mascot ?? elephantUIPlane}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='announcements__container'>
|
|
||||||
<CustomEmojiProvider emojis={emojis}>
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
<ReactSwipeableViews
|
<Carousel
|
||||||
animateHeight
|
classNamePrefix='announcements'
|
||||||
animateTransitions={!reduceMotion}
|
renderItem={renderSlide}
|
||||||
index={index}
|
items={announcements}
|
||||||
onChangeIndex={handleChangeIndex}
|
|
||||||
>
|
|
||||||
{announcements
|
|
||||||
.map((announcement, idx) => (
|
|
||||||
<Announcement
|
|
||||||
key={announcement.id}
|
|
||||||
announcement={announcement}
|
|
||||||
selected={index === idx}
|
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
.reverse()}
|
|
||||||
</ReactSwipeableViews>
|
|
||||||
</CustomEmojiProvider>
|
</CustomEmojiProvider>
|
||||||
|
|
||||||
{announcements.length > 1 && (
|
|
||||||
<div className='announcements__pagination'>
|
|
||||||
<IconButton
|
|
||||||
disabled={announcements.length === 1}
|
|
||||||
title={intl.formatMessage(messages.previous)}
|
|
||||||
icon='chevron-left'
|
|
||||||
iconComponent={ChevronLeftIcon}
|
|
||||||
onClick={handlePrevIndex}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{index + 1} / {announcements.length}
|
|
||||||
</span>
|
|
||||||
<IconButton
|
|
||||||
disabled={announcements.length === 1}
|
|
||||||
title={intl.formatMessage(messages.next)}
|
|
||||||
icon='chevron-right'
|
|
||||||
iconComponent={ChevronRightIcon}
|
|
||||||
onClick={handleNextIndex}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1421,7 +1421,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcements__item__content {
|
.announcements__content {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
@@ -9234,10 +9234,21 @@ noscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.announcements {
|
.announcements {
|
||||||
|
width: calc(100% - 124px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media screen and (max-width: (124px + 300px)) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__root {
|
||||||
background: lighten($ui-base-color, 8%);
|
background: lighten($ui-base-color, 8%);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
&__mastodon {
|
&__mastodon {
|
||||||
width: 124px;
|
width: 124px;
|
||||||
@@ -9248,19 +9259,16 @@ noscript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__container {
|
&__slides {
|
||||||
width: calc(100% - 124px);
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex-wrap: nowrap;
|
||||||
position: relative;
|
align-items: start;
|
||||||
|
|
||||||
@media screen and (max-width: (124px + 300px)) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__item {
|
&__slide {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 0 0 100%;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
position: relative;
|
position: relative;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -9269,8 +9277,8 @@ noscript {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
&__range {
|
&__range {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -9289,7 +9297,6 @@ noscript {
|
|||||||
width: 0.625rem;
|
width: 0.625rem;
|
||||||
height: 0.625rem;
|
height: 0.625rem;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&__pagination {
|
&__pagination {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
@@ -9299,6 +9306,7 @@ noscript {
|
|||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11910,4 +11918,10 @@ noscript {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user