From ec76288dff7330ef243a2e4230f95d37b45823d6 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 28 Jan 2026 11:17:32 +0100 Subject: [PATCH] Profile redesign: Timeline filters (#37626) --- .../mastodon/actions/timelines.test.ts | 60 ++++++ .../mastodon/actions/timelines_typed.ts | 148 ++++++++++++++- .../components/form_fields/toggle.module.css | 5 + .../components/form_fields/toggle_field.tsx | 4 +- .../components/redesign.module.scss | 27 ++- .../account_timeline/components/tabs.tsx | 19 ++ .../account_timeline/hooks/useFilters.ts | 28 +++ .../features/account_timeline/v2/filters.tsx | 146 +++++++++++++++ .../features/account_timeline/v2/index.tsx | 172 ++++++++++++++++++ .../account_timeline/v2/styles.module.scss | 37 ++++ .../features/ui/util/async-components.js | 5 + app/javascript/mastodon/locales/en.json | 7 + .../mastodon/selectors/timelines.ts | 51 ++++++ 13 files changed, 704 insertions(+), 5 deletions(-) create mode 100644 app/javascript/mastodon/actions/timelines.test.ts create mode 100644 app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts create mode 100644 app/javascript/mastodon/features/account_timeline/v2/filters.tsx create mode 100644 app/javascript/mastodon/features/account_timeline/v2/index.tsx create mode 100644 app/javascript/mastodon/features/account_timeline/v2/styles.module.scss create mode 100644 app/javascript/mastodon/selectors/timelines.ts diff --git a/app/javascript/mastodon/actions/timelines.test.ts b/app/javascript/mastodon/actions/timelines.test.ts new file mode 100644 index 0000000000..e7f4198cde --- /dev/null +++ b/app/javascript/mastodon/actions/timelines.test.ts @@ -0,0 +1,60 @@ +import { parseTimelineKey, timelineKey } from './timelines_typed'; + +describe('timelineKey', () => { + test('returns expected key for account timeline with filters', () => { + const key = timelineKey({ + type: 'account', + userId: '123', + replies: true, + boosts: false, + media: true, + }); + expect(key).toBe('account:123:0110'); + }); + + test('returns expected key for account timeline with tag', () => { + const key = timelineKey({ + type: 'account', + userId: '456', + tagged: 'nature', + replies: true, + }); + expect(key).toBe('account:456:0100:nature'); + }); + + test('returns expected key for account timeline with pins', () => { + const key = timelineKey({ + type: 'account', + userId: '789', + pinned: true, + }); + expect(key).toBe('account:789:0001'); + }); +}); + +describe('parseTimelineKey', () => { + test('parses account timeline key with filters correctly', () => { + const params = parseTimelineKey('account:123:1010'); + expect(params).toEqual({ + type: 'account', + userId: '123', + boosts: true, + replies: false, + media: true, + pinned: false, + }); + }); + + test('parses account timeline key with tag correctly', () => { + const params = parseTimelineKey('account:456:0100:nature'); + expect(params).toEqual({ + type: 'account', + userId: '456', + replies: true, + boosts: false, + media: false, + pinned: false, + tagged: 'nature', + }); + }); +}); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index e846882660..d1fcfb1c65 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; -import { TIMELINE_NON_STATUS_MARKERS } from './timelines'; +import { createAppThunk } from '../store/typed_functions'; + +import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines'; + +export const expandTimelineByKey = createAppThunk( + (args: { key: string; maxId?: number }, { dispatch }) => { + const params = parseTimelineKey(args.key); + if (!params) { + return; + } + + void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId })); + }, +); + +export const expandTimelineByParams = createAppThunk( + (params: TimelineParams & { maxId?: number }, { dispatch }) => { + let url = ''; + const extra: Record = {}; + + if (params.type === 'account') { + url = `/api/v1/accounts/${params.userId}/statuses`; + + if (!params.replies) { + extra.exclude_replies = true; + } + if (!params.boosts) { + extra.exclude_reblogs = true; + } + if (params.pinned) { + extra.pinned = true; + } + if (params.media) { + extra.only_media = true; + } + if (params.tagged) { + extra.tagged = params.tagged; + } + } else if (params.type === 'public') { + url = '/api/v1/timelines/public'; + } + + if (params.maxId) { + extra.max_id = params.maxId.toString(); + } + + return dispatch(expandTimeline(timelineKey(params), url, extra)); + }, +); + +export interface AccountTimelineParams { + type: 'account'; + userId: string; + tagged?: string; + media?: boolean; + pinned?: boolean; + boosts?: boolean; + replies?: boolean; +} +export type PublicTimelineServer = 'local' | 'remote' | 'all'; +export interface PublicTimelineParams { + type: 'public'; + tagged?: string; + server?: PublicTimelineServer; // Defaults to 'all' + media?: boolean; +} +export interface HomeTimelineParams { + type: 'home'; +} +export type TimelineParams = + | AccountTimelineParams + | PublicTimelineParams + | HomeTimelineParams; + +const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const; + +export function timelineKey(params: TimelineParams): string { + const { type } = params; + const key: string[] = [type]; + + if (type === 'account') { + key.push(params.userId); + + const view = ACCOUNT_FILTERS.reduce( + (prev, curr) => prev + (params[curr] ? '1' : '0'), + '', + ); + + key.push(view); + } else if (type === 'public') { + key.push(params.server ?? 'all'); + if (params.media) { + key.push('media'); + } + } + + if (type !== 'home' && params.tagged) { + key.push(params.tagged); + } + + return key.filter(Boolean).join(':'); +} + +export function parseTimelineKey(key: string): TimelineParams | null { + const segments = key.split(':'); + const type = segments[0]; + + if (type === 'account') { + const userId = segments[1]; + if (!userId) { + return null; + } + + const parsed: TimelineParams = { + type: 'account', + userId, + tagged: segments[3], + }; + + const view = segments[2]?.split('') ?? []; + for (let i = 0; i < view.length; i++) { + const flagName = ACCOUNT_FILTERS[i]; + if (flagName) { + parsed[flagName] = view[i] === '1'; + } + } + return parsed; + } + + if (type === 'public') { + return { + type: 'public', + server: + segments[1] === 'remote' || segments[1] === 'local' + ? segments[1] + : 'all', + tagged: segments[2], + media: segments[3] === 'media', + }; + } + + if (type === 'home') { + return { type: 'home' }; + } + + return null; +} export function isNonStatusId(value: unknown) { return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null); diff --git a/app/javascript/mastodon/components/form_fields/toggle.module.css b/app/javascript/mastodon/components/form_fields/toggle.module.css index c2d3f57bcc..997434f336 100644 --- a/app/javascript/mastodon/components/form_fields/toggle.module.css +++ b/app/javascript/mastodon/components/form_fields/toggle.module.css @@ -68,3 +68,8 @@ :global([dir='rtl']) .input:checked + .toggle::before { transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2)))); } + +.wrapper { + display: inline-block; + position: relative; +} diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.tsx index a116c001bc..e14bb54ad1 100644 --- a/app/javascript/mastodon/components/form_fields/toggle_field.tsx +++ b/app/javascript/mastodon/components/form_fields/toggle_field.tsx @@ -32,7 +32,7 @@ ToggleField.displayName = 'ToggleField'; export const PlainToggleField = forwardRef( ({ className, size, ...otherProps }, ref) => ( - <> + ( } hidden /> - + ), ); PlainToggleField.displayName = 'PlainToggleField'; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index 028e2b41dc..f7a0bb8bbf 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index c08de1390e..f525264ed4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -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 ( +
+ + + + + + + + + +
+ ); + } return (
diff --git a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts new file mode 100644 index 0000000000..d979d895ac --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts @@ -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, + }; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx new file mode 100644 index 0000000000..874d653c20 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx @@ -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 ( + <> + +
+ +
+ + ); +}; + +const FilterDropdown: FC = () => { + const [open, setOpen] = useState(false); + const buttonRef = useRef(null); + + const handleClick = useCallback(() => { + setOpen(true); + }, []); + const handleHide = useCallback(() => { + setOpen(false); + }, []); + + const { boosts, replies, setBoosts, setReplies } = useFilters(); + const handleChange: ChangeEventHandler = 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(null); + + return ( +
+ + + {({ props }) => ( +
+ + + + + +
+ )} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx new file mode 100644 index 0000000000..4254e3d7eb --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -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(); + +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 ; + } + + if (!accountId) { + return ( + + + + ); + } + + // Add this key to remount the timeline when accountId changes. + return ( + + ); +}; + +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 ( + + + + + } + append={} + 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={} + bindToDocument={!multiColumn} + timelineId='account' + withCounters + /> + + ); +}; + +const Prepend: FC<{ + accountId: string; + tagged?: string; + forceEmpty: boolean; +}> = ({ forceEmpty, accountId, tagged }) => { + if (forceEmpty) { + return ; + } + + return ( + <> + + + + + ); +}; + +const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => { + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + if (suspended) { + return ( + + ); + } else if (hidden) { + return ; + } else if (blockedBy) { + return ( + + ); + } + + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default AccountTimelineV2; diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss new file mode 100644 index 0000000000..a8ba29afa5 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 976b1fb13f..4d31f6a4c4 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -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'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 64329bcc2c..1468848715 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -14,6 +14,7 @@ "about.powered_by": "Decentralized social media powered by {mastodon}", "about.rules": "Server rules", "account.account_note_header": "Personal note", + "account.activity": "Activity", "account.add_note": "Add a personal note", "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Automated", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", + "account.filters.all": "All activity", + "account.filters.boosts_toggle": "Show boosts", + "account.filters.posts_boosts": "Posts and boosts", + "account.filters.posts_only": "Posts", + "account.filters.posts_replies": "Posts and replies", + "account.filters.replies_toggle": "Show replies", "account.follow": "Follow", "account.follow_back": "Follow back", "account.follow_back_short": "Follow back", diff --git a/app/javascript/mastodon/selectors/timelines.ts b/app/javascript/mastodon/selectors/timelines.ts new file mode 100644 index 0000000000..5db50ea894 --- /dev/null +++ b/app/javascript/mastodon/selectors/timelines.ts @@ -0,0 +1,51 @@ +import type { Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; + +import type { TimelineParams } from '../actions/timelines_typed'; +import { timelineKey } from '../actions/timelines_typed'; +import { createAppSelector } from '../store'; + +interface TimelineShape { + unread: number; + online: boolean; + top: boolean; + isLoading: boolean; + hasMore: boolean; + pendingItems: ImmutableList; + items: ImmutableList; +} + +type TimelinesState = ImmutableMap>; + +const emptyList = ImmutableList(); + +export const selectTimelineByKey = createAppSelector( + [(_, key: string) => key, (state) => state.timelines as TimelinesState], + (key, timelines) => toTypedTimeline(timelines.get(key)), +); + +export const selectTimelineByParams = createAppSelector( + [ + (_, params: TimelineParams) => timelineKey(params), + (state) => state.timelines as TimelinesState, + ], + (key, timelines) => toTypedTimeline(timelines.get(key)), +); + +export function toTypedTimeline(timeline?: ImmutableMap) { + if (!timeline) { + return null; + } + return { + unread: timeline.get('unread', 0) as number, + online: !!timeline.get('online', false), + top: !!timeline.get('top', false), + isLoading: !!timeline.get('isLoading', true), + hasMore: !!timeline.get('hasMore', false), + pendingItems: timeline.get( + 'pendingItems', + emptyList, + ) as ImmutableList, + items: timeline.get('items', emptyList) as ImmutableList, + } as TimelineShape; +}