mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
371 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|