import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useHistory, useLocation } from 'react-router-dom'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import { showAlertForError } from 'mastodon/actions/alerts'; import { openModal } from 'mastodon/actions/modal'; import { apiFollowAccount } from 'mastodon/api/accounts'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Account } from 'mastodon/components/account'; import { Avatar } from 'mastodon/components/avatar'; import { Badge } from 'mastodon/components/badge'; import { Button } from 'mastodon/components/button'; import { Callout } from 'mastodon/components/callout'; import { DisplayName } from 'mastodon/components/display_name'; import { EmptyState } from 'mastodon/components/empty_state'; import { FormStack, Combobox } from 'mastodon/components/form_fields'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; import { Article, ItemList, Scrollable, } from 'mastodon/components/scrollable_list/components'; import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts'; import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; import { addCollectionItem, removeCollectionItem, } from 'mastodon/reducers/slices/collections'; import { store, useAppDispatch, useAppSelector } from 'mastodon/store'; import type { TempCollectionState } from './state'; import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; const MIN_ACCOUNT_COUNT = 1; const MAX_ACCOUNT_COUNT = 25; function isOlderThanAWeek(date?: string): boolean { if (!date) return false; const targetDate = new Date(date); const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); return targetDate < sevenDaysAgo; } const AddedAccountItem: React.FC<{ accountId: string; isRemovable: boolean; onRemove: (id: string) => void; }> = ({ accountId, isRemovable, onRemove }) => { const intl = useIntl(); const account = useAccount(accountId); const handleRemoveAccount = useCallback(() => { onRemove(accountId); }, [accountId, onRemove]); const lastPostHint = useMemo( () => isOlderThanAWeek(account?.last_status_at) && ( } icon={} className={classes.accountBadge} /> ), [account?.last_status_at], ); return ( {isRemovable && ( )} ); }; interface SuggestionItem { id: string; isSelected: boolean; } const SuggestedAccountItem: React.FC = ({ id, isSelected }) => { const account = useAccount(id); if (!account) return null; return ( <> {isSelected && ( )} ); }; const renderAccountItem = (item: SuggestionItem) => ( ); const getItemId = (item: SuggestionItem) => item.id; const getIsItemSelected = (item: SuggestionItem) => item.isSelected; export const CollectionAccounts: React.FC<{ collection?: ApiCollectionJSON | null; }> = ({ collection }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); const location = useLocation(); const { id, initialItemIds } = getCollectionEditorState( collection, location.state, ); const isEditMode = !!id; const collectionItems = collection?.items; const [searchValue, setSearchValue] = useState(''); // This state is only used when creating a new collection. // In edit mode, the collection will be updated instantly const [addedAccountIds, setAccountIds] = useState(initialItemIds); const accountIds = useMemo( () => isEditMode ? (collectionItems ?.map((item) => item.account_id) .filter((id): id is string => !!id) ?? []) : addedAccountIds, [isEditMode, collectionItems, addedAccountIds], ); const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT; const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT; const canSubmit = !hasTooFewAccounts; const { accountIds: suggestedAccountIds, isLoading: isLoadingSuggestions, searchAccounts, } = useSearchAccounts({ withRelationships: true, filterResults: (account) => // Only suggest accounts who allow being featured/recommended account.feature_approval.current_user === 'automatic', }); const suggestedItems = suggestedAccountIds.map((id) => ({ id, isSelected: accountIds.includes(id), })); const handleSearchValueChange = useCallback( (e: React.ChangeEvent) => { setSearchValue(e.target.value); searchAccounts(e.target.value); }, [searchAccounts], ); const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); } }, [], ); const relationships = useAppSelector((state) => state.relationships); const confirmFollowStatus = useCallback( (accountId: string, onFollowing: () => void) => { const relationship = relationships.get(accountId); if (!relationship) { return; } if ( accountId === me || relationship.following || relationship.requested ) { onFollowing(); } else { dispatch( openModal({ modalType: 'CONFIRM_FOLLOW_TO_COLLECTION', modalProps: { accountId, onConfirm: () => { apiFollowAccount(accountId) .then(onFollowing) .catch((err: unknown) => { store.dispatch(showAlertForError(err)); }); }, }, }), ); } }, [dispatch, relationships], ); const removeAccountItem = useCallback((accountId: string) => { setAccountIds((ids) => ids.filter((id) => id !== accountId)); }, []); const addAccountItem = useCallback( (accountId: string) => { confirmFollowStatus(accountId, () => { setAccountIds((ids) => [...ids, accountId]); }); }, [confirmFollowStatus], ); const toggleAccountItem = useCallback( (item: SuggestionItem) => { if (addedAccountIds.includes(item.id)) { removeAccountItem(item.id); } else { addAccountItem(item.id); } }, [addAccountItem, addedAccountIds, removeAccountItem], ); const instantRemoveAccountItem = useCallback( (accountId: string) => { const itemId = collectionItems?.find( (item) => item.account_id === accountId, )?.id; if (itemId && id) { if ( window.confirm( intl.formatMessage({ id: 'collections.confirm_account_removal', defaultMessage: 'Are you sure you want to remove this account from this collection?', }), ) ) { void dispatch(removeCollectionItem({ collectionId: id, itemId })); } } }, [collectionItems, dispatch, id, intl], ); const instantAddAccountItem = useCallback( (collectionId: string, accountId: string) => { confirmFollowStatus(accountId, () => { void dispatch(addCollectionItem({ collectionId, accountId })); }); }, [confirmFollowStatus, dispatch], ); const instantToggleAccountItem = useCallback( (item: SuggestionItem) => { if (accountIds.includes(item.id)) { instantRemoveAccountItem(item.id); } else if (id) { instantAddAccountItem(id, item.id); } }, [accountIds, id, instantAddAccountItem, instantRemoveAccountItem], ); const handleRemoveAccountItem = useCallback( (accountId: string) => { if (isEditMode) { instantRemoveAccountItem(accountId); } else { removeAccountItem(accountId); } }, [isEditMode, instantRemoveAccountItem, removeAccountItem], ); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) { return; } if (!id) { history.push(`/collections/new/details`, { account_ids: accountIds, }); } }, [canSubmit, id, history, accountIds], ); const inputId = useId(); const inputLabel = intl.formatMessage({ id: 'collections.search_accounts_label', defaultMessage: 'Search for accounts to add…', }); return (
{!id && ( } description={ } /> )} {hasMaxAccounts && ( )} {hasMinAccounts && ( )} } message={ } /> } > {accountIds.map((accountId, index) => (
))}
{!isEditMode && (
{hasTooFewAccounts ? ( ) : (
{(text) => (
{text}
)}
{canSubmit && ( )}
)}
)}
); };