Allow managing collection accounts (#37812)

This commit is contained in:
diondiondion
2026-02-16 19:17:20 +01:00
committed by GitHub
parent cff25c186b
commit a644a4a762
15 changed files with 590 additions and 75 deletions

View File

@@ -11,6 +11,7 @@ import type {
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
ApiCollectionsJSON, ApiCollectionsJSON,
WrappedCollectionAccountItem,
} from '../api_types/collections'; } from '../api_types/collections';
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
@@ -37,3 +38,14 @@ export const apiGetAccountCollections = (accountId: string) =>
apiRequestGet<ApiCollectionsJSON>( apiRequestGet<ApiCollectionsJSON>(
`v1_alpha/accounts/${accountId}/collections`, `v1_alpha/accounts/${accountId}/collections`,
); );
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
apiRequestPost<WrappedCollectionAccountItem>(
`v1_alpha/collections/${collectionId}/items`,
{ account_id: accountId },
);
export const apiRemoveCollectionItem = (collectionId: string, itemId: string) =>
apiRequestDelete<WrappedCollectionAccountItem>(
`v1_alpha/collections/${collectionId}/items/${itemId}`,
);

View File

@@ -53,6 +53,7 @@ export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON
* Nested account item * Nested account item
*/ */
interface CollectionAccountItem { interface CollectionAccountItem {
id: string;
account_id?: string; // Only present when state is 'accepted' (or the collection is your own) account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
state: 'pending' | 'accepted' | 'rejected' | 'revoked'; state: 'pending' | 'accepted' | 'rejected' | 'revoked';
position: number; position: number;

View File

@@ -73,6 +73,7 @@ interface AccountProps {
defaultAction?: 'block' | 'mute'; defaultAction?: 'block' | 'mute';
withBio?: boolean; withBio?: boolean;
withMenu?: boolean; withMenu?: boolean;
children?: React.ReactNode;
} }
export const Account: React.FC<AccountProps> = ({ export const Account: React.FC<AccountProps> = ({
@@ -83,6 +84,7 @@ export const Account: React.FC<AccountProps> = ({
defaultAction, defaultAction,
withBio, withBio,
withMenu = true, withMenu = true,
children,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { signedIn } = useIdentity(); const { signedIn } = useIdentity();
@@ -353,6 +355,8 @@ export const Account: React.FC<AccountProps> = ({
{button} {button}
</div> </div>
)} )}
{children}
</div> </div>
</div> </div>
); );

View File

@@ -25,14 +25,17 @@
.popover { .popover {
z-index: 9999; z-index: 9999;
box-sizing: border-box; box-sizing: border-box;
max-height: max(200px, 30dvh);
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
color: var(--color-text-primary); color: var(--color-text-primary);
background: var(--color-bg-primary); background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
box-shadow: var(--dropdown-shadow); box-shadow: var(--dropdown-shadow);
overflow-y: auto;
// backdrop-filter: $backdrop-blur-filter; scrollbar-width: thin;
scrollbar-gutter: stable;
overscroll-behavior-y: contain;
} }
.menuItem { .menuItem {
@@ -47,7 +50,7 @@
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
&[aria-selected='true'] { &[data-highlighted='true'] {
color: var(--color-text-on-brand-base); color: var(--color-text-on-brand-base);
background: var(--color-bg-brand-base); background: var(--color-bg-brand-base);

View File

@@ -18,24 +18,30 @@ import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { TextInput } from './text_input_field'; import { TextInput } from './text_input_field';
interface Item { interface ComboboxItem {
id: string; id: string;
} }
export interface ComboboxItemState {
isSelected: boolean;
isDisabled: boolean;
}
interface ComboboxProps< interface ComboboxProps<
T extends Item, T extends ComboboxItem,
> extends ComponentPropsWithoutRef<'input'> { > extends ComponentPropsWithoutRef<'input'> {
value: string; value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>; onChange: React.ChangeEventHandler<HTMLInputElement>;
isLoading?: boolean; isLoading?: boolean;
items: T[]; items: T[];
getItemId: (item: T) => string; getItemId: (item: T) => string;
getIsItemSelected?: (item: T) => boolean;
getIsItemDisabled?: (item: T) => boolean; getIsItemDisabled?: (item: T) => boolean;
renderItem: (item: T) => React.ReactElement; renderItem: (item: T, state: ComboboxItemState) => React.ReactElement;
onSelectItem: (item: T) => void; onSelectItem: (item: T) => void;
} }
interface Props<T extends Item> interface Props<T extends ComboboxItem>
extends ComboboxProps<T>, CommonFieldWrapperProps {} extends ComboboxProps<T>, CommonFieldWrapperProps {}
/** /**
@@ -43,7 +49,7 @@ interface Props<T extends Item>
* from a large list of options by searching or filtering. * from a large list of options by searching or filtering.
*/ */
export const ComboboxFieldWithRef = <T extends Item>( export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>, { id, label, hint, hasError, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>, ref: React.ForwardedRef<HTMLInputElement>,
) => ( ) => (
@@ -61,7 +67,7 @@ export const ComboboxFieldWithRef = <T extends Item>(
// Using a type assertion to maintain the full type signature of ComboboxWithRef // Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`. // (including its generic type) after wrapping it with `forwardRef`.
export const ComboboxField = forwardRef(ComboboxFieldWithRef) as { export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
<T extends Item>( <T extends ComboboxItem>(
props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> }, props: Props<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxFieldWithRef>; ): ReturnType<typeof ComboboxFieldWithRef>;
displayName: string; displayName: string;
@@ -69,13 +75,15 @@ export const ComboboxField = forwardRef(ComboboxFieldWithRef) as {
ComboboxField.displayName = 'ComboboxField'; ComboboxField.displayName = 'ComboboxField';
const ComboboxWithRef = <T extends Item>( const ComboboxWithRef = <T extends ComboboxItem>(
{ {
value, value,
isLoading = false, isLoading = false,
items, items,
getItemId, getItemId,
getIsItemDisabled, getIsItemDisabled,
getIsItemSelected,
disabled,
renderItem, renderItem,
onSelectItem, onSelectItem,
onChange, onChange,
@@ -88,6 +96,7 @@ const ComboboxWithRef = <T extends Item>(
const intl = useIntl(); const intl = useIntl();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(); const inputRef = useRef<HTMLInputElement | null>();
const popoverRef = useRef<HTMLDivElement>(null);
const [highlightedItemId, setHighlightedItemId] = useState<string | null>( const [highlightedItemId, setHighlightedItemId] = useState<string | null>(
null, null,
@@ -101,11 +110,13 @@ const ComboboxWithRef = <T extends Item>(
}); });
const showStatusMessageInMenu = const showStatusMessageInMenu =
!!statusMessage && value.length > 0 && items.length === 0; !!statusMessage && value.length > 0 && items.length === 0;
const hasMenuContent = items.length > 0 || showStatusMessageInMenu; const hasMenuContent =
!disabled && (items.length > 0 || showStatusMessageInMenu);
const isMenuOpen = shouldMenuOpen && hasMenuContent; const isMenuOpen = shouldMenuOpen && hasMenuContent;
const openMenu = useCallback(() => { const openMenu = useCallback(() => {
setShouldMenuOpen(true); setShouldMenuOpen(true);
inputRef.current?.focus();
}, []); }, []);
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
@@ -118,6 +129,18 @@ const ComboboxWithRef = <T extends Item>(
setHighlightedItemId(firstItemId); setHighlightedItemId(firstItemId);
}, [getItemId, items]); }, [getItemId, items]);
const highlightItem = useCallback((id: string | null) => {
setHighlightedItemId(id);
if (id) {
const itemElement = popoverRef.current?.querySelector<HTMLLIElement>(
`[data-item-id='${id}']`,
);
if (itemElement && popoverRef.current) {
scrollItemIntoView(itemElement, popoverRef.current);
}
}
}, []);
const handleInputChange = useCallback( const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e); onChange(e);
@@ -127,14 +150,14 @@ const ComboboxWithRef = <T extends Item>(
[onChange, resetHighlight], [onChange, resetHighlight],
); );
const handleHighlightItem = useCallback( const handleItemMouseEnter = useCallback(
(e: React.MouseEvent<HTMLLIElement>) => { (e: React.MouseEvent<HTMLLIElement>) => {
const { itemId } = e.currentTarget.dataset; const { itemId } = e.currentTarget.dataset;
if (itemId) { if (itemId) {
setHighlightedItemId(itemId); highlightItem(itemId);
} }
}, },
[], [highlightItem],
); );
const selectItem = useCallback( const selectItem = useCallback(
@@ -175,10 +198,10 @@ const ComboboxWithRef = <T extends Item>(
// If no item is highlighted yet, highlight the first or last // If no item is highlighted yet, highlight the first or last
if (direction > 0) { if (direction > 0) {
const firstItem = items.at(0); const firstItem = items.at(0);
setHighlightedItemId(firstItem ? getItemId(firstItem) : null); highlightItem(firstItem ? getItemId(firstItem) : null);
} else { } else {
const lastItem = items.at(-1); const lastItem = items.at(-1);
setHighlightedItemId(lastItem ? getItemId(lastItem) : null); highlightItem(lastItem ? getItemId(lastItem) : null);
} }
} else { } else {
// If there is a highlighted item, select the next or previous item // If there is a highlighted item, select the next or previous item
@@ -191,12 +214,12 @@ const ComboboxWithRef = <T extends Item>(
} }
const newHighlightedItem = items[newIndex]; const newHighlightedItem = items[newIndex];
setHighlightedItemId( highlightItem(
newHighlightedItem ? getItemId(newHighlightedItem) : null, newHighlightedItem ? getItemId(newHighlightedItem) : null,
); );
} }
}, },
[getItemId, highlightedItemId, items], [getItemId, highlightItem, highlightedItemId, items],
); );
useOnClickOutside(wrapperRef, closeMenu); useOnClickOutside(wrapperRef, closeMenu);
@@ -231,7 +254,6 @@ const ComboboxWithRef = <T extends Item>(
if (isMenuOpen) { if (isMenuOpen) {
e.preventDefault(); e.preventDefault();
selectHighlightedItem(); selectHighlightedItem();
closeMenu();
} }
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -271,9 +293,10 @@ const ComboboxWithRef = <T extends Item>(
<TextInput <TextInput
role='combobox' role='combobox'
{...otherProps} {...otherProps}
disabled={disabled}
aria-controls={listId} aria-controls={listId}
aria-expanded={isMenuOpen ? 'true' : 'false'} aria-expanded={isMenuOpen ? 'true' : 'false'}
aria-haspopup='true' aria-haspopup='listbox'
aria-activedescendant={ aria-activedescendant={
isMenuOpen && highlightedItemId ? highlightedItemId : undefined isMenuOpen && highlightedItemId ? highlightedItemId : undefined
} }
@@ -311,11 +334,11 @@ const ComboboxWithRef = <T extends Item>(
{isMenuOpen && statusMessage} {isMenuOpen && statusMessage}
</span> </span>
<Overlay <Overlay
flip
show={isMenuOpen} show={isMenuOpen}
offset={[0, 1]} offset={[0, 1]}
placement='bottom-start' placement='bottom-start'
onHide={closeMenu} onHide={closeMenu}
ref={popoverRef}
target={inputRef as React.RefObject<HTMLInputElement>} target={inputRef as React.RefObject<HTMLInputElement>}
container={wrapperRef} container={wrapperRef}
popperConfig={{ popperConfig={{
@@ -331,19 +354,30 @@ const ComboboxWithRef = <T extends Item>(
{items.map((item) => { {items.map((item) => {
const id = getItemId(item); const id = getItemId(item);
const isDisabled = getIsItemDisabled?.(item); const isDisabled = getIsItemDisabled?.(item);
const isHighlighted = id === highlightedItemId;
// If `getIsItemSelected` is defined, we assume 'multi-select'
// behaviour and don't set `aria-selected` based on highlight,
// but based on selected item state.
const isSelected = getIsItemSelected
? getIsItemSelected(item)
: isHighlighted;
return ( return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events // eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li <li
key={id} key={id}
role='option' role='option'
className={classes.menuItem} className={classes.menuItem}
aria-selected={id === highlightedItemId} data-highlighted={isHighlighted}
aria-selected={isSelected}
aria-disabled={isDisabled} aria-disabled={isDisabled}
data-item-id={id} data-item-id={id}
onMouseEnter={handleHighlightItem} onMouseEnter={handleItemMouseEnter}
onClick={handleSelectItem} onClick={handleSelectItem}
> >
{renderItem(item)} {renderItem(item, {
isSelected,
isDisabled: isDisabled ?? false,
})}
</li> </li>
); );
})} })}
@@ -359,7 +393,7 @@ const ComboboxWithRef = <T extends Item>(
// Using a type assertion to maintain the full type signature of ComboboxWithRef // Using a type assertion to maintain the full type signature of ComboboxWithRef
// (including its generic type) after wrapping it with `forwardRef`. // (including its generic type) after wrapping it with `forwardRef`.
export const Combobox = forwardRef(ComboboxWithRef) as { export const Combobox = forwardRef(ComboboxWithRef) as {
<T extends Item>( <T extends ComboboxItem>(
props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> }, props: ComboboxProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
): ReturnType<typeof ComboboxWithRef>; ): ReturnType<typeof ComboboxWithRef>;
displayName: string; displayName: string;
@@ -406,3 +440,23 @@ function useGetA11yStatusMessage({
} }
return ''; return '';
} }
const SCROLL_MARGIN = 6;
function scrollItemIntoView(item: HTMLElement, scrollParent: HTMLElement) {
const itemTopEdge = item.offsetTop;
const itemBottomEdge = itemTopEdge + item.offsetHeight;
// If item is above scroll area, scroll up
if (itemTopEdge < scrollParent.scrollTop) {
scrollParent.scrollTop = itemTopEdge - SCROLL_MARGIN;
}
// If item is below scroll area, scroll down
else if (
itemBottomEdge >
scrollParent.scrollTop + scrollParent.offsetHeight
) {
scrollParent.scrollTop =
itemBottomEdge - scrollParent.offsetHeight + SCROLL_MARGIN;
}
}

View File

@@ -3,7 +3,11 @@ export { Fieldset } from './fieldset';
export { TextInputField, TextInput } from './text_input_field'; export { TextInputField, TextInput } from './text_input_field';
export { TextAreaField, TextArea } from './text_area_field'; export { TextAreaField, TextArea } from './text_area_field';
export { CheckboxField, Checkbox } from './checkbox_field'; export { CheckboxField, Checkbox } from './checkbox_field';
export { ComboboxField, Combobox } from './combobox_field'; export {
ComboboxField,
Combobox,
type ComboboxItemState,
} from './combobox_field';
export { RadioButtonField, RadioButton } from './radio_button_field'; export { RadioButtonField, RadioButton } from './radio_button_field';
export { ToggleField, Toggle } from './toggle_field'; export { ToggleField, Toggle } from './toggle_field';
export { SelectField, Select } from './select_field'; export { SelectField, Select } from './select_field';

View File

@@ -1,67 +1,363 @@
import { useCallback } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom'; 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 'mastodon/api_types/collections'; 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 { Button } from 'mastodon/components/button';
import { FormStack } from 'mastodon/components/form_fields'; import { Callout } from 'mastodon/components/callout';
import { DisplayName } from 'mastodon/components/display_name';
import { EmptyState } from 'mastodon/components/empty_state';
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts';
import {
addCollectionItem,
removeCollectionItem,
} from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
import { getInitialState } from './state'; import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; 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<{ export const CollectionAccounts: React.FC<{
collection?: ApiCollectionJSON | null; collection?: ApiCollectionJSON | null;
}> = ({ collection }) => { }> = ({ collection }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialItemIds } = getCollectionEditorState(
collection,
location.state,
);
const isEditMode = !!id;
const collectionItems = collection?.items;
const { id } = getInitialState(collection, location.state); 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();
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( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!canSubmit) {
return;
}
if (!id) { if (!id) {
history.push(`/collections/new/details`); history.push(`/collections/new/details`, {
account_ids: accountIds,
});
} }
}, },
[id, history], [canSubmit, id, history, accountIds],
); );
return ( return (
<FormStack as='form' onSubmit={handleSubmit}> <form onSubmit={handleSubmit} className={classes.form}>
{!id && ( <FormStack className={classes.formFieldStack}>
<WizardStepHeader {!id && (
step={1} <WizardStepHeader
title={ 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 <FormattedMessage
id='collections.create.accounts_title' id='collections.search_accounts_label'
defaultMessage='Who will you feature in this collection?' defaultMessage='Search for accounts to add…'
/> />
} }
description={ hint={
<FormattedMessage hasMaxAccounts ? (
id='collections.create.accounts_subtitle' <FormattedMessage
defaultMessage='Only accounts you follow who have opted into discovery can be added.' 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
} }
/> />
)}
<div className='actions'> {hasMinAccounts && (
<Button type='submit'> <Callout>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage <FormattedMessage
id='collections.continue' id='collections.hints.can_not_remove_more_accounts'
defaultMessage='Continue' 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>
)} )}
</Button> </div>
</div> )}
</FormStack> </form>
); );
}; };

View File

@@ -16,7 +16,8 @@ import { updateCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
import { getInitialState } from './state'; import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC<{ export const CollectionDetails: React.FC<{
@@ -26,10 +27,8 @@ export const CollectionDetails: React.FC<{
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialName, initialDescription, initialTopic } = getInitialState( const { id, initialName, initialDescription, initialTopic, initialItemIds } =
collection, getCollectionEditorState(collection, location.state);
location.state,
);
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription); const [description, setDescription] = useState(initialDescription);
@@ -76,13 +75,14 @@ export const CollectionDetails: React.FC<{
name, name,
description, description,
tag_name: topic || null, tag_name: topic || null,
account_ids: initialItemIds,
}; };
history.replace('/collections/new', payload); history.replace('/collections/new', payload);
history.push('/collections/new/settings', payload); history.push('/collections/new/settings', payload);
} }
}, },
[id, dispatch, name, description, topic, history], [id, name, description, topic, dispatch, history, initialItemIds],
); );
return ( return (
@@ -155,7 +155,7 @@ export const CollectionDetails: React.FC<{
maxLength={40} maxLength={40}
/> />
<div className='actions'> <div className={classes.actionWrapper}>
<Button type='submit'> <Button type='submit'>
{id ? ( {id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' /> <FormattedMessage id='lists.save' defaultMessage='Save' />

View File

@@ -25,7 +25,8 @@ import {
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
import type { TempCollectionState } from './state'; import type { TempCollectionState } from './state';
import { getInitialState } from './state'; import { getCollectionEditorState } from './state';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; import { WizardStepHeader } from './wizard_step_header';
export const CollectionSettings: React.FC<{ export const CollectionSettings: React.FC<{
@@ -35,8 +36,8 @@ export const CollectionSettings: React.FC<{
const history = useHistory(); const history = useHistory();
const location = useLocation<TempCollectionState>(); const location = useLocation<TempCollectionState>();
const { id, initialDiscoverable, initialSensitive, ...temporaryState } = const { id, initialDiscoverable, initialSensitive, ...editorState } =
getInitialState(collection, location.state); getCollectionEditorState(collection, location.state);
const [discoverable, setDiscoverable] = useState(initialDiscoverable); const [discoverable, setDiscoverable] = useState(initialDiscoverable);
const [sensitive, setSensitive] = useState(initialSensitive); const [sensitive, setSensitive] = useState(initialSensitive);
@@ -71,13 +72,14 @@ export const CollectionSettings: React.FC<{
}); });
} else { } else {
const payload: ApiCreateCollectionPayload = { const payload: ApiCreateCollectionPayload = {
name: temporaryState.initialName, name: editorState.initialName,
description: temporaryState.initialDescription, description: editorState.initialDescription,
discoverable, discoverable,
sensitive, sensitive,
account_ids: editorState.initialItemIds,
}; };
if (temporaryState.initialTopic) { if (editorState.initialTopic) {
payload.tag_name = temporaryState.initialTopic; payload.tag_name = editorState.initialTopic;
} }
void dispatch( void dispatch(
@@ -94,7 +96,7 @@ export const CollectionSettings: React.FC<{
}); });
} }
}, },
[id, discoverable, sensitive, dispatch, history, temporaryState], [id, discoverable, sensitive, dispatch, history, editorState],
); );
return ( return (
@@ -180,7 +182,7 @@ export const CollectionSettings: React.FC<{
/> />
</Fieldset> </Fieldset>
<div className='actions'> <div className={classes.actionWrapper}>
<Button type='submit'> <Button type='submit'>
{id ? ( {id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' /> <FormattedMessage id='lists.save' defaultMessage='Save' />

View File

@@ -15,7 +15,7 @@ export type TempCollectionState =
* Resolve initial editor state. Temporary location state * Resolve initial editor state. Temporary location state
* trumps stored data, otherwise initial values are returned. * trumps stored data, otherwise initial values are returned.
*/ */
export function getInitialState( export function getCollectionEditorState(
collection: ApiCollectionJSON | null | undefined, collection: ApiCollectionJSON | null | undefined,
locationState: TempCollectionState, locationState: TempCollectionState,
) { ) {
@@ -27,10 +27,19 @@ export function getInitialState(
language = '', language = '',
discoverable = true, discoverable = true,
sensitive = false, sensitive = false,
items,
} = collection ?? {}; } = collection ?? {};
const collectionItemIds =
items?.map((item) => item.account_id).filter(onlyExistingIds) ?? [];
const initialItemIds = (
locationState?.account_ids ?? collectionItemIds
).filter(onlyExistingIds);
return { return {
id, id,
initialItemIds,
initialName: locationState?.name ?? name, initialName: locationState?.name ?? name,
initialDescription: locationState?.description ?? description, initialDescription: locationState?.description ?? description,
initialTopic: locationState?.tag_name ?? tag?.name ?? '', initialTopic: locationState?.tag_name ?? tag?.name ?? '',
@@ -39,3 +48,5 @@ export function getInitialState(
initialSensitive: locationState?.sensitive ?? sensitive, initialSensitive: locationState?.sensitive ?? sensitive,
}; };
} }
const onlyExistingIds = (id?: string): id is string => !!id;

View File

@@ -13,3 +13,76 @@
font-size: 15px; font-size: 15px;
margin-top: 8px; margin-top: 8px;
} }
/* Make form stretch full height of the column */
.form {
--bottom-spacing: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 100%;
padding-bottom: var(--bottom-spacing);
@media (width < 760px) {
--bottom-spacing: var(--mobile-bottom-nav-height);
}
}
.selectedSuggestionIcon {
box-sizing: border-box;
width: 18px;
height: 18px;
margin-left: auto;
padding: 2px;
border-radius: 100%;
color: var(--color-text-on-brand-base);
background: var(--color-bg-brand-base);
[data-highlighted='true'] & {
color: var(--color-bg-brand-base);
background: var(--color-text-on-brand-base);
}
}
.formFieldStack {
flex-grow: 1;
}
.scrollableWrapper {
display: flex;
flex: 1;
margin-inline: -8px;
}
.scrollableInner {
margin-inline: -8px;
}
.submitDisabledCallout {
align-self: center;
}
.stickyFooter {
position: sticky;
bottom: var(--bottom-spacing);
padding: 16px;
background-image: linear-gradient(
to bottom,
transparent,
var(--color-bg-primary) 32px
);
}
.itemCountReadout {
text-align: center;
}
.actionWrapper {
display: flex;
flex-direction: column;
width: min-content;
min-width: 240px;
margin-inline: auto;
gap: 8px;
}

View File

@@ -165,10 +165,11 @@ const ListMembers: React.FC<{
const [mode, setMode] = useState<Mode>('remove'); const [mode, setMode] = useState<Mode>('remove');
const { const {
accountIds: searchAccountIds = [], accountIds: searchAccountIds,
isLoading: loadingSearchResults, isLoading: loadingSearchResults,
searchAccounts: handleSearch, searchAccounts: handleSearch,
} = useSearchAccounts({ } = useSearchAccounts({
resetOnInputClear: false,
onSettled: (value) => { onSettled: (value) => {
if (value.trim().length === 0) { if (value.trim().length === 0) {
setSearching(false); setSearching(false);

View File

@@ -8,13 +8,15 @@ import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
export function useSearchAccounts({ export function useSearchAccounts({
resetOnInputClear = true,
onSettled, onSettled,
}: { }: {
onSettled?: (value: string) => void; onSettled?: (value: string) => void;
resetOnInputClear?: boolean;
} = {}) { } = {}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [accountIds, setAccountIds] = useState<string[]>(); const [accountIds, setAccountIds] = useState<string[]>([]);
const [loadingState, setLoadingState] = useState< const [loadingState, setLoadingState] = useState<
'idle' | 'loading' | 'error' 'idle' | 'loading' | 'error'
>('idle'); >('idle');
@@ -29,6 +31,9 @@ export function useSearchAccounts({
if (value.trim().length === 0) { if (value.trim().length === 0) {
onSettled?.(''); onSettled?.('');
if (resetOnInputClear) {
setAccountIds([]);
}
return; return;
} }

View File

@@ -244,9 +244,12 @@
"closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!", "closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!",
"closed_registrations_modal.title": "Signing up on Mastodon", "closed_registrations_modal.title": "Signing up on Mastodon",
"collections.account_count": "{count, plural, one {# account} other {# accounts}}", "collections.account_count": "{count, plural, one {# account} other {# accounts}}",
"collections.accounts.empty_description": "Add up to {count} accounts you follow",
"collections.accounts.empty_title": "This collection is empty",
"collections.collection_description": "Description", "collections.collection_description": "Description",
"collections.collection_name": "Name", "collections.collection_name": "Name",
"collections.collection_topic": "Topic", "collections.collection_topic": "Topic",
"collections.confirm_account_removal": "Are you sure you want to remove this account from this collection?",
"collections.content_warning": "Content warning", "collections.content_warning": "Content warning",
"collections.continue": "Continue", "collections.continue": "Continue",
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.", "collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
@@ -261,6 +264,9 @@
"collections.edit_details": "Edit basic details", "collections.edit_details": "Edit basic details",
"collections.edit_settings": "Edit settings", "collections.edit_settings": "Edit settings",
"collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.add_more_accounts": "Add at least {count, plural, one {# account} other {# accounts}} to continue",
"collections.hints.can_not_remove_more_accounts": "Collections must contain at least {count, plural, one {# account} other {# accounts}}. Removing more accounts is not possible.",
"collections.last_updated_at": "Last updated: {date}", "collections.last_updated_at": "Last updated: {date}",
"collections.manage_accounts": "Manage accounts", "collections.manage_accounts": "Manage accounts",
"collections.manage_accounts_in_collection": "Manage accounts in this collection", "collections.manage_accounts_in_collection": "Manage accounts in this collection",
@@ -269,6 +275,9 @@
"collections.name_length_hint": "100 characters limit", "collections.name_length_hint": "100 characters limit",
"collections.new_collection": "New collection", "collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.", "collections.no_collections_yet": "No collections yet.",
"collections.remove_account": "Remove this account",
"collections.search_accounts_label": "Search for accounts to add…",
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
"collections.view_collection": "View collection", "collections.view_collection": "View collection",
"collections.visibility_public": "Public", "collections.visibility_public": "Public",

View File

@@ -7,6 +7,8 @@ import {
apiUpdateCollection, apiUpdateCollection,
apiGetCollection, apiGetCollection,
apiDeleteCollection, apiDeleteCollection,
apiAddCollectionItem,
apiRemoveCollectionItem,
} from '@/mastodon/api/collections'; } from '@/mastodon/api/collections';
import type { import type {
ApiCollectionJSON, ApiCollectionJSON,
@@ -131,6 +133,32 @@ const collectionSlice = createSlice({
}; };
} }
}); });
/**
* Adding an account to a collection
*/
builder.addCase(addCollectionItem.fulfilled, (state, action) => {
const { collection_item } = action.payload;
const { collectionId } = action.meta.arg;
state.collections[collectionId]?.items.push(collection_item);
});
/**
* Removing an account from a collection
*/
builder.addCase(removeCollectionItem.fulfilled, (state, action) => {
const { itemId, collectionId } = action.meta.arg;
const collection = state.collections[collectionId];
if (collection) {
collection.items = collection.items.filter(
(item) => item.id !== itemId,
);
}
});
}, },
}); });
@@ -169,6 +197,18 @@ export const deleteCollection = createDataLoadingThunk(
apiDeleteCollection(collectionId), apiDeleteCollection(collectionId),
); );
export const addCollectionItem = createDataLoadingThunk(
`${collectionSlice.name}/addCollectionItem`,
({ collectionId, accountId }: { collectionId: string; accountId: string }) =>
apiAddCollectionItem(collectionId, accountId),
);
export const removeCollectionItem = createDataLoadingThunk(
`${collectionSlice.name}/removeCollectionItem`,
({ collectionId, itemId }: { collectionId: string; itemId: string }) =>
apiRemoveCollectionItem(collectionId, itemId),
);
export const collections = collectionSlice.reducer; export const collections = collectionSlice.reducer;
/** /**