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 = ( message = (
<FormattedMessage <FormattedMessage
id='empty_column.account_featured.me' 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) { } else if (suspended) {
@ -52,7 +52,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
message = ( message = (
<FormattedMessage <FormattedMessage
id='empty_column.account_featured.other' 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 }} values={{ acct }}
/> />
); );

View file

@ -4,17 +4,14 @@ import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts'; import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags'; import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
import { Account } from 'mastodon/components/account'; import { Account } from 'mastodon/components/account';
import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint'; 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 { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
@ -43,7 +40,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
} }
@ -52,10 +48,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
const isLoading = useAppSelector( const isLoading = useAppSelector(
(state) => (state) =>
!accountId || !accountId ||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:pinned`,
'isLoading',
]) ||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']), !!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
); );
const featuredTags = useAppSelector( const featuredTags = useAppSelector(
@ -65,13 +57,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(), ImmutableList(),
) as ImmutableList<TagMap>, ) 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( const featuredAccountIds = useAppSelector(
(state) => (state) =>
state.user_lists.getIn( state.user_lists.getIn(
@ -94,11 +79,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
); );
} }
if ( if (featuredTags.isEmpty() && featuredAccountIds.isEmpty()) {
featuredStatusIds.isEmpty() &&
featuredTags.isEmpty() &&
featuredAccountIds.isEmpty()
) {
return ( return (
<AccountFeaturedWrapper accountId={accountId}> <AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage <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() && ( {!featuredAccountIds.isEmpty() && (
<> <>
<h4 className='column-subheading'> <h4 className='column-subheading'>

View file

@ -22,6 +22,7 @@ import { RemoteHint } from 'mastodon/components/remote_hint';
import { AccountHeader } from './components/account_header'; import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint'; import { LimitedAccountHint } from './components/limited_account_hint';
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
const emptyList = ImmutableList(); const emptyList = ImmutableList();
@ -169,7 +170,12 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton /> <ColumnBackButton />
<StatusList <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 alwaysPrepend
append={<RemoteHint accountId={accountId} />} append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline' scrollKey='account_timeline'

View file

@ -34,7 +34,6 @@
"account.featured": "Featured", "account.featured": "Featured",
"account.featured.accounts": "Profiles", "account.featured.accounts": "Profiles",
"account.featured.hashtags": "Hashtags", "account.featured.hashtags": "Hashtags",
"account.featured.posts": "Posts",
"account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts", "account.featured_tags.last_status_never": "No posts",
"account.follow": "Follow", "account.follow": "Follow",
@ -173,7 +172,7 @@
"column.lists": "Lists", "column.lists": "Lists",
"column.mutes": "Muted users", "column.mutes": "Muted users",
"column.notifications": "Notifications", "column.notifications": "Notifications",
"column.pins": "Featured posts", "column.pins": "Pinned posts",
"column.public": "Federated timeline", "column.public": "Federated timeline",
"column_back_button.label": "Back", "column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings", "column_header.hide_settings": "Hide settings",
@ -309,8 +308,8 @@
"emoji_button.search_results": "Search results", "emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols", "emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places", "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.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 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 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_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_hides_collections": "This user has chosen to not make this information available",
"empty_column.account_suspended": "Account suspended", "empty_column.account_suspended": "Account suspended",
@ -344,6 +343,11 @@
"explore.trending_links": "News", "explore.trending_links": "News",
"explore.trending_statuses": "Posts", "explore.trending_statuses": "Posts",
"explore.trending_tags": "Hashtags", "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_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.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.", "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.my_profile": "Open your profile",
"keyboard_shortcuts.notifications": "Open notifications column", "keyboard_shortcuts.notifications": "Open notifications column",
"keyboard_shortcuts.open_media": "Open media", "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.profile": "Open author's profile",
"keyboard_shortcuts.reply": "Reply to post", "keyboard_shortcuts.reply": "Reply to post",
"keyboard_shortcuts.requests": "Open follow requests list", "keyboard_shortcuts.requests": "Open follow requests list",
@ -567,7 +571,7 @@
"navigation_bar.mutes": "Muted users", "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.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.personal": "Personal",
"navigation_bar.pins": "Featured posts", "navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences", "navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline", "navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.search": "Search", "navigation_bar.search": "Search",
@ -863,7 +867,7 @@
"status.mute": "Mute @{name}", "status.mute": "Mute @{name}",
"status.mute_conversation": "Mute conversation", "status.mute_conversation": "Mute conversation",
"status.open": "Expand this post", "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.filtered": "Hidden due to one of your filters",
"status.quote_error.not_found": "This post cannot be displayed.", "status.quote_error.not_found": "This post cannot be displayed.",
"status.quote_error.pending_approval": "This post is pending approval from the original author.", "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.translated_from_with": "Translated from {lang} using {provider}",
"status.uncached_media_warning": "Preview not available", "status.uncached_media_warning": "Preview not available",
"status.unmute_conversation": "Unmute conversation", "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.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.save": "Save changes",
"subscribed_languages.target": "Change subscribed languages for {target}", "subscribed_languages.target": "Change subscribed languages for {target}",

View file

@ -567,3 +567,9 @@ a.sparkline {
opacity: 0.25; 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; -webkit-tap-highlight-color: transparent;
} }
.react-toggle-screenreader-only { .react-toggle-screenreader-only,
.sr-only {
border: 0; border: 0;
clip: rect(0 0 0 0); clip: rect(0 0 0 0);
height: 1px; height: 1px;
@ -8536,6 +8537,8 @@ noscript {
position: absolute; position: absolute;
bottom: 3px; bottom: 3px;
inset-inline-end: 0; inset-inline-end: 0;
display: flex;
align-items: center;
} }
} }
@ -11033,3 +11036,42 @@ noscript {
.lists-scrollable { .lists-scrollable {
min-height: 50vh; 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;
}
}