diff --git a/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx b/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx new file mode 100644 index 0000000000..5117bc08e3 --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/carousel.stories.tsx @@ -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 = ({ + active, + text, + color, +}) => ( +
+ {text} +
+); + +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, + 'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide' +>; + +const meta = { + title: 'Components/Carousel', + args: { + items: slides, + renderItem(item, active) { + return ; + }, + onChangeSlide: fn(), + emptyFallback: 'No slides available', + }, + render(args) { + return ( + <> + + + + ); + }, + argTypes: { + emptyFallback: { + type: 'string', + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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: [], + }, +}; diff --git a/app/javascript/flavours/glitch/components/carousel/index.tsx b/app/javascript/flavours/glitch/components/carousel/index.tsx new file mode 100644 index 0000000000..f2b9e9823b --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/index.tsx @@ -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: 'Slide {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; + onChangeSlide?: (index: number, ref: Element) => void; + paginationComponent?: ComponentType | null; + paginationProps?: Partial; + messages?: Record; + 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 & ComponentPropsWithoutRef<'div'>) => { + // Handle slide change + const [slideIndex, setSlideIndex] = useState(0); + const wrapperRef = useRef(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( + 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 ( +
+
+ {children} + {Pagination && items.length > 1 && ( + + )} +
+ + + {items.map((itemsProps, index) => ( + + 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, + })} + /> + ))} + +
+ ); +}; + +type CarouselSlideWrapperProps = { + observer: ResizeObserver; + className: string; + active: boolean; + item: SlideProps; + index: number; + label: string; +} & Pick, 'renderItem'>; + +const CarouselSlideWrapper = ({ + observer, + className, + active, + renderItem, + item, + index, + label, +}: CarouselSlideWrapperProps) => { + const handleRef = useCallback( + (instance: HTMLDivElement | null) => { + if (instance) { + observer.observe(instance); + } + }, + [observer], + ); + + const children = useMemo( + () => renderItem(item, active, index), + [renderItem, item, active, index], + ); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/carousel/pagination.tsx b/app/javascript/flavours/glitch/components/carousel/pagination.tsx new file mode 100644 index 0000000000..a2666f486f --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/pagination.tsx @@ -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; +} + +export const CarouselPagination: FC = ({ + onNext, + onPrev, + current, + max, + className = '', + messages, +}) => { + const intl = useIntl(); + return ( +
+ + + {intl.formatMessage(messages.current, { + current: current + 1, + max, + sr: (chunk) => {chunk}, + })} + + +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/carousel/styles.scss b/app/javascript/flavours/glitch/components/carousel/styles.scss new file mode 100644 index 0000000000..bcd0bc7d3a --- /dev/null +++ b/app/javascript/flavours/glitch/components/carousel/styles.scss @@ -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; + } +} diff --git a/app/javascript/flavours/glitch/components/featured_carousel.tsx b/app/javascript/flavours/glitch/components/featured_carousel.tsx index 48fda7d2ce..5f85f12d1f 100644 --- a/app/javascript/flavours/glitch/components/featured_carousel.tsx +++ b/app/javascript/flavours/glitch/components/featured_carousel.tsx @@ -1,38 +1,43 @@ -import type { ComponentPropsWithRef } from 'react'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - useId, -} from 'react'; +import { useCallback, useEffect, useId } from 'react'; -import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage } 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 '@/flavours/glitch/actions/timelines'; import { Icon } from '@/flavours/glitch/components/icon'; -import { IconButton } from '@/flavours/glitch/components/icon_button'; import { StatusQuoteManager } from '@/flavours/glitch/components/status_quoted'; -import { usePrevious } from '@/flavours/glitch/hooks/usePrevious'; -import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; -import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; -import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from '@/flavours/glitch/store'; 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).getIn( + [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], + ImmutableList(), + ) as ImmutableList, + ], + (items) => items.toArray().map((id) => ({ id })), +); + const messages = defineMessages({ - previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' }, - next: { id: 'featured_carousel.next', defaultMessage: 'Next' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + current: { + id: 'featured_carousel.current', + defaultMessage: 'Post {current, number} / {max, number}', + }, 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; tagged?: string; }> = ({ accountId, tagged }) => { - const intl = useIntl(); const accessibilityId = useId(); // Load pinned statuses @@ -50,175 +54,37 @@ export const FeaturedCarousel: React.FC<{ void dispatch(expandAccountFeaturedTimeline(accountId, { tagged })); } }, [accountId, dispatch, tagged]); - const pinnedStatuses = useAppSelector( - (state) => - (state.timelines as ImmutableMap).getIn( - [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], - ImmutableList(), - ) as ImmutableList, + const pinnedStatuses = useAppSelector((state) => + pinnedStatusesSelector(state, accountId, tagged), ); - // Handle slide change - const [slideIndex, setSlideIndex] = useState(0); - const wrapperRef = useRef(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], + const renderSlide = useCallback( + ({ id }: { id: string }) => ( + + ), + [], ); - // Handle slide heights - const [currentSlideHeight, setCurrentSlideHeight] = useState( - wrapperRef.current?.scrollHeight ?? 0, - ); - const previousSlideHeight = usePrevious(currentSlideHeight); - const observerRef = useRef( - 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()) { + if (!accountId || pinnedStatuses.length === 0) { return null; } return ( -
-
-

- - -

- {pinnedStatuses.size > 1 && ( - <> - - - - {(text) => {text}} - - {slideIndex + 1} / {pinnedStatuses.size} - - - - )} -
- - {pinnedStatuses.map((statusId, index) => ( - - ))} - -
- ); -}; - -interface FeaturedCarouselItemProps { - statusId: string; - active: boolean; - observer: ResizeObserver; -} - -const FeaturedCarouselItem: React.FC< - FeaturedCarouselItemProps & AnimatedProps> -> = ({ statusId, active, observer, ...props }) => { - const handleRef = useCallback( - (instance: HTMLDivElement | null) => { - if (instance) { - observer.observe(instance); - } - }, - [observer], - ); - - return ( - - - +

+ + +

+
); }; diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx index 1bc8cdb9da..447b185138 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx @@ -15,24 +15,24 @@ export interface IAnnouncement extends ApiAnnouncementJSON { interface AnnouncementProps { announcement: IAnnouncement; - selected: boolean; + active?: boolean; } export const Announcement: FC = ({ announcement, - selected, + active, }) => { const [unread, setUnread] = useState(!announcement.read); useEffect(() => { // Only update `unread` marker once the announcement is out of view - if (!selected && unread !== !announcement.read) { + if (!active && unread !== !announcement.read) { setUnread(!announcement.read); } - }, [announcement.read, selected, unread]); + }, [announcement.read, active, unread]); return ( - - + + = ({ - {unread && } + {unread && } ); }; diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx index 980f8916ea..bc049e6740 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx +++ b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx @@ -1,63 +1,50 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import type { FC } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; - 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 { IconButton } from '@/flavours/glitch/components/icon_button'; -import { mascot, reduceMotion } from '@/flavours/glitch/initial_state'; +import { mascot } from '@/flavours/glitch/initial_state'; import { createAppSelector, useAppSelector } from '@/flavours/glitch/store'; 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 { 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( [(state) => state.announcements as Map>>], (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 = () => { - const intl = useIntl(); - const announcements = useAppSelector(announcementSelector); const emojis = useAppSelector((state) => state.custom_emojis); - const [index, setIndex] = useState(0); - const handleChangeIndex = useCallback( - (idx: number) => { - setIndex(idx % announcements.length); - }, - [announcements.length], + const renderSlide: RenderSlideFn<{ + id: string; + announcement: IAnnouncement; + }> = useCallback( + (item, active) => ( + + ), + [], ); - 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) { return null; } return ( -
+
{ src={mascot ?? elephantUIPlane} /> -
- - - {announcements - .map((announcement, idx) => ( - - )) - .reverse()} - - - - {announcements.length > 1 && ( -
- - - {index + 1} / {announcements.length} - - -
- )} -
+ + +
); }; diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 144466d58b..9807bf5cde 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -1421,7 +1421,7 @@ } } -.announcements__item__content { +.announcements__content { overflow-wrap: break-word; overflow-y: auto; @@ -9234,10 +9234,21 @@ noscript { } .announcements { - background: lighten($ui-base-color, 8%); - font-size: 13px; - display: flex; - align-items: flex-end; + 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%); + font-size: 13px; + display: flex; + align-items: flex-end; + } &__mastodon { width: 124px; @@ -9248,19 +9259,16 @@ noscript { } } - &__container { - width: calc(100% - 124px); - flex: 0 0 auto; - position: relative; - - @media screen and (max-width: (124px + 300px)) { - width: 100%; - } + &__slides { + display: flex; + flex-wrap: nowrap; + align-items: start; } - &__item { + &__slide { box-sizing: border-box; width: 100%; + flex: 0 0 100%; padding: 15px; position: relative; font-size: 15px; @@ -9269,26 +9277,25 @@ noscript { font-weight: 400; max-height: 50vh; overflow: hidden; - display: flex; flex-direction: column; + } - &__range { - display: block; - font-weight: 500; - margin-bottom: 10px; - padding-inline-end: 18px; - } + &__range { + display: block; + font-weight: 500; + margin-bottom: 10px; + padding-inline-end: 18px; + } - &__unread { - position: absolute; - top: 19px; - inset-inline-end: 19px; - display: block; - background: $highlight-text-color; - border-radius: 50%; - width: 0.625rem; - height: 0.625rem; - } + &__unread { + position: absolute; + top: 19px; + inset-inline-end: 19px; + display: block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; } &__pagination { @@ -9299,6 +9306,7 @@ noscript { inset-inline-end: 0; display: flex; align-items: center; + z-index: 1; } } @@ -11910,4 +11918,10 @@ noscript { height: 16px; } } + + &__pagination { + display: flex; + align-items: center; + gap: 4px; + } }