Redesign: Add new column header to "Saved Posts" and "Post Likes" pages (#40345)

This commit is contained in:
diondiondion
2026-09-01 16:35:59 +02:00
committed by GitHub
parent fbfbcc80b2
commit 3bd8929d50
11 changed files with 226 additions and 153 deletions

View File

@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import { Icon } from 'mastodon/components/icon';
import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { RenderIntoTabsBarPortal } from 'mastodon/features/ui/util/columns_context';
import { useAppHistory } from '../router';
@@ -49,5 +49,5 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
</button>
);
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
return <RenderIntoTabsBarPortal>{component}</RenderIntoTabsBarPortal>;
};

View File

@@ -13,7 +13,7 @@ import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { RenderIntoTabsBarPortal } from 'mastodon/features/ui/util/columns_context';
import { useIdentity } from 'mastodon/identity_context';
import { getColumnSkipLinkId } from '../../features/ui/components/skip_links';
@@ -340,6 +340,6 @@ export const ColumnHeader: React.FC<ColumnHeaderProps> = ({
if (placeholder) {
return component;
} else {
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
return <RenderIntoTabsBarPortal>{component}</RenderIntoTabsBarPortal>;
}
};

View File

@@ -9,6 +9,7 @@ import { ArrowLeftIcon, ListIcon } from '@phosphor-icons/react';
import { openNavigation } from '@/mastodon/actions/navigation';
import { getColumnSkipLinkId } from '@/mastodon/features/ui/components/skip_links';
import { useBreakpoint } from '@/mastodon/features/ui/hooks/useBreakpoint';
import { RenderIntoTabsBarPortal } from '@/mastodon/features/ui/util/columns_context';
import { useAppDispatch } from '@/mastodon/store';
import { hasReactChildren } from '@/mastodon/utils/has_react_children';
@@ -38,21 +39,23 @@ export const ColumnHeader: React.FC<ColumnHeaderProps> = ({
const columnIndex = useColumnIndexContext();
return (
<header {...props} className={classNames(className, classes.root)}>
{withBackButton ? <BackButton /> : <MobileMenuButton />}
<NavigationFocusTarget className={classes.title}>
<button
type='button'
onClick={scrollTop}
id={getColumnSkipLinkId(columnIndex)}
>
{title}
</button>
</NavigationFocusTarget>
{hasReactChildren(extraButtons) && (
<div className={classes.rightButtons}>{extraButtons}</div>
)}
</header>
<RenderIntoTabsBarPortal>
<header {...props} className={classNames(className, classes.root)}>
{withBackButton ? <BackButton /> : <MobileMenuButton />}
<NavigationFocusTarget className={classes.title}>
<button
type='button'
onClick={scrollTop}
id={getColumnSkipLinkId(columnIndex)}
>
{title}
</button>
</NavigationFocusTarget>
{hasReactChildren(extraButtons) && (
<div className={classes.rightButtons}>{extraButtons}</div>
)}
</header>
</RenderIntoTabsBarPortal>
);
};

View File

@@ -18,7 +18,7 @@
color: var(--color-text-primary);
background: linear-gradient(
to bottom,
var(--color-bg-primary) 50%,
var(--color-bg-primary) var(--column-header-gradient-start, 55%),
transparent
);
}

View File

@@ -5,7 +5,9 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column/header';
import { ColumnHeader as LegacyColumnHeader } from '@/mastodon/components/column/header';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import {
fetchBookmarkedStatuses,
@@ -18,6 +20,7 @@ import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
headingRedesign: { id: 'column.saved_posts', defaultMessage: 'Saved Posts' },
});
const Bookmarks: React.FC<{
@@ -74,16 +77,20 @@ const Bookmarks: React.FC<{
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
pinned={pinned}
multiColumn={multiColumn}
scrollTopOnClick
/>
{isRedesignEnabled() ? (
<ColumnHeader title={intl.formatMessage(messages.headingRedesign)} />
) : (
<LegacyColumnHeader
icon='bookmarks'
iconComponent={BookmarksIcon}
title={intl.formatMessage(messages.heading)}
onPin={handlePin}
onMove={handleMove}
pinned={pinned}
multiColumn={multiColumn}
scrollTopOnClick
/>
)}
<StatusList
trackScroll={!pinned}
@@ -98,7 +105,11 @@ const Bookmarks: React.FC<{
/>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<title>
{isRedesignEnabled()
? intl.formatMessage(messages.headingRedesign)
: intl.formatMessage(messages.heading)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>

View File

@@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions';
import { Account } from 'mastodon/components/account';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column/header';
import { Icon } from 'mastodon/components/icon';
import { injectIntl } from '@/mastodon/components/intl';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
const messages = defineMessages({
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
});
class Favourites extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
if (!this.props.accountIds) {
this.props.dispatch(fetchFavourites(this.props.params.statusId));
}
}
handleRefresh = () => {
this.props.dispatch(fetchFavourites(this.props.params.statusId));
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandFavourites(this.props.params.statusId));
}, 300, { leading: true });
render () {
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favorited this post yet. When someone does, they will show up here.' />;
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={(
<button type='button' className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' icon={RefreshIcon} /></button>
)}
/>
<ScrollableList
scrollKey='favourites'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map(id =>
<Account key={id} id={id} reference='status' />,
)}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Favourites));

