From 5bd7b2c6f35f4c4430f438520d86136ad5b13c72 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 26 May 2025 15:35:28 +0200 Subject: [PATCH] [Glitch] Move pinned posts to a carousel Port ba5320671c287b66284bc42544b3bccc506f22b9 to glitch-soc Co-authored-by: diondiondion Signed-off-by: Claire --- .../glitch/components/featured_carousel.tsx | 211 ++++++++++++++++++ .../components/empty_message.tsx | 4 +- .../features/account_featured/index.tsx | 38 +--- .../features/account_timeline/index.jsx | 8 +- .../flavours/glitch/styles/components.scss | 44 +++- .../glitch/styles/mastodon-light/diff.scss | 6 + 6 files changed, 270 insertions(+), 41 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/featured_carousel.tsx diff --git a/app/javascript/flavours/glitch/components/featured_carousel.tsx b/app/javascript/flavours/glitch/components/featured_carousel.tsx new file mode 100644 index 0000000000..9c1efb48c4 --- /dev/null +++ b/app/javascript/flavours/glitch/components/featured_carousel.tsx @@ -0,0 +1,211 @@ +import type { ComponentPropsWithRef } from 'react'; +import { useCallback, useEffect, useRef, useState } 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 '@/flavours/glitch/actions/timelines'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import StatusContainer from '@/flavours/glitch/containers/status_container'; +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'; + +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(); + + // 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).getIn( + [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], + ImmutableList(), + ) as ImmutableList, + ); + + // 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], + ); + + // Handle slide heights + const [currentSlideHeight, setCurrentSlideHeight] = useState( + wrapperRef.current?.scrollHeight ?? 0, + ); + const observerRef = useRef( + new ResizeObserver(() => { + handleSlideChange(0); + }), + ); + const wrapperStyles = useSpring({ + x: `-${slideIndex * 100}%`, + height: currentSlideHeight, + }); + useEffect(() => { + // 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 ( +
+
+ + {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/account_featured/components/empty_message.tsx b/app/javascript/flavours/glitch/features/account_featured/components/empty_message.tsx index fa910b923e..ec0a551bd0 100644 --- a/app/javascript/flavours/glitch/features/account_featured/components/empty_message.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/components/empty_message.tsx @@ -29,7 +29,7 @@ export const EmptyMessage: React.FC = ({ message = ( ); } else if (suspended) { @@ -52,7 +52,7 @@ export const EmptyMessage: React.FC = ({ message = ( ); diff --git a/app/javascript/flavours/glitch/features/account_featured/index.tsx b/app/javascript/flavours/glitch/features/account_featured/index.tsx index ea1bac2e9d..0a9e6c2e88 100644 --- a/app/javascript/flavours/glitch/features/account_featured/index.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/index.tsx @@ -4,17 +4,14 @@ import { FormattedMessage } from 'react-intl'; import { useParams } from 'react-router'; -import type { Map as ImmutableMap } from 'immutable'; import { List as ImmutableList } from 'immutable'; import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts'; import { fetchFeaturedTags } from 'flavours/glitch/actions/featured_tags'; -import { expandAccountFeaturedTimeline } from 'flavours/glitch/actions/timelines'; import { Account } from 'flavours/glitch/components/account'; import { ColumnBackButton } from 'flavours/glitch/components/column_back_button'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { RemoteHint } from 'flavours/glitch/components/remote_hint'; -import { StatusQuoteManager } from 'flavours/glitch/components/status_quoted'; import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import Column from 'flavours/glitch/features/ui/components/column'; @@ -43,7 +40,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ useEffect(() => { if (accountId) { - void dispatch(expandAccountFeaturedTimeline(accountId)); void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId })); } @@ -52,10 +48,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ const isLoading = useAppSelector( (state) => !accountId || - !!(state.timelines as ImmutableMap).getIn([ - `account:${accountId}:pinned`, - 'isLoading', - ]) || !!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']), ); const featuredTags = useAppSelector( @@ -65,13 +57,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ImmutableList(), ) as ImmutableList, ); - const featuredStatusIds = useAppSelector( - (state) => - (state.timelines as ImmutableMap).getIn( - [`account:${accountId}:pinned`, 'items'], - ImmutableList(), - ) as ImmutableList, - ); const featuredAccountIds = useAppSelector( (state) => state.user_lists.getIn( @@ -94,11 +79,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ); } - if ( - featuredStatusIds.isEmpty() && - featuredTags.isEmpty() && - featuredAccountIds.isEmpty() - ) { + if (featuredTags.isEmpty() && featuredAccountIds.isEmpty()) { return ( = ({ ))} )} - {!featuredStatusIds.isEmpty() && ( - <> -

- -

- {featuredStatusIds.map((statusId) => ( - - ))} - - )} {!featuredAccountIds.isEmpty() && ( <>

diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx index e75f0d0c27..b5e709652b 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx @@ -21,6 +21,7 @@ import { RemoteHint } from 'flavours/glitch/components/remote_hint'; import { AccountHeader } from './components/account_header'; import { LimitedAccountHint } from './components/limited_account_hint'; +import { FeaturedCarousel } from '@/flavours/glitch/components/featured_carousel'; const emptyList = ImmutableList(); @@ -170,7 +171,12 @@ class AccountTimeline extends ImmutablePureComponent { } + prepend={ + <> + + + + } alwaysPrepend append={} scrollKey='account_timeline' diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 1e31daf363..09f396472f 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -3739,7 +3739,8 @@ $ui-header-logo-wordmark-width: 99px; -webkit-tap-highlight-color: transparent; } -.react-toggle-screenreader-only { +.react-toggle-screenreader-only, +.sr-only { border: 0; clip: rect(0 0 0 0); height: 1px; @@ -8839,6 +8840,8 @@ noscript { position: absolute; bottom: 3px; inset-inline-end: 0; + display: flex; + align-items: center; } } @@ -11353,3 +11356,42 @@ noscript { .lists-scrollable { min-height: 50vh; } + +.featured-carousel { + background: var(--surface-background-color); + overflow: hidden; + flex-shrink: 0; + border-bottom: 1px solid var(--background-border-color); + touch-action: pan-x; + + &__slides { + display: flex; + flex-wrap: nowrap; + align-items: start; + } + + &__slide { + flex: 0 0 auto; + flex-basis: 100%; + } + + .status { + border-bottom: 0; + } + + &__header { + padding: 8px 16px; + color: $darker-text-color; + inset-inline-end: 0; + display: flex; + align-items: center; + gap: 4px; + } + + &__title { + flex-grow: 1; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + } +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 1626d6c9b2..b3cc475bbd 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -620,3 +620,9 @@ a.sparkline { opacity: 0.25; } } + +.featured-carousel { + background: var(--nested-card-background); + border-bottom: var(--nested-card-border); + color: var(--nested-card-text); +}