import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import type { Map as ImmutableMap } from 'immutable'; import type { ApiMutedAccountJSON } from 'mastodon/api_types/accounts'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { AccountListItem } from 'mastodon/components/account_list_item'; import { Avatar } from 'mastodon/components/avatar'; import { PendingBadge } from 'mastodon/components/badge'; import { Button } from 'mastodon/components/button'; import { DisplayName } from 'mastodon/components/display_name'; import { useAccountHandle } from 'mastodon/components/display_name/default'; import { EmptyState } from 'mastodon/components/empty_state'; import { FormStack, ComboboxField } from 'mastodon/components/form_fields'; import { useComboboxItemProps } from 'mastodon/components/form_fields/combobox_field'; import { ListItemContent, ListItemWrapper, } from 'mastodon/components/list_item'; import { Article, ItemList, Scrollable, } from 'mastodon/components/scrollable_list/components'; import { useAccount } from 'mastodon/hooks/useAccount'; import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts'; import { domain } from 'mastodon/initial_state'; import type { Relationship } from 'mastodon/models/relationship'; import { addCollectionItem, getEditorCollectionItems, removeCollectionItem, updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; 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'; export const MAX_COLLECTION_ACCOUNT_COUNT = 25; const AddedAccountItem: React.FC<{ accountId: string; pending?: boolean; onRemove: (id: string) => void; }> = ({ accountId, pending, onRemove }) => { const handleRemoveAccount = useCallback(() => { onRemove(accountId); }, [accountId, onRemove]); const renderButton = useCallback( () => ( ), [handleRemoveAccount], ); return ( } renderButton={renderButton} /> ); }; const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => { const account = useAccount(id); const handle = useAccountHandle(account, domain); const comboboxItemProps = useComboboxItemProps(); if (!account) return null; return (
  • }>
  • ); }; const renderAccountItem = (account: ApiMutedAccountJSON) => ( ); type GroupKey = 'available' | 'mustFollow' | 'disabled'; function groupSuggestions( accounts: ApiMutedAccountJSON[], relationships: ImmutableMap, ) { const { available, mustFollow, disabled } = Object.groupBy( accounts, (account): GroupKey => { if (canAccountBeAdded(account)) { return 'available'; } if ( canAccountBeAddedByFollowers(account) && !relationships.get(account.id)?.following ) { return 'mustFollow'; } return 'disabled'; }, ); // Returning a new object ensures a fixed property order return { available, mustFollow, disabled }; } const renderGroupTitle = (groupKey: GroupKey, titleId: string) => { if (groupKey === 'available') { return null; } let title: React.ReactElement; let description: React.ReactElement; if (groupKey === 'mustFollow') { title = ( ); description = ( ); } else { title = ( ); description = ( ); } return (
  • {title}
  • ); }; const getItemId = (account: ApiMutedAccountJSON) => account.id; const getIsItemDisabled = (account: ApiMutedAccountJSON) => !canAccountBeAdded(account); export const CollectionAccounts: React.FC<{ collection?: ApiCollectionJSON | null; }> = ({ collection }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); const { id, items: collectionItems } = collection ?? {}; const isEditMode = !!id; const editorItemsFromState = useAppSelector( (state) => state.collections.editor.items, ); // In edit mode, we're bypassing our Redux state and just work on the // collection items directly since they're edited "live", saving right // after each addition/deletion const editorItems = useMemo( () => isEditMode ? getEditorCollectionItems(collectionItems) : editorItemsFromState, [isEditMode, collectionItems, editorItemsFromState], ); const hasPendingItems = editorItems.some((item) => item.state === 'pending'); const [searchValue, setSearchValue] = useState(''); const hasItems = editorItems.length > 0; const hasMaxItems = editorItems.length === MAX_COLLECTION_ACCOUNT_COUNT; const wasAccountAdded = useCallback( (account: ApiMutedAccountJSON) => !!editorItems.find((item) => item.account_id === account.id), [editorItems], ); const { accounts: suggestedAccounts, isLoading: isLoadingSuggestions, searchAccounts, resetAccounts, } = useSearchAccounts({ withRelationships: true, withDefaultFollows: searchValue === '', // Don't suggest accounts that were already added filterResults: (account) => !wasAccountAdded(account), }); const relationships = useAppSelector((state) => state.relationships); const groupedItems = groupSuggestions(suggestedAccounts, relationships); 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 removeAccountItem = useCallback( (accountId: string) => { dispatch( updateCollectionEditorField({ field: 'items', value: editorItems.filter((item) => item.account_id !== accountId), }), ); }, [editorItems, dispatch], ); const addAccountItem = useCallback( (item: ApiMutedAccountJSON) => { if (!wasAccountAdded(item)) { dispatch( updateCollectionEditorField({ field: 'items', value: [ ...editorItems, { account_id: item.id, state: item.feature_approval.current_user === 'manual' ? 'pending' : 'accepted', }, ], }), ); } }, [editorItems, wasAccountAdded, dispatch], ); 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( (item: ApiMutedAccountJSON) => { if (id && !wasAccountAdded(item)) { void dispatch( addCollectionItem({ collectionId: id, accountId: item.id }), ); } }, [dispatch, id, wasAccountAdded], ); const handleRemoveAccountItem = useCallback( (accountId: string) => { if (isEditMode) { instantRemoveAccountItem(accountId); } else { removeAccountItem(accountId); } }, [isEditMode, instantRemoveAccountItem, removeAccountItem], ); const handleSelectItem = useCallback( (item: ApiMutedAccountJSON) => { if (isEditMode) { instantAddAccountItem(item); } else { addAccountItem(item); } setSearchValue(''); resetAccounts(); }, [addAccountItem, instantAddAccountItem, isEditMode, resetAccounts], ); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (!id) { history.push('/collections/new/details'); } }, [id, history], ); const inputId = useId(); const AccountsHeadingElement = id ? 'h2' : 'h3'; return (
    {!id && ( } /> )} {hasPendingItems && }
    {hasItems && ( )} } message={ } /> } > {editorItems.map(({ account_id, state }, index) => (
    ))}
    {!isEditMode && hasItems && (
    )}
    ); };