View File

@@ -0,0 +1,142 @@
import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import type { List as ImmutableList } from 'immutable';
import { ArrowClockwiseIcon } from '@phosphor-icons/react';
import { Helmet } from '@unhead/react/helmet';
import { useDebouncedCallback } from 'use-debounce';
import { ColumnHeader as LegacyColumnHeader } from '@/mastodon/components/column/header';
import {
ColumnHeader,
ColumnHeaderButton,
} from '@/mastodon/components/column_header';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '@/mastodon/utils/environment';
import RefreshIcon from '@/material-icons/400-24px/refresh.svg?react';
import {
fetchFavourites,
expandFavourites,
} from 'mastodon/actions/interactions';
import { Account } from 'mastodon/components/account';
import { Column } from 'mastodon/components/column';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
const messages = defineMessages({
title: { id: 'status.likes_title', defaultMessage: 'Post Likes' },
refresh: { id: 'refresh', defaultMessage: 'Refresh' },
});
const Favourites: React.FC<{ multiColumn?: boolean }> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { statusId } = useParams<{ statusId?: string }>();
const { accountIds, hasMore, isLoading } = useAppSelector((state) => ({
accountIds: state.user_lists.getIn(['favourited_by', statusId, 'items']) as
| ImmutableList<string>
| undefined,
hasMore: !!state.user_lists.getIn(['favourited_by', statusId, 'next']),
isLoading: state.user_lists.getIn(
['favourited_by', statusId, 'isLoading'],
true,
) as boolean,
}));
useEffect(() => {
if (!accountIds) {
dispatch(fetchFavourites(statusId));
}
}, [accountIds, dispatch, statusId]);
const handleRefresh = useCallback(() => {
dispatch(fetchFavourites(statusId));
}, [dispatch, statusId]);
const handleLoadMore = useDebouncedCallback(
() => {
dispatch(expandFavourites(statusId));
},
300,
{
leading: true,
},
);
if (!accountIds) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = (
<FormattedMessage
id='empty_column.favourites'
defaultMessage='No one has favorited this post yet. When someone does, they will show up here.'
/>
);
return (
<Column bindToDocument={!multiColumn}>
{isRedesignEnabled() ? (
<ColumnHeader
withBackButton
title={intl.formatMessage(messages.title)}
extraButtons={
<ColumnHeaderButton
icon={ArrowClockwiseIcon}
onClick={handleRefresh}
>
{intl.formatMessage(messages.refresh)}
</ColumnHeaderButton>
}
/>
) : (
<LegacyColumnHeader
showBackButton
multiColumn={multiColumn}
extraButton={
<button
type='button'
className='column-header__button'
title={intl.formatMessage(messages.refresh)}
aria-label={intl.formatMessage(messages.refresh)}
onClick={handleRefresh}
>
<Icon id='refresh' icon={RefreshIcon} />
</button>
}
/>
)}
<ScrollableList
scrollKey='favourites'
onLoadMore={handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{accountIds.map((id) => (
<Account key={id} id={id} reference='status' />
))}
</ScrollableList>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Favourites;

View File

@@ -51,20 +51,24 @@
container: column / inline-size;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
@media ($mobile-menu-breakpoint < width < $no-gap-breakpoint) {
border-inline: 1px solid var(--color-border-primary);
}
}
.columnHeader {
background: var(--color-bg-blend-solid);
// Make bottom half of column header transparent to make the
// overlay gradient of the nested `ColumnHeader` component work
--column-header-gradient-start: 55%;
background: linear-gradient(
to bottom,
var(--color-bg-blend-solid) var(--column-header-gradient-start),
transparent var(--column-header-gradient-start)
);
position: sticky;
top: 0;
z-index: 2;
border-top: 0;
@media (width >= $no-gap-breakpoint) {
@media (width >= $mobile-menu-breakpoint) {
border-top: 10px solid var(--color-bg-blend-solid);
}
@@ -74,3 +78,22 @@
background: var(--color-bg-primary);
}
}
.content {
width: 100%;
height: 100%;
margin: 0 auto;
:global(.column) {
width: 100%;
height: 100%;
padding: 0;
}
:global(.scrollable) {
contain: content;
overflow: visible;
border: none;
border-radius: 0;
}
}

View File

@@ -15,7 +15,7 @@ import { useColumnsContext } from '../../util/columns_context';
import { MultiColumnContent } from './multi_column_content';
import classes from './redesign.module.scss';
const TabsBarPortal = () => {
const TabsBarPortal: React.FC<React.ComponentProps<'div'>> = (props) => {
const { setTabsBarElement } = useColumnsContext();
const setRef = useCallback(
@@ -27,7 +27,7 @@ const TabsBarPortal = () => {
[setTabsBarElement],
);
return <div id='tabs-bar__portal' ref={setRef} />;
return <div {...props} ref={setRef} />;
};
export const ColumnsAreaRedesign: React.FC<{
@@ -47,11 +47,9 @@ export const ColumnsAreaRedesign: React.FC<{
<div className={classes.main}>
<Header />
<div className='tabs-bar__wrapper'>
<TabsBarPortal />
</div>
<TabsBarPortal className={classes.columnHeader} />
<div className='columns-area columns-area--mobile'>{children}</div>
<div className={classes.content}>{children}</div>
<Footer />
</div>
@@ -68,11 +66,9 @@ export const ColumnsAreaRedesign: React.FC<{
{isMobile ? <RedesignMobileNavigation /> : <ComposeRedesignButton />}
<main className={classes.main}>
<div className={classes.columnHeader}>
<TabsBarPortal />
</div>
<TabsBarPortal className={classes.columnHeader} />
<div className='columns-area columns-area--mobile'>{children}</div>
<div className={classes.content}>{children}</div>
</main>
</div>
);

View File

@@ -16,7 +16,7 @@ export function useColumnsContext() {
return useContext(ColumnsContext);
}
export const ButtonInTabsBar: React.FC<{
export const RenderIntoTabsBarPortal: React.FC<{
children: ReactElement | string | undefined;
}> = ({ children }) => {
const { multiColumn, tabsBarElement } = useColumnsContext();

View File

@@ -470,6 +470,7 @@
"column.other_collections": "Collections by {name}",
"column.pins": "Pinned posts",
"column.public": "Federated timeline",
"column.saved_posts": "Saved Posts",
"column.your_collections": "Your Collections",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
@@ -1335,6 +1336,7 @@
"status.filter": "Filter this post",
"status.history.created": "{name} created {date}",
"status.history.edited": "{name} edited {date}",
"status.likes_title": "Post Likes",
"status.load_more": "Load more",
"status.media.open": "Click to open",
"status.media.show": "Click to show",