[Glitch] Move pinned posts to a carousel

Port ba5320671c to glitch-soc

Co-authored-by: diondiondion <mail@diondiondion.com>
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2025-05-26 15:35:28 +02:00
committed by Claire
parent e1cac17f4a
commit 5bd7b2c6f3
6 changed files with 270 additions and 41 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 '@/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<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 '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<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

@@ -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 {
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<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

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

View File

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