Allow adding an account to a collection directly from the profile page (#39080)

This commit is contained in:
diondiondion
2026-05-20 13:29:41 +02:00
committed by GitHub
parent a444a0b572
commit 6e7e8de343
11 changed files with 354 additions and 66 deletions

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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<HTMLInputElement>;
}
export const CollectionToggle: React.FC<CollectionToggleProps> = ({
collection,
checked,
disabled,
subtitle,
onChange,
}) => {
const uniqueId = useId();
const toggleId = `${uniqueId}-toggle`;
const infoId = `${uniqueId}-info`;
return (
<ListItemWrapper
className={classes.wrapper}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={
<Toggle
id={toggleId}
checked={checked}
disabled={disabled}
onChange={onChange}
aria-describedby={infoId}
/>
}
>
<ListItemContent
as='label'
htmlFor={toggleId}
subtitle={
subtitle ?? (
<CollectionInfo
collection={collection}
withTimestamp={false}
withAuthorHandle={false}
/>
)
}
>
{collection.name}
</ListItemContent>
</ListItemWrapper>
);
};

View File

@@ -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<HTMLInputElement>) => {
void addOrRemove(e.target.checked);
},
[addOrRemove],
);
const hasMaxItemCount =
!isAccountInCollection &&
collection.item_count >= MAX_COLLECTION_ACCOUNT_COUNT;
return (
<CollectionToggle
key={collection.id}
collection={collection}
disabled={isUpdating || hasMaxItemCount}
subtitle={
hasMaxItemCount ? (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
) : 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 (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title' id={titleId}>
<FormattedMessage
id='collections.add_to_collection'
defaultMessage='Add {name} to collections'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div
className='lists-scrollable'
role='group'
aria-labelledby={titleId}
>
{status === 'loading' || !account ? (
<LoadingIndicator />
) : (
collections.map((item) => (
<ListItem key={item.id} collection={item} account={account} />
))
)}
</div>
</div>
</div>
);
};

View File

@@ -57,10 +57,45 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
className,
}) => {
const { id, name } = collection;
return (
<ListItemWrapper
className={classNames(classes.wrapper, className)}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={sideContent}
>
<ListItemLink
as='h3'
to={getCollectionPath(id)}
subtitle={
<CollectionInfo
collection={collection}
withAuthorHandle={withAuthorHandle}
withTimestamp={withTimestamp}
/>
}
>
{name}
</ListItemLink>
</ListItemWrapper>
);
};
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 (
<ul>
{collection.sensitive && (
<li className='sr-only'>
@@ -98,25 +133,4 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
)}
</ul>
);
return (
<ListItemWrapper
className={classNames(classes.wrapper, className)}
icon={
<AvatarGrid
accountIds={collection.items.map((item) => item.account_id)}
sensitive={collection.sensitive}
/>
}
sideContent={sideContent}
>
<ListItemLink
as='h3'
to={getCollectionPath(id)}
subtitle={collectionInfo}
>
{name}
</ListItemLink>
</ListItemWrapper>
);
};

View File

@@ -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<string, Relationship>,
@@ -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<{
<FormattedMessage
id='collections.hints.accounts_counter'
defaultMessage='{count}/{max} accounts'
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
values={{
count: editorItems.length,
max: MAX_COLLECTION_ACCOUNT_COUNT,
}}
/>
</AccountsHeadingElement>
)}
@@ -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,
}}
/>
}

View File

@@ -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');

View File

@@ -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<HTMLInputElement>) => {
onChange(id, e.target.checked);
@@ -50,14 +52,13 @@ const ListItem: React.FC<{
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<label className='lists__item' htmlFor={uniqueId}>
<div className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
<Toggle id={uniqueId} checked={checked} onChange={handleChange} />
</label>
);
};

View File

@@ -77,6 +77,7 @@ export const MODAL_COMPONENTS = {
'DOMAIN_BLOCK': DomainBlockModal,
'REPORT': ReportModal,
'REPORT_COLLECTION': ReportCollectionModal,
'COLLECTION_ADDER': () => import('@/mastodon/features/collection_adder').then(module => ({ default: module.CollectionAdder })),
'SHARE_COLLECTION': () => import('@/mastodon/features/collections/components/share_modal').then(module => ({ default: module.CollectionShareModal })),
'REVOKE_COLLECTION_INCLUSION': () => import('@/mastodon/features/collections/detail/revoke_collection_inclusion_modal').then(module => ({ default: module.RevokeCollectionInclusionModal })),
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),

View File

@@ -86,6 +86,7 @@
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
"account.mention": "Mention @{name}",
"account.menu.add_to_collection": "Add to collection…",
"account.menu.add_to_list": "Add to list…",
"account.menu.block": "Block account",
"account.menu.block_domain": "Block {domain}",
@@ -372,6 +373,7 @@
"collections.accounts.empty_description": "Add up to {count} accounts",
"collections.accounts.empty_editor_title": "No one is in this collection yet",
"collections.accounts.empty_title": "This collection is empty",
"collections.add_to_collection": "Add {name} to collections",
"collections.block_collection_owner": "Block account",
"collections.by_account": "by {account_handle}",
"collections.collection_description": "Description",

View File

@@ -283,8 +283,12 @@ const collectionSlice = createSlice({
builder.addCase(addCollectionItem.fulfilled, (state, action) => {
const { collection_item } = action.payload;
const { collectionId } = action.meta.arg;
const collection = state.collections[collectionId];
state.collections[collectionId]?.items.push(collection_item);
if (collection) {
collection.items.push(collection_item);
collection.item_count++;
}
});
/**
@@ -302,6 +306,7 @@ const collectionSlice = createSlice({
collection.items = collection.items.filter(
(item) => item.id !== itemId,
);
collection.item_count--;
}
};