Move pinned posts to a carousel (#34754)

Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
Echo 2025-05-26 15:35:28 +02:00 committed by GitHub
parent 47512fe518
commit ba5320671c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 282 additions and 49 deletions

View file

@ -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<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 observerRef = useRef<ResizeObserver>(
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 (
<div
className='featured-carousel'
{...bind()}
aria-roledescription='carousel'
aria-labelledby='featured-carousel-title'
role='region'
>
<div className='featured-carousel__header'>
<h4 className='featured-carousel__title' id='featured-carousel-title'>
<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>
);
};

View file

@ -29,7 +29,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
message = (
<FormattedMessage
id='empty_column.account_featured.me'
defaultMessage='You have not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friends accounts on your profile?'
defaultMessage='You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
/>
);
} else if (suspended) {
@ -52,7 +52,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
message = (
<FormattedMessage
id='empty_column.account_featured.other'
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friends accounts on your profile?'
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
values={{ acct }}
/>
);

View file

@ -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<string, unknown>).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<TagMap>,
);
const featuredStatusIds = useAppSelector(
(state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
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 (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
@ -133,23 +114,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
))}
</>
)}
{!featuredStatusIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.posts'
defaultMessage='Posts'
/>
</h4>
{featuredStatusIds.map((statusId) => (
<StatusQuoteManager
key={`f-${statusId}`}
id={statusId}
contextType='account'
/>
))}
</>
)}
{!featuredAccountIds.isEmpty() && (
<>
<h4 className='column-subheading'>

View file

@ -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 {
<ColumnBackButton />
<StatusList
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
prepend={
<>
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />
<FeaturedCarousel accountId={this.props.accountId} />
</>
}
alwaysPrepend
append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline'

View file

@ -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 friends 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 friends 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 friends 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 friends 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}",

View file

@ -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);
}

View file

@ -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;
}
}