From 440466c246a6ad372ce2c66eb56acc678ff600c4 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 25 Feb 2026 15:14:55 +0100 Subject: [PATCH] Display public collections on profile "Featured tab" (#37967) --- .../features/account_featured/index.tsx | 37 +++++++++++++++++++ .../detail/collection_list_item.module.scss | 10 +++-- .../detail/collection_list_item.tsx | 9 ++++- .../collections/detail/collection_menu.tsx | 31 ++++++++++++---- .../features/collections/detail/index.tsx | 35 +++++++++++++++--- .../mastodon/features/collections/index.tsx | 6 ++- app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/slices/collections.ts | 10 +++-- 8 files changed, 115 insertions(+), 24 deletions(-) diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 45f2ccb1d7..db01c5b272 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -17,8 +17,15 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err import Column from 'mastodon/features/ui/components/column'; import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; +import { + fetchAccountCollections, + selectAccountCollections, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { CollectionListItem } from '../collections/detail/collection_list_item'; +import { areCollectionsEnabled } from '../collections/utils'; + import { EmptyMessage } from './components/empty_message'; import { FeaturedTag } from './components/featured_tag'; import type { TagMap } from './components/featured_tag'; @@ -42,6 +49,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ if (accountId) { void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId })); + if (areCollectionsEnabled()) { + void dispatch(fetchAccountCollections({ accountId })); + } } }, [accountId, dispatch]); @@ -64,6 +74,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ImmutableList(), ) as ImmutableList, ); + const { collections, status } = useAppSelector((state) => + selectAccountCollections(state, accountId ?? null), + ); + const publicCollections = collections.filter( + // This filter only applies when viewing your own profile, where the endpoint + // returns all collections, but we hide unlisted ones here to avoid confusion + (item) => item.discoverable, + ); if (accountId === null) { return ; @@ -101,6 +119,25 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ {accountId && ( )} + {publicCollections.length > 0 && status === 'idle' && ( + <> +

+ +

+
+ {publicCollections.map((item, index) => ( + + ))} +
+ + )} {!featuredTags.isEmpty() && ( <>

diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss index 74eac9f3e1..9e771dbaa0 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss @@ -2,15 +2,17 @@ display: flex; align-items: center; gap: 16px; - margin-inline: 10px; - padding-inline-end: 5px; - border-bottom: 1px solid var(--color-border-primary); + padding-inline: 16px; + + &:not(.wrapperWithoutBorder) { + border-bottom: 1px solid var(--color-border-primary); + } } .content { position: relative; flex-grow: 1; - padding: 15px 5px; + padding-block: 15px; } .link { diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx index fe5aa50047..1a7e18b521 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -67,13 +67,18 @@ export const CollectionMetaData: React.FC<{ export const CollectionListItem: React.FC<{ collection: ApiCollectionJSON; -}> = ({ collection }) => { + withoutBorder?: boolean; +}> = ({ collection, withoutBorder }) => { const { id, name } = collection; const linkId = useId(); return (
diff --git a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx index ba17e8b1ec..b236c1cadb 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx @@ -2,6 +2,8 @@ import { useCallback, useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { matchPath } from 'react-router'; + import { useAccount } from '@/mastodon/hooks/useAccount'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import { openModal } from 'mastodon/actions/modal'; @@ -9,6 +11,7 @@ import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { IconButton } from 'mastodon/components/icon_button'; import { me } from 'mastodon/initial_state'; +import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppDispatch } from 'mastodon/store'; import { messages as editorMessages } from '../editor'; @@ -70,7 +73,7 @@ export const CollectionMenu: React.FC<{ const menu = useMemo(() => { if (isOwnCollection) { - const commonItems = [ + const commonItems: MenuItem[] = [ { text: intl.formatMessage(editorMessages.manageAccounts), to: `/collections/${id}/edit`, @@ -97,17 +100,31 @@ export const CollectionMenu: React.FC<{ return commonItems; } } else if (ownerAccount) { - return [ - { - text: intl.formatMessage(messages.viewOtherCollections), - to: `/@${ownerAccount.acct}/featured`, - }, - null, + const items: MenuItem[] = [ { text: intl.formatMessage(messages.report), action: openReportModal, }, ]; + const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`; + // Don't show menu link to featured collections while on that very page + if ( + !matchPath(location.pathname, { + path: featuredCollectionsPath, + exact: true, + }) + ) { + items.unshift( + ...[ + { + text: intl.formatMessage(messages.viewOtherCollections), + to: featuredCollectionsPath, + }, + null, + ], + ); + } + return items; } else { return []; } diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 9ba4813cc8..d5b14da859 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; import { useParams } from 'react-router'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import { showAlert } from 'mastodon/actions/alerts'; @@ -123,6 +124,28 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ ); }; +const CollectionAccountItem: React.FC<{ + accountId: string | undefined; + collectionOwnerId: string; +}> = ({ accountId, collectionOwnerId }) => { + const relationship = useRelationship(accountId); + + if (!accountId) { + return null; + } + + // When viewing your own collection, only show the Follow button + // for accounts you're not following (anymore). + // Otherwise, always show the follow button in its various states. + const withoutButton = + accountId === me || + !relationship || + (collectionOwnerId === me && + (relationship.following || relationship.requested)); + + return ; +}; + export const CollectionDetailPage: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -163,11 +186,13 @@ export const CollectionDetailPage: React.FC<{ collection ? : null } > - {collection?.items.map(({ account_id }) => - account_id ? ( - - ) : null, - )} + {collection?.items.map(({ account_id }) => ( + + ))} diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 607d7fe4f3..24819cf755 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -14,7 +14,7 @@ import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; import { fetchAccountCollections, - selectMyCollections, + selectAccountCollections, } from 'mastodon/reducers/slices/collections'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -31,7 +31,9 @@ export const Collections: React.FC<{ const dispatch = useAppDispatch(); const intl = useIntl(); const me = useAppSelector((state) => state.meta.get('me') as string); - const { collections, status } = useAppSelector(selectMyCollections); + const { collections, status } = useAppSelector((state) => + selectAccountCollections(state, me), + ); useEffect(() => { void dispatch(fetchAccountCollections({ accountId: me })); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 151c5d72a6..d3d9a2a4f3 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -45,6 +45,7 @@ "account.familiar_followers_two": "Followed by {name1} and {name2}", "account.featured": "Featured", "account.featured.accounts": "Profiles", + "account.featured.collections": "Collections", "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index 77969ffcad..c3bec4b1c6 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -229,14 +229,16 @@ interface AccountCollectionQuery { collections: ApiCollectionJSON[]; } -export const selectMyCollections = createAppSelector( +export const selectAccountCollections = createAppSelector( [ - (state) => state.meta.get('me') as string, + (_, accountId: string | null) => accountId, (state) => state.collections.accountCollections, (state) => state.collections.collections, ], - (me, collectionsByAccountId, collectionsMap) => { - const myCollectionsQuery = collectionsByAccountId[me]; + (accountId, collectionsByAccountId, collectionsMap) => { + const myCollectionsQuery = accountId + ? collectionsByAccountId[accountId] + : null; if (!myCollectionsQuery) { return {