From 6e7e8de3435c2a11715eecca97ddd92e1a2df3ea Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 20 May 2026 13:29:41 +0200 Subject: [PATCH] Allow adding an account to a collection directly from the profile page (#39080) --- .../components/account_header/menu.tsx | 82 ++++++---- .../collection_toggle.module.scss | 12 ++ .../collection_adder/collection_toggle.tsx | 72 +++++++++ .../features/collection_adder/index.tsx | 143 ++++++++++++++++++ .../components/collection_lockup.tsx | 58 ++++--- .../features/collections/editor/accounts.tsx | 21 ++- .../mastodon/features/collections/utils.ts | 11 ++ .../mastodon/features/list_adder/index.tsx | 11 +- .../features/ui/components/modal_root.jsx | 1 + app/javascript/mastodon/locales/en.json | 2 + .../mastodon/reducers/slices/collections.ts | 7 +- 11 files changed, 354 insertions(+), 66 deletions(-) create mode 100644 app/javascript/mastodon/features/collection_adder/collection_toggle.module.scss create mode 100644 app/javascript/mastodon/features/collection_adder/collection_toggle.tsx create mode 100644 app/javascript/mastodon/features/collection_adder/index.tsx diff --git a/app/javascript/mastodon/components/account_header/menu.tsx b/app/javascript/mastodon/components/account_header/menu.tsx index 936609e91f..299ea26d37 100644 --- a/app/javascript/mastodon/components/account_header/menu.tsx +++ b/app/javascript/mastodon/components/account_header/menu.tsx @@ -21,6 +21,10 @@ import { import { openModal } from '@/mastodon/actions/modal'; import { initMuteModal } from '@/mastodon/actions/mutes'; import { initReport } from '@/mastodon/actions/reports'; +import { + canAccountBeAdded, + canAccountBeAddedByFollowers, +} from '@/mastodon/features/collections/utils'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useIdentity } from '@/mastodon/identity_context'; import type { Account } from '@/mastodon/models/account'; @@ -214,6 +218,10 @@ const redesignMessages = defineMessages({ id: 'account.menu.add_to_list', defaultMessage: 'Add to list…', }, + addToCollection: { + id: 'account.menu.add_to_collection', + defaultMessage: 'Add to collection…', + }, openOriginalPage: { id: 'account.menu.open_original_page', defaultMessage: 'View on {domain}', @@ -294,35 +302,57 @@ function getMenuItems({ return items; } - // List and featuring options + // Add to list if (relationship?.following) { - items.push( - { - text: intl.formatMessage(redesignMessages.addToList), - action: () => { - dispatch( - openModal({ - modalType: 'LIST_ADDER', - modalProps: { - accountId: account.id, - }, - }), - ); - }, + items.push({ + text: intl.formatMessage(redesignMessages.addToList), + action: () => { + dispatch( + openModal({ + modalType: 'LIST_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); }, - { - text: intl.formatMessage( - relationship.endorsed ? messages.unendorse : messages.endorse, - ), - action: () => { - if (relationship.endorsed) { - dispatch(unpinAccount(account.id)); - } else { - dispatch(pinAccount(account.id)); - } - }, + }); + } + + // Add to collection + if ( + canAccountBeAdded(account) || + (canAccountBeAddedByFollowers(account) && relationship?.following) + ) { + items.push({ + text: intl.formatMessage(redesignMessages.addToCollection), + action: () => { + dispatch( + openModal({ + modalType: 'COLLECTION_ADDER', + modalProps: { + accountId: account.id, + }, + }), + ); }, - ); + }); + } + + // Feature on profile + if (relationship?.following) { + items.push({ + text: intl.formatMessage( + relationship.endorsed ? messages.unendorse : messages.endorse, + ), + action: () => { + if (relationship.endorsed) { + dispatch(unpinAccount(account.id)); + } else { + dispatch(pinAccount(account.id)); + } + }, + }); } items.push( diff --git a/app/javascript/mastodon/features/collection_adder/collection_toggle.module.scss b/app/javascript/mastodon/features/collection_adder/collection_toggle.module.scss new file mode 100644 index 0000000000..e51c937739 --- /dev/null +++ b/app/javascript/mastodon/features/collection_adder/collection_toggle.module.scss @@ -0,0 +1,12 @@ +.wrapper { + --list-item-gap: 16px; + --list-item-padding-block: 12px; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-border-primary); + } + + &:has(input:disabled) { + color: var(--color-text-secondary); + } +} diff --git a/app/javascript/mastodon/features/collection_adder/collection_toggle.tsx b/app/javascript/mastodon/features/collection_adder/collection_toggle.tsx new file mode 100644 index 0000000000..7d9c62392e --- /dev/null +++ b/app/javascript/mastodon/features/collection_adder/collection_toggle.tsx @@ -0,0 +1,72 @@ +import { useId } from 'react'; + +import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; +import { Toggle } from '@/mastodon/components/form_fields'; +import { + ListItemContent, + ListItemWrapper, +} from '@/mastodon/components/list_item'; +import { + AvatarGrid, + CollectionInfo, +} from 'mastodon/features/collections/components/collection_lockup'; + +import classes from './collection_toggle.module.scss'; + +export interface CollectionToggleProps { + collection: ApiCollectionJSON; + checked: boolean; + disabled?: boolean; + loading?: boolean; + subtitle?: React.ReactNode; + onChange: React.ChangeEventHandler; +} + +export const CollectionToggle: React.FC = ({ + collection, + checked, + disabled, + subtitle, + onChange, +}) => { + const uniqueId = useId(); + const toggleId = `${uniqueId}-toggle`; + const infoId = `${uniqueId}-info`; + + return ( + item.account_id)} + sensitive={collection.sensitive} + /> + } + sideContent={ + + } + > + + ) + } + > + {collection.name} + + + ); +}; diff --git a/app/javascript/mastodon/features/collection_adder/index.tsx b/app/javascript/mastodon/features/collection_adder/index.tsx new file mode 100644 index 0000000000..48a0e68f48 --- /dev/null +++ b/app/javascript/mastodon/features/collection_adder/index.tsx @@ -0,0 +1,143 @@ +import { useCallback, useId, useState } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import type { ApiCollectionJSON } from '@/mastodon/api_types/collections'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId'; +import type { Account } from '@/mastodon/models/account'; +import { + addCollectionItem, + removeCollectionItem, +} from '@/mastodon/reducers/slices/collections'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import { IconButton } from 'mastodon/components/icon_button'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import { MAX_COLLECTION_ACCOUNT_COUNT } from '../collections/editor/accounts'; +import { useCollectionsCreatedBy } from '../collections/overview/created_by_you'; + +import { CollectionToggle } from './collection_toggle'; + +const messages = defineMessages({ + close: { + id: 'lightbox.close', + defaultMessage: 'Close', + }, +}); + +const ListItem: React.FC<{ + collection: ApiCollectionJSON; + account: Account; +}> = ({ collection, account }) => { + const dispatch = useAppDispatch(); + const [isUpdating, setIsUpdating] = useState(false); + + const accountItemInCollection = collection.items.find( + (item) => item.account_id === account.id, + ); + const isAccountInCollection = !!accountItemInCollection; + + const addOrRemove = useCallback( + async (shouldAdd: boolean) => { + setIsUpdating(true); + + if (shouldAdd) { + await dispatch( + addCollectionItem({ + collectionId: collection.id, + accountId: account.id, + }), + ); + } else if (accountItemInCollection) { + await dispatch( + removeCollectionItem({ + collectionId: collection.id, + itemId: accountItemInCollection.id, + }), + ); + } + + setIsUpdating(false); + }, + [account.id, collection.id, accountItemInCollection, dispatch], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + void addOrRemove(e.target.checked); + }, + [addOrRemove], + ); + + const hasMaxItemCount = + !isAccountInCollection && + collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT; + + return ( + + ) : null + } + checked={isAccountInCollection} + onChange={handleChange} + /> + ); +}; + +export const CollectionAdder: React.FC<{ + accountId: string; + onClose: () => void; +}> = ({ accountId, onClose }) => { + const intl = useIntl(); + const titleId = useId(); + const account = useAppSelector((state) => state.accounts.get(accountId)); + const currentAccountId = useCurrentAccountId(); + const { collections, status } = useCollectionsCreatedBy(currentAccountId); + + return ( +
+
+ + + + @{account?.acct} }} + /> + +
+ +
+
+ {status === 'loading' || !account ? ( + + ) : ( + collections.map((item) => ( + + )) + )} +
+
+
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx index 22c584559f..c79de4f61e 100644 --- a/app/javascript/mastodon/features/collections/components/collection_lockup.tsx +++ b/app/javascript/mastodon/features/collections/components/collection_lockup.tsx @@ -57,10 +57,45 @@ export const CollectionLockup: React.FC = ({ className, }) => { const { id, name } = collection; + + return ( + item.account_id)} + sensitive={collection.sensitive} + /> + } + sideContent={sideContent} + > + + } + > + {name} + + + ); +}; + +export const CollectionInfo: React.FC< + Pick< + CollectionLockupProps, + 'collection' | 'withAuthorHandle' | 'withTimestamp' + > +> = ({ collection, withAuthorHandle, withTimestamp }) => { const authorAccount = useAccount(collection.account_id); const authorHandle = useAccountHandle(authorAccount, domain); - const collectionInfo = ( + return (
    {collection.sensitive && (
  • @@ -98,25 +133,4 @@ export const CollectionLockup: React.FC = ({ )}
); - - return ( - item.account_id)} - sensitive={collection.sensitive} - /> - } - sideContent={sideContent} - > - - {name} - - - ); }; diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 3643072f10..c9423217f5 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -39,11 +39,12 @@ import { import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { PendingNote } from '../detail'; +import { canAccountBeAdded, canAccountBeAddedByFollowers } from '../utils'; import classes from './styles.module.scss'; import { WizardStepTitle } from './wizard_step_title'; -const MAX_ACCOUNT_COUNT = 25; +export const MAX_COLLECTION_ACCOUNT_COUNT = 25; const AddedAccountItem: React.FC<{ accountId: string; @@ -99,9 +100,6 @@ const renderAccountItem = (account: ApiMutedAccountJSON) => ( type GroupKey = 'available' | 'mustFollow' | 'disabled'; -const canAccountBeAdded = (account: ApiMutedAccountJSON) => - ['automatic', 'manual'].includes(account.feature_approval.current_user); - function groupSuggestions( accounts: ApiMutedAccountJSON[], relationships: ImmutableMap, @@ -113,12 +111,8 @@ function groupSuggestions( return 'available'; } - const canAccountBeAddedByFollowers = - account.feature_approval.automatic.includes('followers') || - account.feature_approval.manual.includes('followers'); - if ( - canAccountBeAddedByFollowers && + canAccountBeAddedByFollowers(account) && !relationships.get(account.id)?.following ) { return 'mustFollow'; @@ -212,7 +206,7 @@ export const CollectionAccounts: React.FC<{ const [searchValue, setSearchValue] = useState(''); const hasItems = editorItems.length > 0; - const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT; + const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT; const { accounts: suggestedAccounts, @@ -406,7 +400,10 @@ export const CollectionAccounts: React.FC<{ )} @@ -426,7 +423,7 @@ export const CollectionAccounts: React.FC<{ id='collections.accounts.empty_description' defaultMessage='Add up to {count} accounts' values={{ - count: MAX_ACCOUNT_COUNT, + count: MAX_COLLECTION_ACCOUNT_COUNT, }} /> } diff --git a/app/javascript/mastodon/features/collections/utils.ts b/app/javascript/mastodon/features/collections/utils.ts index 1905edfef1..6cf2cfaca7 100644 --- a/app/javascript/mastodon/features/collections/utils.ts +++ b/app/javascript/mastodon/features/collections/utils.ts @@ -1,3 +1,5 @@ +import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts'; +import type { Account } from '@/mastodon/models/account'; import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; export function areCollectionsEnabled() { @@ -5,3 +7,12 @@ export function areCollectionsEnabled() { } export const getCollectionPath = (id: string) => `/collections/${id}`; + +export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) => + ['automatic', 'manual'].includes(account.feature_approval.current_user); + +export const canAccountBeAddedByFollowers = ( + account: ApiMutedAccountJSON | Account, +) => + account.feature_approval.automatic.includes('followers') || + account.feature_approval.manual.includes('followers'); diff --git a/app/javascript/mastodon/features/list_adder/index.tsx b/app/javascript/mastodon/features/list_adder/index.tsx index c23df73cda..a3fbf1456b 100644 --- a/app/javascript/mastodon/features/list_adder/index.tsx +++ b/app/javascript/mastodon/features/list_adder/index.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useId } from 'react'; import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; import { isFulfilled } from '@reduxjs/toolkit'; +import { Toggle } from '@/mastodon/components/form_fields'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { fetchLists } from 'mastodon/actions/lists'; @@ -15,7 +16,6 @@ import { } from 'mastodon/api/lists'; import type { ApiListJSON } from 'mastodon/api_types/lists'; import { Button } from 'mastodon/components/button'; -import { CheckBox } from 'mastodon/components/check_box'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; import { getOrderedLists } from 'mastodon/selectors/lists'; @@ -42,6 +42,8 @@ const ListItem: React.FC<{ checked: boolean; onChange: (id: string, checked: boolean) => void; }> = ({ id, title, checked, onChange }) => { + const uniqueId = useId(); + const handleChange = useCallback( (e: React.ChangeEvent) => { onChange(id, e.target.checked); @@ -50,14 +52,13 @@ const ListItem: React.FC<{ ); return ( - // eslint-disable-next-line jsx-a11y/label-has-associated-control -