393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import { useCallback, useId, useMemo, useState } from 'react';
|
|
|
|
import { FormattedMessage, useIntl } from 'react-intl';
|
|
|
|
import { useHistory } from 'react-router-dom';
|
|
|
|
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 { Button } from 'mastodon/components/button';
|
|
import { DisplayName } from 'mastodon/components/display_name';
|
|
import { EmptyState } from 'mastodon/components/empty_state';
|
|
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
|
|
import {
|
|
Article,
|
|
ItemList,
|
|
Scrollable,
|
|
} from 'mastodon/components/scrollable_list/components';
|
|
import { useAccount } from 'mastodon/hooks/useAccount';
|
|
import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts';
|
|
import { me } from 'mastodon/initial_state';
|
|
import {
|
|
addCollectionItem,
|
|
getCollectionItemIds,
|
|
removeCollectionItem,
|
|
updateCollectionEditorField,
|
|
} from 'mastodon/reducers/slices/collections';
|
|
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
|
|
|
|
import classes from './styles.module.scss';
|
|
import { WizardStepTitle } from './wizard_step_title';
|
|
|
|
const MAX_ACCOUNT_COUNT = 3;
|
|
|
|
const AddedAccountItem: React.FC<{
|
|
accountId: string;
|
|
onRemove: (id: string) => void;
|
|
}> = ({ accountId, onRemove }) => {
|
|
const handleRemoveAccount = useCallback(() => {
|
|
onRemove(accountId);
|
|
}, [accountId, onRemove]);
|
|
|
|
return (
|
|
<Account minimal key={accountId} id={accountId}>
|
|
<Button compact secondary onClick={handleRemoveAccount}>
|
|
<FormattedMessage
|
|
id='collections.remove_account'
|
|
defaultMessage='Remove'
|
|
/>
|
|
</Button>
|
|
</Account>
|
|
);
|
|
};
|
|
|
|
interface SuggestionItem {
|
|
id: string;
|
|
}
|
|
|
|
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
|
|
const account = useAccount(id);
|
|
|
|
if (!account) return null;
|
|
|
|
return (
|
|
<>
|
|
<Avatar account={account} />
|
|
<DisplayName account={account} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderAccountItem = (item: SuggestionItem) => (
|
|
<SuggestedAccountItem id={item.id} />
|
|
);
|
|
|
|
const getItemId = (item: SuggestionItem) => item.id;
|
|
|
|
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 addedAccountIds = useAppSelector(
|
|
(state) => state.collections.editor.accountIds,
|
|
);
|
|
|
|
// In edit mode, we're bypassing state and just return collection items directly,
|
|
// since they're edited "live", saving after each addition/deletion
|
|
const accountIds = useMemo(
|
|
() =>
|
|
isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds,
|
|
[isEditMode, collectionItems, addedAccountIds],
|
|
);
|
|
|
|
const [searchValue, setSearchValue] = useState('');
|
|
|
|
const hasAccounts = accountIds.length > 0;
|
|
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
|
|
|
|
const {
|
|
accountIds: suggestedAccountIds,
|
|
isLoading: isLoadingSuggestions,
|
|
searchAccounts,
|
|
resetAccounts,
|
|
} = useSearchAccounts({
|
|
withRelationships: true,
|
|
filterResults: (account) =>
|
|
!accountIds.includes(account.id) &&
|
|
// Only suggest accounts who allow being featured/recommended
|
|
account.feature_approval.current_user === 'automatic',
|
|
});
|
|
|
|
const suggestedItems = suggestedAccountIds.map((id) => ({
|
|
id,
|
|
isDisabled: 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 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) => {
|
|
dispatch(
|
|
updateCollectionEditorField({
|
|
field: 'accountIds',
|
|
value: accountIds.filter((id) => id !== accountId),
|
|
}),
|
|
);
|
|
},
|
|
[accountIds, dispatch],
|
|
);
|
|
|
|
const addAccountItem = useCallback(
|
|
(item: SuggestionItem) => {
|
|
confirmFollowStatus(item.id, () => {
|
|
dispatch(
|
|
updateCollectionEditorField({
|
|
field: 'accountIds',
|
|
value: [...accountIds, item.id],
|
|
}),
|
|
);
|
|
});
|
|
},
|
|
[accountIds, confirmFollowStatus, 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: SuggestionItem) => {
|
|
confirmFollowStatus(item.id, () => {
|
|
if (id) {
|
|
void dispatch(
|
|
addCollectionItem({ collectionId: id, accountId: item.id }),
|
|
);
|
|
}
|
|
});
|
|
},
|
|
[confirmFollowStatus, dispatch, id],
|
|
);
|
|
|
|
const handleRemoveAccountItem = useCallback(
|
|
(accountId: string) => {
|
|
if (isEditMode) {
|
|
instantRemoveAccountItem(accountId);
|
|
} else {
|
|
removeAccountItem(accountId);
|
|
}
|
|
},
|
|
[isEditMode, instantRemoveAccountItem, removeAccountItem],
|
|
);
|
|
|
|
const handleSelectItem = useCallback(
|
|
(item: SuggestionItem) => {
|
|
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`, {
|
|
account_ids: accountIds,
|
|
});
|
|
}
|
|
},
|
|
[id, history, accountIds],
|
|
);
|
|
|
|
const inputId = useId();
|
|
const AccountsHeadingElement = id ? 'h2' : 'h3';
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className={classes.form}>
|
|
<FormStack className={classes.formFieldStack}>
|
|
<header className={classes.header}>
|
|
{!id && (
|
|
<WizardStepTitle
|
|
step={1}
|
|
title={
|
|
<FormattedMessage
|
|
id='collections.create.accounts_title'
|
|
defaultMessage='Who will you feature in this collection?'
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
<ComboboxField
|
|
id={inputId}
|
|
label={intl.formatMessage({
|
|
id: 'collections.search_accounts_label',
|
|
defaultMessage: 'Search for an account to add',
|
|
})}
|
|
value={hasMaxAccounts ? '' : searchValue}
|
|
onChange={handleSearchValueChange}
|
|
onKeyDown={handleSearchKeyDown}
|
|
disabled={hasMaxAccounts}
|
|
isLoading={isLoadingSuggestions}
|
|
items={suggestedItems}
|
|
getItemId={getItemId}
|
|
renderItem={renderAccountItem}
|
|
onSelectItem={handleSelectItem}
|
|
status={
|
|
hasMaxAccounts
|
|
? {
|
|
variant: 'warning',
|
|
message: intl.formatMessage({
|
|
id: 'collections.search_accounts_max_reached',
|
|
defaultMessage:
|
|
'You have added the maximum number of accounts',
|
|
}),
|
|
}
|
|
: null
|
|
}
|
|
/>
|
|
</header>
|
|
|
|
<div>
|
|
{hasAccounts && (
|
|
<AccountsHeadingElement className={classes.listHeading}>
|
|
<FormattedMessage
|
|
id='collections.hints.accounts_counter'
|
|
defaultMessage='{count}/{max} accounts'
|
|
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
|
|
/>
|
|
</AccountsHeadingElement>
|
|
)}
|
|
|
|
<Scrollable className={classes.scrollableWrapper}>
|
|
<ItemList
|
|
emptyMessage={
|
|
<EmptyState
|
|
title={
|
|
<FormattedMessage
|
|
id='collections.accounts.empty_editor_title'
|
|
defaultMessage='No one is in this collection yet'
|
|
/>
|
|
}
|
|
message={
|
|
<FormattedMessage
|
|
id='collections.accounts.empty_description'
|
|
defaultMessage='Add up to {count} accounts'
|
|
values={{
|
|
count: MAX_ACCOUNT_COUNT,
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
{accountIds.map((accountId, index) => (
|
|
<Article
|
|
key={accountId}
|
|
aria-posinset={index}
|
|
aria-setsize={accountIds.length}
|
|
>
|
|
<AddedAccountItem
|
|
accountId={accountId}
|
|
onRemove={handleRemoveAccountItem}
|
|
/>
|
|
</Article>
|
|
))}
|
|
</ItemList>
|
|
</Scrollable>
|
|
</div>
|
|
</FormStack>
|
|
{!isEditMode && hasAccounts && (
|
|
<div className={classes.stickyFooter}>
|
|
<Button type='submit'>
|
|
{id ? (
|
|
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
|
) : (
|
|
<FormattedMessage
|
|
id='collections.continue'
|
|
defaultMessage='Continue'
|
|
/>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
);
|
|
};
|