From 6507a61d30078982855f904d649f4b60588bb4f9 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 20 Mar 2026 14:38:10 +0100 Subject: [PATCH] Profile redesign: Profile tab settings (#38309) --- app/javascript/mastodon/actions/timelines.js | 2 +- app/javascript/mastodon/api_types/accounts.ts | 3 + .../features/account_featured/index.tsx | 24 ++-- .../features/account_gallery/index.tsx | 113 ++++++++++-------- .../account_timeline/components/tabs.tsx | 46 +++++-- .../mastodon/features/link_timeline/index.tsx | 3 +- app/javascript/mastodon/models/account.ts | 3 + app/javascript/mastodon/reducers/timelines.js | 4 + app/javascript/testing/factories.ts | 3 + 9 files changed, 129 insertions(+), 72 deletions(-) diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index c6c2a864a8f..cb9109b7692 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -158,7 +158,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }); export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId }); export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged }); -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); +export const expandAccountMediaTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}:media${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, exclude_replies: !withReplies }); export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); export const expandLinkTimeline = (url, { maxId } = {}) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }); export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}) => { diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 9fe076ce96d..351f3245cc6 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -53,6 +53,9 @@ export interface BaseApiAccountJSON { id: string; last_status_at: string; locked: boolean; + show_media: boolean; + show_media_replies: boolean; + show_featured: boolean; noindex?: boolean; note: string; roles?: ApiAccountJSON[]; diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 57edf04b646..59e632aa105 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -2,10 +2,12 @@ import { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useParams } from 'react-router'; +import { useHistory } from 'react-router'; import { List as ImmutableList } from 'immutable'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; import { fetchEndorsedAccounts } from 'mastodon/actions/accounts'; import { fetchFeaturedTags } from 'mastodon/actions/featured_tags'; import { Account } from 'mastodon/components/account'; @@ -35,21 +37,27 @@ import { EmptyMessage } from './components/empty_message'; import { FeaturedTag } from './components/featured_tag'; import type { TagMap } from './components/featured_tag'; -interface Params { - acct?: string; - id?: string; -} - const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ multiColumn, }) => { const accountId = useAccountId(); + const account = useAccount(accountId); const { suspended, blockedBy, hidden } = useAccountVisibility(accountId); const forceEmptyState = suspended || blockedBy || hidden; - const { acct = '' } = useParams(); const dispatch = useAppDispatch(); + const history = useHistory(); + useEffect(() => { + if ( + account && + !account.show_featured && + isServerFeatureEnabled('profile_redesign') + ) { + history.push(`/@${account.acct}`); + } + }, [account, history]); + useEffect(() => { if (accountId) { void dispatch(fetchFeaturedTags({ accountId })); @@ -166,7 +174,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ aria-posinset={index + 1} aria-setsize={featuredTags.size} > - + ))} diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx index 594f71cb232..52f30ac5057 100644 --- a/app/javascript/mastodon/features/account_gallery/index.tsx +++ b/app/javascript/mastodon/features/account_gallery/index.tsx @@ -2,10 +2,9 @@ import { useEffect, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { createSelector } from '@reduxjs/toolkit'; -import type { Map as ImmutableMap } from 'immutable'; -import { List as ImmutableList } from 'immutable'; +import { List as ImmutableList, isList } from 'immutable'; +import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; import { openModal } from 'mastodon/actions/modal'; import { expandAccountMediaTimeline } from 'mastodon/actions/timelines'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; @@ -18,38 +17,69 @@ import Column from 'mastodon/features/ui/components/column'; import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; import type { MediaAttachment } from 'mastodon/models/media_attachment'; -import type { RootState } from 'mastodon/store'; -import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { + useAppSelector, + useAppDispatch, + createAppSelector, +} from 'mastodon/store'; import { MediaItem } from './components/media_item'; -const getAccountGallery = createSelector( +const emptyList = ImmutableList(); + +const redesignEnabled = isServerFeatureEnabled('profile_redesign'); + +const selectGalleryTimeline = createAppSelector( [ - (state: RootState, accountId: string) => - (state.timelines as ImmutableMap).getIn( - [`account:${accountId}:media`, 'items'], - ImmutableList(), - ) as ImmutableList, - (state: RootState) => state.statuses, + (_state, accountId?: string | null) => accountId, + (state) => state.timelines, + (state) => state.accounts, + (state) => state.statuses, ], - (statusIds, statuses) => { - let items = ImmutableList(); + (accountId, timelines, accounts, statuses) => { + if (!accountId) { + return null; + } + const account = accounts.get(accountId); + if (!account) { + return null; + } - statusIds.forEach((statusId) => { - const status = statuses.get(statusId) as - | ImmutableMap - | undefined; + let items = emptyList; + const { show_media, show_media_replies } = account; + // If the account disabled showing media, don't display anything. + if (!show_media && redesignEnabled) { + return { + items, + hasMore: false, + isLoading: false, + showingReplies: false, + }; + } - if (status) { + const showingReplies = show_media_replies && redesignEnabled; + const timeline = timelines.get( + `account:${accountId}:media${showingReplies ? ':with_replies' : ''}`, + ); + const statusIds = timeline?.get('items'); + + if (isList(statusIds)) { + for (const statusId of statusIds) { + const status = statuses.get(statusId); items = items.concat( ( - status.get('media_attachments') as ImmutableList + status?.get('media_attachments') as ImmutableList ).map((media) => media.set('status', status)), ); } - }); + } - return items; + return { + items, + hasMore: !!timeline?.get('hasMore'), + isLoading: !!timeline?.get('isLoading'), + showingReplies, + }; }, ); @@ -58,27 +88,12 @@ export const AccountGallery: React.FC<{ }> = ({ multiColumn }) => { const dispatch = useAppDispatch(); const accountId = useAccountId(); - const attachments = useAppSelector((state) => - accountId - ? getAccountGallery(state, accountId) - : ImmutableList(), - ); - const isLoading = useAppSelector((state) => - (state.timelines as ImmutableMap).getIn([ - `account:${accountId}:media`, - 'isLoading', - ]), - ); - const hasMore = useAppSelector((state) => - (state.timelines as ImmutableMap).getIn([ - `account:${accountId}:media`, - 'hasMore', - ]), - ); - const account = useAppSelector((state) => - accountId ? state.accounts.get(accountId) : undefined, - ); - const isAccount = !!account; + const { + isLoading = true, + hasMore = false, + items: attachments = emptyList, + showingReplies: withReplies = false, + } = useAppSelector((state) => selectGalleryTimeline(state, accountId)) ?? {}; const { suspended, blockedBy, hidden } = useAccountVisibility(accountId); @@ -87,16 +102,18 @@ export const AccountGallery: React.FC<{ | undefined; useEffect(() => { - if (accountId && isAccount) { - void dispatch(expandAccountMediaTimeline(accountId)); + if (accountId) { + void dispatch(expandAccountMediaTimeline(accountId, { withReplies })); } - }, [dispatch, accountId, isAccount]); + }, [dispatch, accountId, withReplies]); const handleLoadMore = useCallback(() => { if (maxId) { - void dispatch(expandAccountMediaTimeline(accountId, { maxId })); + void dispatch( + expandAccountMediaTimeline(accountId, { maxId, withReplies }), + ); } - }, [dispatch, accountId, maxId]); + }, [maxId, dispatch, accountId, withReplies]); const handleOpenMedia = useCallback( (attachment: MediaAttachment) => { diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index eeb48c1c532..5febb8eaf8c 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -5,25 +5,16 @@ import { FormattedMessage } from 'react-intl'; import type { NavLinkProps } from 'react-router-dom'; import { NavLink } from 'react-router-dom'; +import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; + import { isRedesignEnabled } from '../common'; import classes from './redesign.module.scss'; export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { if (isRedesignEnabled()) { - return ( -
- - - - - - - - - -
- ); + return ; } return (
@@ -49,3 +40,32 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { const isActive: Required['isActive'] = (match, location) => match?.url === location.pathname || (!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`)); + +const RedesignTabs: FC = () => { + const accountId = useAccountId(); + const account = useAccount(accountId); + + if (!account) { + return null; + } + + const { acct, show_featured, show_media } = account; + + return ( +
+ + + + {show_media && ( + + + + )} + {show_featured && ( + + + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/features/link_timeline/index.tsx b/app/javascript/mastodon/features/link_timeline/index.tsx index e6b8480a24d..fd9e1577731 100644 --- a/app/javascript/mastodon/features/link_timeline/index.tsx +++ b/app/javascript/mastodon/features/link_timeline/index.tsx @@ -21,8 +21,7 @@ export const LinkTimeline: React.FC<{ const columnRef = useRef(null); const firstStatusId = useAppSelector((state) => decodedUrl - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - (state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string) + ? (state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string) : undefined, ); const story = useAppSelector((state) => diff --git a/app/javascript/mastodon/models/account.ts b/app/javascript/mastodon/models/account.ts index c8109fffb50..6248d8e97b6 100644 --- a/app/javascript/mastodon/models/account.ts +++ b/app/javascript/mastodon/models/account.ts @@ -82,6 +82,9 @@ export const accountDefaultValues: AccountShape = { last_status_at: '', locked: false, noindex: false, + show_featured: true, + show_media: true, + show_media_replies: true, note: '', note_emojified: '', note_plain: 'string', diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index ae9ea345820..add56179308 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -28,6 +28,7 @@ import { } from '../actions/timelines_typed'; import { compareId } from '../compare_id'; +/** @type {ImmutableMap} */ const initialState = ImmutableMap(); const initialTimeline = ImmutableMap({ @@ -36,7 +37,9 @@ const initialTimeline = ImmutableMap({ top: true, isLoading: false, hasMore: true, + /** @type {ImmutableList} */ pendingItems: ImmutableList(), + /** @type {ImmutableList} */ items: ImmutableList(), }); @@ -197,6 +200,7 @@ const reconnectTimeline = (state, usePendingItems) => { }); }; +/** @type {import('@reduxjs/toolkit').Reducer} */ export default function timelines(state = initialState, action) { switch(action.type) { case TIMELINE_LOAD_PENDING: diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index 1efd22c3c97..e345e08351a 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -45,6 +45,9 @@ export const accountFactory: FactoryFunction = ({ indexable: true, last_status_at: '2023-01-01', locked: false, + show_featured: true, + show_media: true, + show_media_replies: true, mute_expires_at: null, note: 'This is a test user account.', statuses_count: 0,