mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-14 00:08:46 +00:00
[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:
211
app/javascript/flavours/glitch/components/featured_carousel.tsx
Normal file
211
app/javascript/flavours/glitch/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 '@/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 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) {
|
} 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 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 }}
|
values={{ acct }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 'flavours/glitch/actions/accounts';
|
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
|
||||||
import { fetchFeaturedTags } from 'flavours/glitch/actions/featured_tags';
|
import { fetchFeaturedTags } from 'flavours/glitch/actions/featured_tags';
|
||||||
import { expandAccountFeaturedTimeline } from 'flavours/glitch/actions/timelines';
|
|
||||||
import { Account } from 'flavours/glitch/components/account';
|
import { Account } from 'flavours/glitch/components/account';
|
||||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { RemoteHint } from 'flavours/glitch/components/remote_hint';
|
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 { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
|
||||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
import Column from 'flavours/glitch/features/ui/components/column';
|
import Column from 'flavours/glitch/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'>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { RemoteHint } from 'flavours/glitch/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 '@/flavours/glitch/components/featured_carousel';
|
||||||
|
|
||||||
const emptyList = ImmutableList();
|
const emptyList = ImmutableList();
|
||||||
|
|
||||||
@@ -170,7 +171,12 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||||||
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
||||||
|
|
||||||
<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'
|
||||||
|
|||||||
@@ -3739,7 +3739,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;
|
||||||
@@ -8839,6 +8840,8 @@ noscript {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 3px;
|
bottom: 3px;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11353,3 +11356,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -620,3 +620,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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user