Files
mastodon/app/javascript/flavours/glitch/features/collections/editor/accounts.tsx
2026-02-19 22:04:05 +01:00

371 lines
11 KiB
TypeScript

import { useCallback, 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 type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Button } from 'flavours/glitch/components/button';
import { Callout } from 'flavours/glitch/components/callout';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { EmptyState } from 'flavours/glitch/components/empty_state';
import {
FormStack,
ComboboxField,
} from 'flavours/glitch/components/form_fields';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { useSearchAccounts } from 'flavours/glitch/features/lists/use_search_accounts';
import {
addCollectionItem,
removeCollectionItem,
} from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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;
const AddedAccountItem: React.FC<{
accountId: string;
isRemovable: boolean;
onRemove: (id: string) => void;
}> = ({ accountId, isRemovable, onRemove }) => {
const intl = useIntl();
const handleRemoveAccount = useCallback(() => {
onRemove(accountId);
}, [accountId, onRemove]);
return (
<Account minimal key={accountId} id={accountId}>
{isRemovable && (
<IconButton
title={intl.formatMessage({
id: 'collections.remove_account',
defaultMessage: 'Remove this account',
})}
icon='remove'
iconComponent={CancelIcon}
onClick={handleRemoveAccount}
/>
)}
</Account>
);
};
interface SuggestionItem {
id: string;
isSelected: boolean;
}
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
const account = useAppSelector((state) => state.accounts.get(id));
if (!account) return null;
return (
<>
<Avatar account={account} />
<DisplayName account={account} />
{isSelected && (
<Icon
id='checked'
icon={CheckIcon}
className={classes.selectedSuggestionIcon}
/>
)}
</>
);
};
const renderAccountItem = (item: SuggestionItem) => (
<SuggestedAccountItem id={item.id} isSelected={item.isSelected} />
);
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<TempCollectionState>();
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({
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<HTMLInputElement>) => {
setSearchValue(e.target.value);
searchAccounts(e.target.value);
},
[searchAccounts],
);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
}
},
[],
);
const toggleAccountItem = useCallback((item: SuggestionItem) => {
setAccountIds((ids) =>
ids.includes(item.id)
? ids.filter((id) => id !== item.id)
: [...ids, item.id],
);
}, []);
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 instantToggleAccountItem = useCallback(
(item: SuggestionItem) => {
if (accountIds.includes(item.id)) {
instantRemoveAccountItem(item.id);
} else {
if (id) {
void dispatch(
addCollectionItem({ collectionId: id, accountId: item.id }),
);
}
}
},
[accountIds, dispatch, id, instantRemoveAccountItem],
);
const handleRemoveAccountItem = useCallback(
(accountId: string) => {
if (isEditMode) {
instantRemoveAccountItem(accountId);
} else {
setAccountIds((ids) => ids.filter((id) => id !== accountId));
}
},
[isEditMode, instantRemoveAccountItem],
);
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],
);
return (
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
{!id && (
<WizardStepHeader
step={1}
title={
<FormattedMessage
id='collections.create.accounts_title'
defaultMessage='Who will you feature in this collection?'
/>
}
description={
<FormattedMessage
id='collections.create.accounts_subtitle'
defaultMessage='Only accounts you follow who have opted into discovery can be added.'
/>
}
/>
)}
<ComboboxField
label={
<FormattedMessage
id='collections.search_accounts_label'
defaultMessage='Search for accounts to add…'
/>
}
hint={
hasMaxAccounts ? (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
) : undefined
}
value={hasMaxAccounts ? '' : searchValue}
onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts}
isLoading={isLoadingSuggestions}
items={suggestedItems}
getItemId={getItemId}
getIsItemSelected={getIsItemSelected}
renderItem={renderAccountItem}
onSelectItem={
isEditMode ? instantToggleAccountItem : toggleAccountItem
}
/>
{hasMinAccounts && (
<Callout>
<FormattedMessage
id='collections.hints.can_not_remove_more_accounts'
defaultMessage='Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.'
values={{ count: MIN_ACCOUNT_COUNT }}
/>
</Callout>
)}
<div className={classes.scrollableWrapper}>
<ScrollableList
scrollKey='collection-items'
className={classes.scrollableInner}
emptyMessage={
<EmptyState
title={
<FormattedMessage
id='collections.accounts.empty_title'
defaultMessage='This collection is empty'
/>
}
message={
<FormattedMessage
id='collections.accounts.empty_description'
defaultMessage='Add up to {count} accounts you follow'
values={{
count: MAX_ACCOUNT_COUNT,
}}
/>
}
/>
}
// TODO: Re-add `bindToDocument={!multiColumn}`
>
{accountIds.map((accountId) => (
<AddedAccountItem
key={accountId}
accountId={accountId}
isRemovable={!isEditMode || !hasMinAccounts}
onRemove={handleRemoveAccountItem}
/>
))}
</ScrollableList>
</div>
</FormStack>
{!isEditMode && (
<div className={classes.stickyFooter}>
{hasTooFewAccounts ? (
<Callout icon={false} className={classes.submitDisabledCallout}>
<FormattedMessage
id='collections.hints.add_more_accounts'
defaultMessage='Add at least {count, plural, one {# account} other {# accounts}} to continue'
values={{ count: MIN_ACCOUNT_COUNT }}
/>
</Callout>
) : (
<div className={classes.actionWrapper}>
<FormattedMessage
id='collections.hints.accounts_counter'
defaultMessage='{count} / {max} accounts'
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
>
{(text) => (
<div className={classes.itemCountReadout}>{text}</div>
)}
</FormattedMessage>
{canSubmit && (
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.continue'
defaultMessage='Continue'
/>
)}
</Button>
)}
</div>
)}
</div>
)}
</form>
);
};