Implement collection limit on frontend (#38786)

This commit is contained in:
diondiondion 2026-04-23 14:44:30 +02:00 committed by GitHub
parent d7b60a2cb6
commit 2f0db28aa4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 79 additions and 26 deletions

View File

@ -25,12 +25,9 @@ import Column from 'mastodon/features/ui/components/column';
import { useAccount } from 'mastodon/hooks/useAccount'; import { useAccount } from 'mastodon/hooks/useAccount';
import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountId } from 'mastodon/hooks/useAccountId';
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
import {
fetchAccountCollections,
selectAccountCollections,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useAccountCollections } from '../collections';
import { CollectionListItem } from '../collections/components/collection_list_item'; import { CollectionListItem } from '../collections/components/collection_list_item';
import { areCollectionsEnabled } from '../collections/utils'; import { areCollectionsEnabled } from '../collections/utils';
@ -59,10 +56,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
useEffect(() => { useEffect(() => {
if (accountId) { if (accountId) {
void dispatch(fetchEndorsedAccounts({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId }));
if (collectionsEnabled) {
void dispatch(fetchAccountCollections({ accountId }));
}
} }
}, [accountId, dispatch]); }, [accountId, dispatch]);
@ -73,9 +66,8 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(), ImmutableList(),
) as ImmutableList<string>, ) as ImmutableList<string>,
); );
const { collections, status: collectionsLoadStatus } = useAppSelector( const { collections, status: collectionsLoadStatus } =
(state) => selectAccountCollections(state, accountId ?? null), useAccountCollections(accountId);
);
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy( const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
collections, collections,

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { import {
@ -12,6 +12,9 @@ import {
useLocation, useLocation,
} from 'react-router-dom'; } 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 ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
@ -22,8 +25,11 @@ import {
} from 'mastodon/reducers/slices/collections'; } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { useAccountCollections } from '..';
import { CollectionAccounts } from './accounts'; import { CollectionAccounts } from './accounts';
import { CollectionDetails } from './details'; import { CollectionDetails } from './details';
import classes from './styles.module.scss';
export const messages = defineMessages({ export const messages = defineMessages({
create: { create: {
@ -61,11 +67,14 @@ function usePageTitle(id: string | null) {
} }
} }
export const userCollectionLimit = initialState?.role?.collection_limit ?? 0;
export const CollectionEditorPage: React.FC<{ export const CollectionEditorPage: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const accountId = useCurrentAccountId();
const { id = null } = useParams<{ id?: string }>(); const { id = null } = useParams<{ id?: string }>();
const { path } = useRouteMatch(); const { path } = useRouteMatch();
const collection = useAppSelector((state) => const collection = useAppSelector((state) =>
@ -73,7 +82,18 @@ export const CollectionEditorPage: React.FC<{
); );
const editorStateId = useAppSelector((state) => state.collections.editor.id); const editorStateId = useAppSelector((state) => state.collections.editor.id);
const isEditMode = !!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(() => { useEffect(() => {
if (id) { if (id) {
@ -108,7 +128,7 @@ export const CollectionEditorPage: React.FC<{
<div className='scrollable'> <div className='scrollable'>
{isLoading ? ( {isLoading ? (
<LoadingIndicator /> <LoadingIndicator />
) : ( ) : canCreateMoreCollections ? (
<Switch> <Switch>
<Route <Route
exact exact
@ -123,6 +143,8 @@ export const CollectionEditorPage: React.FC<{
render={() => <CollectionDetails />} render={() => <CollectionDetails />}
/> />
</Switch> </Switch>
) : (
<MaxCollectionsCallout />
)} )}
</div> </div>
@ -133,3 +155,21 @@ export const CollectionEditorPage: React.FC<{
</Column> </Column>
); );
}; };
export const MaxCollectionsCallout: React.FC = () => (
<Callout
className={classes.maxCollectionsError}
title={
<FormattedMessage
id='collections.maximum_collection_count_reached'
defaultMessage='You have created the maximum number of collections'
/>
}
>
<FormattedMessage
id='collections.maximum_collection_count_description'
defaultMessage='Your server allows creation of up to {count} collections.'
values={{ count: userCollectionLimit }}
/>
</Callout>
);

View File

@ -77,3 +77,7 @@
.suggestionGroup { .suggestionGroup {
padding-bottom: 4px; padding-bottom: 4px;
} }
.maxCollectionsError {
margin: 16px;
}

View File

@ -25,7 +25,12 @@ import {
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { CollectionListItem } from './components/collection_list_item'; 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({ const messages = defineMessages({
headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' }, 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<{ export const Collections: React.FC<{
multiColumn?: boolean; multiColumn?: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const me = useCurrentAccountId(); const me = useCurrentAccountId();
const accountId = useAccountId(); const accountId = useAccountId();
const account = useAccount(accountId); const account = useAccount(accountId);
const { collections, status } = useAppSelector((state) => const { collections, status } = useAccountCollections(accountId);
selectAccountCollections(state, accountId),
);
useEffect(() => {
if (accountId) {
void dispatch(fetchAccountCollections({ accountId }));
}
}, [dispatch, accountId]);
const emptyMessage = const emptyMessage =
status === 'error' || !accountId ? ( status === 'error' || !accountId ? (
@ -79,6 +87,7 @@ export const Collections: React.FC<{
</> </>
); );
const canCreateMoreCollections = collections.length < userCollectionLimit;
const isOwnCollection = accountId === me; const isOwnCollection = accountId === me;
const titleMessage = isOwnCollection const titleMessage = isOwnCollection
? messages.headingMe ? messages.headingMe
@ -99,7 +108,9 @@ export const Collections: React.FC<{
iconComponent={CollectionsFilledIcon} iconComponent={CollectionsFilledIcon}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={ extraButton={
isOwnCollection && ( isOwnCollection &&
status === 'idle' &&
canCreateMoreCollections && (
<Link <Link
to='/collections/new' to='/collections/new'
className='column-header__button' className='column-header__button'
@ -113,6 +124,9 @@ export const Collections: React.FC<{
/> />
<Scrollable> <Scrollable>
{status === 'idle' && !canCreateMoreCollections && (
<MaxCollectionsCallout />
)}
<ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}> <ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
{collections.map((item, index) => ( {collections.map((item, index) => (
<CollectionListItem <CollectionListItem

View File

@ -57,6 +57,7 @@ interface Role {
permissions: string; permissions: string;
color: string; color: string;
highlighted: boolean; highlighted: boolean;
collection_limit: number;
} }
interface InitialWrapstodonState { interface InitialWrapstodonState {

View File

@ -398,6 +398,8 @@
"collections.manage_accounts": "Manage accounts", "collections.manage_accounts": "Manage accounts",
"collections.mark_as_sensitive": "Mark as sensitive", "collections.mark_as_sensitive": "Mark as sensitive",
"collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.",
"collections.maximum_collection_count_description": "Your server allows creation of up to {count} collections.",
"collections.maximum_collection_count_reached": "You have created the maximum number of collections",
"collections.name_length_hint": "40 characters limit", "collections.name_length_hint": "40 characters limit",
"collections.new_collection": "New collection", "collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",