diff --git a/app/javascript/mastodon/components/featured_carousel.tsx b/app/javascript/mastodon/components/featured_carousel.tsx new file mode 100644 index 0000000000..efdf275fcb --- /dev/null +++ b/app/javascript/mastodon/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 '@/mastodon/actions/timelines'; +import { IconButton } from '@/mastodon/components/icon_button'; +import StatusContainer from '@/mastodon/containers/status_container'; +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'; + +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/mastodon/features/account_featured/components/empty_message.tsx b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx index 8767cb476e..51450a85d8 100644 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx +++ b/app/javascript/mastodon/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/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index c473d311c1..45f2ccb1d7 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/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 'mastodon/actions/accounts'; import { fetchFeaturedTags } from 'mastodon/actions/featured_tags'; -import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines'; import { Account } from 'mastodon/components/account'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RemoteHint } from 'mastodon/components/remote_hint'; -import { StatusQuoteManager } from 'mastodon/components/status_quoted'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import Column from 'mastodon/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/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx index 6fc7d0a4ef..fbccdfb6f3 100644 --- a/app/javascript/mastodon/features/account_timeline/index.jsx +++ b/app/javascript/mastodon/features/account_timeline/index.jsx @@ -22,6 +22,7 @@ import { RemoteHint } from 'mastodon/components/remote_hint'; import { AccountHeader } from './components/account_header'; import { LimitedAccountHint } from './components/limited_account_hint'; +import { FeaturedCarousel } from '@/mastodon/components/featured_carousel'; const emptyList = ImmutableList(); @@ -169,7 +170,12 @@ class AccountTimeline extends ImmutablePureComponent { } + prepend={ + <> + + + + } alwaysPrepend append={} scrollKey='account_timeline' diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 12b933004d..91690a13fc 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -34,7 +34,6 @@ "account.featured": "Featured", "account.featured.accounts": "Profiles", "account.featured.hashtags": "Hashtags", - "account.featured.posts": "Posts", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", "account.follow": "Follow", @@ -173,7 +172,7 @@ "column.lists": "Lists", "column.mutes": "Muted users", "column.notifications": "Notifications", - "column.pins": "Featured posts", + "column.pins": "Pinned posts", "column.public": "Federated timeline", "column_back_button.label": "Back", "column_header.hide_settings": "Hide settings", @@ -309,8 +308,8 @@ "emoji_button.search_results": "Search results", "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", - "empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?", - "empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friend’s accounts on your profile?", + "empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", + "empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", "empty_column.account_featured_other.unknown": "This account has not featured anything yet.", "empty_column.account_hides_collections": "This user has chosen to not make this information available", "empty_column.account_suspended": "Account suspended", @@ -344,6 +343,11 @@ "explore.trending_links": "News", "explore.trending_statuses": "Posts", "explore.trending_tags": "Hashtags", + "featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}", + "featured_carousel.next": "Next", + "featured_carousel.post": "Post", + "featured_carousel.previous": "Previous", + "featured_carousel.slide": "{index} of {total}", "filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.", "filter_modal.added.context_mismatch_title": "Context mismatch!", "filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.", @@ -484,7 +488,7 @@ "keyboard_shortcuts.my_profile": "Open your profile", "keyboard_shortcuts.notifications": "Open notifications column", "keyboard_shortcuts.open_media": "Open media", - "keyboard_shortcuts.pinned": "Open featured posts list", + "keyboard_shortcuts.pinned": "Open pinned posts list", "keyboard_shortcuts.profile": "Open author's profile", "keyboard_shortcuts.reply": "Reply to post", "keyboard_shortcuts.requests": "Open follow requests list", @@ -567,7 +571,7 @@ "navigation_bar.mutes": "Muted users", "navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.", "navigation_bar.personal": "Personal", - "navigation_bar.pins": "Featured posts", + "navigation_bar.pins": "Pinned posts", "navigation_bar.preferences": "Preferences", "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.search": "Search", @@ -863,7 +867,7 @@ "status.mute": "Mute @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this post", - "status.pin": "Feature on profile", + "status.pin": "Pin on profile", "status.quote_error.filtered": "Hidden due to one of your filters", "status.quote_error.not_found": "This post cannot be displayed.", "status.quote_error.pending_approval": "This post is pending approval from the original author.", @@ -895,7 +899,7 @@ "status.translated_from_with": "Translated from {lang} using {provider}", "status.uncached_media_warning": "Preview not available", "status.unmute_conversation": "Unmute conversation", - "status.unpin": "Don't feature on profile", + "status.unpin": "Unpin from profile", "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.", "subscribed_languages.save": "Save changes", "subscribed_languages.target": "Change subscribed languages for {target}", diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 8ca860a86d..c6e3f42799 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -567,3 +567,9 @@ a.sparkline { opacity: 0.25; } } + +.featured-carousel { + background: var(--nested-card-background); + border-bottom: var(--nested-card-border); + color: var(--nested-card-text); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 40a68a79cf..a8ca5fc3fd 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3674,7 +3674,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; @@ -8536,6 +8537,8 @@ noscript { position: absolute; bottom: 3px; inset-inline-end: 0; + display: flex; + align-items: center; } } @@ -11033,3 +11036,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; + } +}