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