Move pinned posts to a carousel (#34754)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
parent
47512fe518
commit
ba5320671c
7 changed files with 282 additions and 49 deletions
211
app/javascript/mastodon/components/featured_carousel.tsx
Normal file
211
app/javascript/mastodon/components/featured_carousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 friend’s 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 friend’s 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 friend’s 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 friend’s accounts on your profile?'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue