476 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(
() => (
<Button compact secondary onClick={handleRemoveAccount}>
<FormattedMessage
id='collections.remove_account'
defaultMessage='Remove'
/>
</Button>
),
[handleRemoveAccount],
);
return (
<AccountListItem
accountId={accountId}
badge={pending && <PendingBadge />}
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 (
<li {...comboboxItemProps} className={classes.suggestion}>
<ListItemWrapper icon={<Avatar account={account} size={40} />}>
<ListItemContent subtitle={handle}>
<DisplayName account={account} variant='simple' />
</ListItemContent>
</ListItemWrapper>
</li>
);
};
const renderAccountItem = (account: ApiMutedAccountJSON) => (
<SuggestedAccountItem id={account.id} />
);
type GroupKey = 'available' | 'mustFollow' | 'disabled';
function groupSuggestions(
accounts: ApiMutedAccountJSON[],
relationships: ImmutableMap<string, Relationship>,
) {
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 = (
<FormattedMessage
id='collections.suggestions.must_follow'
defaultMessage='Must follow first'
/>
);
description = (
<FormattedMessage
id='collections.suggestions.must_follow_desc'
defaultMessage='These accounts review all follow requests. Followers can add them to collections.'
/>
);
} else {
title = (
<FormattedMessage
id='collections.suggestions.can_not_add'
defaultMessage='Cant be added'
/>
);
description = (
<FormattedMessage
id='collections.suggestions.can_not_add_desc'
defaultMessage='These accounts may have opted out of discovery, or they might be on a server that doesnt support collections.'
/>
);
}
return (
<li role='presentation'>
<ListItemWrapper className={classes.suggestionGroup}>
<ListItemContent id={titleId} subtitle={description}>
{title}
</ListItemContent>
</ListItemWrapper>
</li>
);
};
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<HTMLInputElement>) => {
setSearchValue(e.target.value);
searchAccounts(e.target.value);
},
[searchAccounts],
);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<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?'
/>
}
/>
)}
{hasPendingItems && <PendingNote />}
<ComboboxField
openOnFocus
id={inputId}
label={intl.formatMessage({
id: 'collections.search_accounts_label',
defaultMessage: 'Search for an account to add',
})}
value={hasMaxItems ? '' : searchValue}
onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown}
disabled={hasMaxItems}
isLoading={isLoadingSuggestions}
items={groupedItems}
getItemId={getItemId}
getIsItemDisabled={getIsItemDisabled}
renderItem={renderAccountItem}
renderGroupTitle={renderGroupTitle}
onSelectItem={handleSelectItem}
status={
hasMaxItems
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.search_accounts_max_reached',
defaultMessage:
'You have added the maximum number of accounts',
}),
}
: null
}
/>
</header>
<div>
{hasItems && (
<AccountsHeadingElement className={classes.listHeading}>
<FormattedMessage
id='collections.hints.accounts_counter'
defaultMessage='{count}/{max} accounts'
values={{
count: editorItems.length,
max: MAX_COLLECTION_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_COLLECTION_ACCOUNT_COUNT,
}}
/>
}
/>
}
>
{editorItems.map(({ account_id, state }, index) => (
<Article
key={account_id}
aria-posinset={index}
aria-setsize={editorItems.length}
>
<AddedAccountItem
accountId={account_id}
pending={state === 'pending'}
onRemove={handleRemoveAccountItem}
/>
</Article>
))}
</ItemList>
</Scrollable>
</div>
</FormStack>
{!isEditMode && hasItems && (
<div className={classes.stickyFooter}>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='collections.continue'
defaultMessage='Continue'
/>
)}
</Button>
</div>
)}
</form>
);
};