Profile redesign: Timeline filters (#37626)

This commit is contained in:
Echo authored and GitHub committed 2026-01-28 10:17:32 +00:00
1 parent 3f46034039
commit ec76288dff
13 files changed
+704 -5

No files matched your search

@@ -49,7 +49,7 @@
background-color: var(--color-bg-secondary);
border: none;
color: var(--color-text-secondary);
font-weight: 600;
font-weight: 500;
> span {
font-weight: unset;
@@ -145,7 +145,7 @@ svg.badgeIcon {
}
dd {
font-weight: 600;
font-weight: 500;
font-size: 15px;
}
@@ -154,3 +154,26 @@ svg.badgeIcon {
margin-left: 4px;
}
}
.tabs {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 24px;
a {
display: block;
font-size: 15px;
font-weight: 500;
padding: 18px 4px;
text-decoration: none;
color: var(--color-text-primary);
border-radius: 0;
}
:global(.active) {
color: var(--color-text-brand);
border-bottom: 4px solid var(--color-text-brand);
padding-bottom: 14px;
}
}
@@ -4,7 +4,26 @@ import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
if (isRedesignEnabled()) {
return (
<div className={classes.tabs}>
<NavLink exact to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</NavLink>
<NavLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</NavLink>
</div>
);
}
return (
<div className='account__section-headline'>
<NavLink exact to={`/@${acct}/featured`}>
@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
export function useFilters() {
const [boosts, setBoosts] = useSearchParam('boosts');
const [replies, setReplies] = useSearchParam('replies');
const handleSetBoosts = useCallback(
(value: boolean) => {
setBoosts(value ? '1' : null);
},
[setBoosts],
);
const handleSetReplies = useCallback(
(value: boolean) => {
setReplies(value ? '1' : null);
},
[setReplies],
);
return {
boosts: boosts === '1',
replies: replies === '1',
setBoosts: handleSetBoosts,
setReplies: handleSetReplies,
};
}
@@ -0,0 +1,146 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ChangeEventHandler, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import Overlay from 'react-overlays/esm/Overlay';
import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field';
import { Icon } from '@/mastodon/components/icon';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
import { AccountTabs } from '../components/tabs';
import { useFilters } from '../hooks/useFilters';
import classes from './styles.module.scss';
export const AccountFilters: FC = () => {
const { acct } = useParams<{ acct: string }>();
if (!acct) {
return null;
}
return (
<>
<AccountTabs acct={acct} />
<div className={classes.filtersWrapper}>
<FilterDropdown />
</div>
</>
);
};
const FilterDropdown: FC = () => {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
setOpen(true);
}, []);
const handleHide = useCallback(() => {
setOpen(false);
}, []);
const { boosts, replies, setBoosts, setReplies } = useFilters();
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const { name, checked } = event.target;
if (name === 'boosts') {
setBoosts(checked);
} else if (name === 'replies') {
setReplies(checked);
}
},
[setBoosts, setReplies],
);
const accessibleId = useId();
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef}>
<button
type='button'
className={classes.filterSelectButton}
ref={buttonRef}
onClick={handleClick}
aria-expanded={open}
aria-controls={`${accessibleId}-wrapper`}
>
{boosts && replies && (
<FormattedMessage
id='account.filters.all'
defaultMessage='All activity'
/>
)}
{!boosts && replies && (
<FormattedMessage
id='account.filters.posts_replies'
defaultMessage='Posts and replies'
/>
)}
{boosts && !replies && (
<FormattedMessage
id='account.filters.posts_boosts'
defaultMessage='Posts and boosts'
/>
)}
{!boosts && !replies && (
<FormattedMessage
id='account.filters.posts_only'
defaultMessage='Posts'
/>
)}
<Icon
id='unfold_more'
icon={KeyboardArrowDownIcon}
className={classes.filterSelectIcon}
/>
</button>
<Overlay
show={open}
target={buttonRef}
flip
placement='bottom-start'
rootClose
onHide={handleHide}
container={containerRef}
>
{({ props }) => (
<div
{...props}
id={`${accessibleId}-wrapper`}
className={classes.filterOverlay}
>
<label htmlFor={`${accessibleId}-replies`}>
<FormattedMessage
id='account.filters.replies_toggle'
defaultMessage='Show replies'
/>
</label>
<PlainToggleField
name='replies'
checked={replies}
onChange={handleChange}
id={`${accessibleId}-replies`}
/>
<label htmlFor={`${accessibleId}-boosts`}>
<FormattedMessage
id='account.filters.boosts_toggle'
defaultMessage='Show boosts'
/>
</label>
<PlainToggleField
name='boosts'
checked={boosts}
onChange={handleChange}
id={`${accessibleId}-boosts`}
/>
</div>
)}
</Overlay>
</div>
);
};
@@ -0,0 +1,172 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { List as ImmutableList } from 'immutable';
import {
expandTimelineByKey,
timelineKey,
} from '@/mastodon/actions/timelines_typed';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import { RemoteHint } from '@/mastodon/components/remote_hint';
import StatusList from '@/mastodon/components/status_list';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { AccountHeader } from '../components/account_header';
import { LimitedAccountHint } from '../components/limited_account_hint';
import { useFilters } from '../hooks/useFilters';
import { AccountFilters } from './filters';
const emptyList = ImmutableList<string>();
const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
// Add this key to remount the timeline when accountId changes.
return (
<InnerTimeline
accountId={accountId}
key={accountId}
multiColumn={multiColumn}
/>
);
};
const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
accountId,
multiColumn,
}) => {
const { tagged } = useParams<{ tagged?: string }>();
const { boosts, replies } = useFilters();
const key = timelineKey({
type: 'account',
userId: accountId,
tagged,
boosts,
replies,
});
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const dispatch = useAppDispatch();
useEffect(() => {
if (!timeline && !!accountId) {
dispatch(expandTimelineByKey({ key }));
}
}, [accountId, dispatch, key, timeline]);
const handleLoadMore = useCallback(
(maxId: number) => {
if (accountId) {
dispatch(expandTimelineByKey({ key, maxId }));
}
},
[accountId, dispatch, key],
);
const forceEmptyState = blockedBy || hidden || suspended;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<StatusList
alwaysPrepend
prepend={
<Prepend
accountId={accountId}
tagged={tagged}
forceEmpty={forceEmptyState}
/>
}
append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline'
// We want to have this component when timeline is undefined (loading),
// because if we don't the prepended component will re-render with every filter change.
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
isLoading={!!timeline?.isLoading}
hasMore={!forceEmptyState && !!timeline?.hasMore}
onLoadMore={handleLoadMore}
emptyMessage={<EmptyMessage accountId={accountId} />}
bindToDocument={!multiColumn}
timelineId='account'
withCounters
/>
</Column>
);
};
const Prepend: FC<{
accountId: string;
tagged?: string;
forceEmpty: boolean;
}> = ({ forceEmpty, accountId, tagged }) => {
if (forceEmpty) {
return <AccountHeader accountId={accountId} hideTabs />;
}
return (
<>
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedCarousel accountId={accountId} tagged={tagged} />
</>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_timeline'
defaultMessage='No posts found'
/>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountTimelineV2;
@@ -0,0 +1,37 @@
.filtersWrapper {
padding: 16px 24px 8px;
}
.filterSelectButton {
appearance: none;
border: none;
background: none;
padding: 8px 0;
font-weight: 500;
display: flex;
align-items: center;
}
.filterSelectIcon {
width: 16px;
height: 16px;
}
.filterOverlay {
background: var(--color-bg-elevated);
border-radius: 12px;
box-shadow: var(--dropdown-shadow);
min-width: 230px;
display: grid;
grid-template-columns: 1fr auto;
align-items: stretch;
row-gap: 16px;
padding: 8px 12px;
z-index: 1;
> label {
cursor: pointer;
display: flex;
align-items: center;
}
}
@@ -1,3 +1,5 @@
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
export function EmojiPicker () {
return import('../../emoji/emoji_picker');
}
@@ -65,6 +67,9 @@ export function PinnedStatuses () {
}
export function AccountTimeline () {
if (isClientFeatureEnabled('profile_redesign')) {
return import('../../account_timeline/v2');
}
return import('../../account_timeline');
}