From 2f0db28aa4f3813ceddfc588cf1b25169e52bde3 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 23 Apr 2026 14:44:30 +0200 Subject: [PATCH] Implement collection limit on frontend (#38786) --- .../features/account_featured/index.tsx | 14 ++---- .../features/collections/editor/index.tsx | 46 +++++++++++++++++-- .../collections/editor/styles.module.scss | 4 ++ .../mastodon/features/collections/index.tsx | 38 ++++++++++----- app/javascript/mastodon/initial_state.ts | 1 + app/javascript/mastodon/locales/en.json | 2 + 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 9acb7ff65c..439016cf9c 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -25,12 +25,9 @@ import Column from 'mastodon/features/ui/components/column'; import { useAccount } from 'mastodon/hooks/useAccount'; 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 { useAccountCollections } from '../collections'; import { CollectionListItem } from '../collections/components/collection_list_item'; import { areCollectionsEnabled } from '../collections/utils'; @@ -59,10 +56,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ useEffect(() => { if (accountId) { void dispatch(fetchEndorsedAccounts({ accountId })); - - if (collectionsEnabled) { - void dispatch(fetchAccountCollections({ accountId })); - } } }, [accountId, dispatch]); @@ -73,9 +66,8 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ImmutableList(), ) as ImmutableList, ); - const { collections, status: collectionsLoadStatus } = useAppSelector( - (state) => selectAccountCollections(state, accountId ?? null), - ); + const { collections, status: collectionsLoadStatus } = + useAccountCollections(accountId); const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( collections, diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx index 63a9e4171e..e48e8718cf 100644 --- a/app/javascript/mastodon/features/collections/editor/index.tsx +++ b/app/javascript/mastodon/features/collections/editor/index.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; import { @@ -12,6 +12,9 @@ import { useLocation, } from 'react-router-dom'; +import { Callout } from '@/mastodon/components/callout'; +import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import { initialState } from '@/mastodon/initial_state'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; @@ -22,8 +25,11 @@ import { } from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { useAccountCollections } from '..'; + import { CollectionAccounts } from './accounts'; import { CollectionDetails } from './details'; +import classes from './styles.module.scss'; export const messages = defineMessages({ create: { @@ -61,11 +67,14 @@ function usePageTitle(id: string | null) { } } +export const userCollectionLimit = initialState?.role?.collection_limit ?? 0; + export const CollectionEditorPage: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const accountId = useCurrentAccountId(); const { id = null } = useParams<{ id?: string }>(); const { path } = useRouteMatch(); const collection = useAppSelector((state) => @@ -73,7 +82,18 @@ export const CollectionEditorPage: React.FC<{ ); const editorStateId = useAppSelector((state) => state.collections.editor.id); const isEditMode = !!id; - const isLoading = isEditMode && !collection; + + // When creating a new collection, we load the current account's collections + // to determine if they're allowed to create more. + const { collections: collectionList, status: collectionListStatus } = + useAccountCollections(isEditMode ? null : accountId); + + const isLoading = + (isEditMode && !collection) || + (!isEditMode && collectionListStatus === 'loading'); + + const canCreateMoreCollections = + isEditMode || collectionList.length < userCollectionLimit; useEffect(() => { if (id) { @@ -108,7 +128,7 @@ export const CollectionEditorPage: React.FC<{
{isLoading ? ( - ) : ( + ) : canCreateMoreCollections ? ( } /> + ) : ( + )}
@@ -133,3 +155,21 @@ export const CollectionEditorPage: React.FC<{ ); }; + +export const MaxCollectionsCallout: React.FC = () => ( + + } + > + + +); diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss index d4b1d2d7f3..d1111e7469 100644 --- a/app/javascript/mastodon/features/collections/editor/styles.module.scss +++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss @@ -77,3 +77,7 @@ .suggestionGroup { padding-bottom: 4px; } + +.maxCollectionsError { + margin: 16px; +} diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 200a58620a..51848970dd 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -25,7 +25,12 @@ import { import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { CollectionListItem } from './components/collection_list_item'; -import { messages as editorMessages } from './editor'; +import { + messages as editorMessages, + MaxCollectionsCallout, + userCollectionLimit, +} from './editor'; +import { areCollectionsEnabled } from './utils'; const messages = defineMessages({ headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' }, @@ -35,24 +40,27 @@ const messages = defineMessages({ }, }); +export function useAccountCollections(accountId: string | null | undefined) { + const dispatch = useAppDispatch(); + + useEffect(() => { + if (accountId && areCollectionsEnabled()) { + void dispatch(fetchAccountCollections({ accountId })); + } + }, [dispatch, accountId]); + + return useAppSelector((state) => selectAccountCollections(state, accountId)); +} + export const Collections: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { - const dispatch = useAppDispatch(); const intl = useIntl(); const me = useCurrentAccountId(); const accountId = useAccountId(); const account = useAccount(accountId); - const { collections, status } = useAppSelector((state) => - selectAccountCollections(state, accountId), - ); - - useEffect(() => { - if (accountId) { - void dispatch(fetchAccountCollections({ accountId })); - } - }, [dispatch, accountId]); + const { collections, status } = useAccountCollections(accountId); const emptyMessage = status === 'error' || !accountId ? ( @@ -79,6 +87,7 @@ export const Collections: React.FC<{ ); + const canCreateMoreCollections = collections.length < userCollectionLimit; const isOwnCollection = accountId === me; const titleMessage = isOwnCollection ? messages.headingMe @@ -99,7 +108,9 @@ export const Collections: React.FC<{ iconComponent={CollectionsFilledIcon} multiColumn={multiColumn} extraButton={ - isOwnCollection && ( + isOwnCollection && + status === 'idle' && + canCreateMoreCollections && ( + {status === 'idle' && !canCreateMoreCollections && ( + + )} {collections.map((item, index) => (