Mark pending accounts in collection editor (#38843)

This commit is contained in:
diondiondion 2026-04-29 12:35:09 +02:00 committed by GitHub
parent 614eda43ff
commit 41a3679d83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 84 additions and 44 deletions

View File

@ -116,7 +116,7 @@ const RevokeControls: React.FC<{
);
};
const PendingNote: React.FC = () => {
export const PendingNote: React.FC = () => {
return (
<Callout
variant='subtle'

View File

@ -6,16 +6,17 @@ import { useHistory } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import { useComboboxItemProps } from '@/mastodon/components/form_fields/combobox_field';
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,
@ -31,12 +32,14 @@ import { domain } from 'mastodon/initial_state';
import type { Relationship } from 'mastodon/models/relationship';
import {
addCollectionItem,
getCollectionItemIds,
getEditorCollectionItems,
removeCollectionItem,
updateCollectionEditorField,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { PendingNote } from '../detail';
import classes from './styles.module.scss';
import { WizardStepTitle } from './wizard_step_title';
@ -44,8 +47,9 @@ const MAX_ACCOUNT_COUNT = 25;
const AddedAccountItem: React.FC<{
accountId: string;
pending?: boolean;
onRemove: (id: string) => void;
}> = ({ accountId, onRemove }) => {
}> = ({ accountId, pending, onRemove }) => {
const handleRemoveAccount = useCallback(() => {
onRemove(accountId);
}, [accountId, onRemove]);
@ -62,7 +66,13 @@ const AddedAccountItem: React.FC<{
[handleRemoveAccount],
);
return <AccountListItem accountId={accountId} renderButton={renderButton} />;
return (
<AccountListItem
accountId={accountId}
badge={pending && <PendingBadge />}
renderButton={renderButton}
/>
);
};
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
@ -183,22 +193,26 @@ export const CollectionAccounts: React.FC<{
const { id, items: collectionItems } = collection ?? {};
const isEditMode = !!id;
const addedAccountIds = useAppSelector(
(state) => state.collections.editor.accountIds,
const editorItemsFromState = useAppSelector(
(state) => state.collections.editor.items,
);
// 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(
// 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 ? getCollectionItemIds(collectionItems) : addedAccountIds,
[isEditMode, collectionItems, addedAccountIds],
isEditMode
? getEditorCollectionItems(collectionItems)
: editorItemsFromState,
[isEditMode, collectionItems, editorItemsFromState],
);
const hasPendingItems = editorItems.some((item) => item.state === 'pending');
const [searchValue, setSearchValue] = useState('');
const hasAccounts = accountIds.length > 0;
const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
const hasItems = editorItems.length > 0;
const hasMaxItems = editorItems.length === MAX_ACCOUNT_COUNT;
const {
accounts: suggestedAccounts,
@ -208,7 +222,8 @@ export const CollectionAccounts: React.FC<{
} = useSearchAccounts({
withRelationships: true,
// Don't suggest accounts that were already added
filterResults: (account) => !accountIds.includes(account.id),
filterResults: (account) =>
!editorItems.find((item) => item.account_id === account.id),
});
const relationships = useAppSelector((state) => state.relationships);
@ -236,24 +251,33 @@ export const CollectionAccounts: React.FC<{
(accountId: string) => {
dispatch(
updateCollectionEditorField({
field: 'accountIds',
value: accountIds.filter((id) => id !== accountId),
field: 'items',
value: editorItems.filter((item) => item.account_id !== accountId),
}),
);
},
[accountIds, dispatch],
[editorItems, dispatch],
);
const addAccountItem = useCallback(
(item: ApiMutedAccountJSON) => {
dispatch(
updateCollectionEditorField({
field: 'accountIds',
value: [...accountIds, item.id],
field: 'items',
value: [
...editorItems,
{
account_id: item.id,
state:
item.feature_approval.current_user === 'manual'
? 'pending'
: 'accepted',
},
],
}),
);
},
[accountIds, dispatch],
[editorItems, dispatch],
);
const instantRemoveAccountItem = useCallback(
@ -319,12 +343,10 @@ export const CollectionAccounts: React.FC<{
e.preventDefault();
if (!id) {
history.push(`/collections/new/details`, {
account_ids: accountIds,
});
history.push('/collections/new/details');
}
},
[id, history, accountIds],
[id, history],
);
const inputId = useId();
@ -345,16 +367,17 @@ export const CollectionAccounts: React.FC<{
}
/>
)}
{hasPendingItems && <PendingNote />}
<ComboboxField
id={inputId}
label={intl.formatMessage({
id: 'collections.search_accounts_label',
defaultMessage: 'Search for an account to add',
})}
value={hasMaxAccounts ? '' : searchValue}
value={hasMaxItems ? '' : searchValue}
onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts}
disabled={hasMaxItems}
isLoading={isLoadingSuggestions}
items={groupedItems}
getItemId={getItemId}
@ -363,7 +386,7 @@ export const CollectionAccounts: React.FC<{
renderGroupTitle={renderGroupTitle}
onSelectItem={handleSelectItem}
status={
hasMaxAccounts
hasMaxItems
? {
variant: 'warning',
message: intl.formatMessage({
@ -378,12 +401,12 @@ export const CollectionAccounts: React.FC<{
</header>
<div>
{hasAccounts && (
{hasItems && (
<AccountsHeadingElement className={classes.listHeading}>
<FormattedMessage
id='collections.hints.accounts_counter'
defaultMessage='{count}/{max} accounts'
values={{ count: accountIds.length, max: MAX_ACCOUNT_COUNT }}
values={{ count: editorItems.length, max: MAX_ACCOUNT_COUNT }}
/>
</AccountsHeadingElement>
)}
@ -410,14 +433,15 @@ export const CollectionAccounts: React.FC<{
/>
}
>
{accountIds.map((accountId, index) => (
{editorItems.map(({ account_id, state }, index) => (
<Article
key={accountId}
key={account_id}
aria-posinset={index}
aria-setsize={accountIds.length}
aria-setsize={editorItems.length}
>
<AddedAccountItem
accountId={accountId}
accountId={account_id}
pending={state === 'pending'}
onRemove={handleRemoveAccountItem}
/>
</Article>
@ -426,7 +450,7 @@ export const CollectionAccounts: React.FC<{
</Scrollable>
</div>
</FormStack>
{!isEditMode && hasAccounts && (
{!isEditMode && hasItems && (
<div className={classes.stickyFooter}>
<Button type='submit'>
{id ? (

View File

@ -46,7 +46,7 @@ import { WizardStepTitle } from './wizard_step_title';
export const CollectionDetails: React.FC = () => {
const dispatch = useAppDispatch();
const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } =
const { id, name, description, topic, discoverable, sensitive, items } =
useAppSelector((state) => state.collections.editor);
const handleNameChange = useCallback(
@ -123,7 +123,7 @@ export const CollectionDetails: React.FC = () => {
description,
discoverable,
sensitive,
account_ids: accountIds,
account_ids: items.map((item) => item.account_id),
};
if (topic) {
payload.tag_name = topic;
@ -152,7 +152,7 @@ export const CollectionDetails: React.FC = () => {
sensitive,
dispatch,
history,
accountIds,
items,
currentUserName,
],
);

View File

@ -16,6 +16,7 @@ import type {
ApiCollectionJSON,
ApiCreateCollectionPayload,
ApiUpdateCollectionPayload,
CollectionAccountItem,
} from '@/mastodon/api_types/collections';
import { me } from '@/mastodon/initial_state';
import {
@ -41,6 +42,16 @@ interface CollectionState {
editor: EditorState;
}
/**
* This is a subset of the `CollectionAccountItem` type
* for use in the editor. Here, `account_id` is always defined
* and `state` is more limited.
*/
export interface EditorCollectionItem {
account_id: string;
state: 'pending' | 'accepted';
}
interface EditorState {
id: string | null;
name: string;
@ -49,7 +60,7 @@ interface EditorState {
language: string | null;
discoverable: boolean;
sensitive: boolean;
accountIds: string[];
items: EditorCollectionItem[];
}
interface UpdateEditorFieldPayload<K extends keyof EditorState> {
@ -68,7 +79,7 @@ const initialState: CollectionState = {
language: null,
discoverable: true,
sensitive: false,
accountIds: [],
items: [],
},
};
@ -87,7 +98,7 @@ const collectionSlice = createSlice({
language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false,
accountIds: getCollectionItemIds(collection?.items ?? []),
items: getEditorCollectionItems(collection?.items ?? []),
};
},
reset(state) {
@ -338,7 +349,12 @@ export const selectAccountCollections = createAppSelector(
},
);
const onlyExistingIds = (id?: string): id is string => !!id;
const isEditorItem = (
item: Partial<CollectionAccountItem>,
): item is EditorCollectionItem =>
!!item.account_id && (item.state === 'accepted' || item.state === 'pending');
export const getCollectionItemIds = (items?: ApiCollectionJSON['items']) =>
items?.map((item) => item.account_id).filter(onlyExistingIds) ?? [];
export const getEditorCollectionItems = (items?: CollectionAccountItem[]) =>
items
?.map(({ account_id, state }) => ({ account_id, state }))
.filter(isEditorItem) ?? [];