Update design of collection accounts editor (#38712)

This commit is contained in:
diondiondion 2026-04-16 15:59:38 +02:00 committed by GitHub
parent 0ef00be494
commit 5a38246ee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 240 additions and 318 deletions

View File

@ -35,3 +35,12 @@
text-wrap: pretty;
}
}
[data-color-scheme='dark'] .defaultImage {
--color-skin-1: #3a3a50;
--color-skin-2: #67678e;
--color-skin-3: #44445f;
--color-outline: #21212c;
--color-shadow: #181820;
--color-highlight: #b2b1c8;
}

View File

@ -29,12 +29,6 @@ export const Default: Story = {
},
};
export const WithoutMessage: Story = {
args: {
message: undefined,
},
};
export const WithAction: Story = {
args: {
...Default.args,
@ -42,3 +36,17 @@ export const WithAction: Story = {
children: <Button onClick={() => action('Refresh')}>Refresh</Button>,
},
};
export const WithoutImage: Story = {
args: {
...Default.args,
image: null,
},
};
export const WithoutMessage: Story = {
args: {
...Default.args,
message: undefined,
},
};

View File

@ -1,31 +1,39 @@
import { FormattedMessage } from 'react-intl';
import ElephantImage from '@/images/elephant_ui.svg?react';
import classes from './empty_state.module.scss';
const images = {
default: <ElephantImage className={classes.defaultImage} />,
};
/**
* Simple empty state component with a neutral default title and customisable message.
*
* Action buttons can be passed as `children`
* Action buttons can be passed as `children`.
*/
export const EmptyState: React.FC<{
image?: React.ReactNode;
image?: keyof typeof images | React.ReactElement | null;
title?: React.ReactNode;
message?: React.ReactNode;
children?: React.ReactNode;
}> = ({
image,
image = 'default',
title = (
<FormattedMessage id='empty_state.no_results' defaultMessage='No results' />
),
message,
children,
}) => {
const imageToRender = typeof image === 'string' ? images[image] : image;
return (
<div className={classes.wrapper}>
{(title || message || image) && (
{(title || message || imageToRender) && (
<div className={classes.content}>
{image}
{imageToRender}
{!!title && <h3>{title}</h3>}
{!!message && <p>{message}</p>}
</div>

View File

@ -1,8 +0,0 @@
[data-color-scheme='dark'] .image {
--color-skin-1: #3a3a50;
--color-skin-2: #67678e;
--color-skin-3: #44445f;
--color-outline: #21212c;
--color-shadow: #181820;
--color-highlight: #b2b1c8;
}

View File

@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import ElephantImage from '@/images/elephant_ui.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { Button } from '@/mastodon/components/button';
import { EmptyState } from '@/mastodon/components/empty_state';
@ -14,8 +13,6 @@ import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import { useAppDispatch } from '@/mastodon/store';
import classes from './empty_message.module.scss';
interface EmptyMessageProps {
suspended: boolean;
hidden: boolean;
@ -54,14 +51,11 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
const hasCollections = areCollectionsEnabled();
const image = <ElephantImage className={classes.image} />;
if (me === accountId) {
if (hasCollections) {
// Return only here to insert the "Create a collection" button as the action for the empty state.
return (
<EmptyState
image={image}
title={
<FormattedMessage
id='empty_column.account_featured_self.showcase_accounts'
@ -140,5 +134,5 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
}
}
return <EmptyState title={title} message={message} image={image} />;
return <EmptyState title={title} message={message} />;
};

View File

@ -4,22 +4,16 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } 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 WarningIcon from '@/material-icons/400-24px/warning.svg?react';
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 { Badge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { EmptyState } from 'mastodon/components/empty_state';
import { FormStack, Combobox } from 'mastodon/components/form_fields';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
import {
Article,
ItemList,
@ -37,75 +31,35 @@ import {
import { store, useAppDispatch, useAppSelector } from 'mastodon/store';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
import { WizardStepTitle } from './wizard_step_title';
const MAX_ACCOUNT_COUNT = 25;
function isOlderThanAWeek(date?: string): boolean {
if (!date) return false;
const targetDate = new Date(date);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return targetDate < sevenDaysAgo;
}
const MAX_ACCOUNT_COUNT = 3;
const AddedAccountItem: React.FC<{
accountId: string;
onRemove: (id: string) => void;
}> = ({ accountId, onRemove }) => {
const intl = useIntl();
const account = useAccount(accountId);
const handleRemoveAccount = useCallback(() => {
onRemove(accountId);
}, [accountId, onRemove]);
const lastStatusAt = account?.last_status_at;
const lastPostHint = useMemo(
() =>
(!lastStatusAt || isOlderThanAWeek(lastStatusAt)) && (
<Badge
label={
<FormattedMessage
id='collections.old_last_post_note'
defaultMessage='Last posted over a week ago'
/>
}
icon={<WarningIcon />}
className={classes.accountBadge}
/>
),
[lastStatusAt],
);
return (
<Account
minimal
key={accountId}
id={accountId}
extraAccountInfo={lastPostHint}
>
<IconButton
title={intl.formatMessage({
id: 'collections.remove_account',
defaultMessage: 'Remove this account',
})}
icon='remove'
iconComponent={CancelIcon}
onClick={handleRemoveAccount}
/>
<Account minimal key={accountId} id={accountId}>
<Button compact secondary onClick={handleRemoveAccount}>
<FormattedMessage
id='collections.remove_account'
defaultMessage='Remove'
/>
</Button>
</Account>
);
};
interface SuggestionItem {
id: string;
isSelected: boolean;
}
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id }) => {
const account = useAccount(id);
if (!account) return null;
@ -114,23 +68,15 @@ const SuggestedAccountItem: React.FC<SuggestionItem> = ({ id, isSelected }) => {
<>
<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} />
<SuggestedAccountItem id={item.id} />
);
const getItemId = (item: SuggestionItem) => item.id;
const getIsItemSelected = (item: SuggestionItem) => item.isSelected;
export const CollectionAccounts: React.FC<{
collection?: ApiCollectionJSON | null;
@ -139,9 +85,8 @@ export const CollectionAccounts: React.FC<{
const dispatch = useAppDispatch();
const history = useHistory();
const { id, items } = collection ?? {};
const { id, items: collectionItems } = collection ?? {};
const isEditMode = !!id;
const collectionItems = items;
const addedAccountIds = useAppSelector(
(state) => state.collections.editor.accountIds,
@ -157,22 +102,25 @@ export const CollectionAccounts: React.FC<{
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,
isSelected: accountIds.includes(id),
isDisabled: accountIds.includes(id),
}));
const handleSearchValueChange = useCallback(
@ -242,12 +190,12 @@ export const CollectionAccounts: React.FC<{
);
const addAccountItem = useCallback(
(accountId: string) => {
confirmFollowStatus(accountId, () => {
(item: SuggestionItem) => {
confirmFollowStatus(item.id, () => {
dispatch(
updateCollectionEditorField({
field: 'accountIds',
value: [...accountIds, accountId],
value: [...accountIds, item.id],
}),
);
});
@ -255,17 +203,6 @@ export const CollectionAccounts: React.FC<{
[accountIds, confirmFollowStatus, dispatch],
);
const toggleAccountItem = useCallback(
(item: SuggestionItem) => {
if (accountIds.includes(item.id)) {
removeAccountItem(item.id);
} else {
addAccountItem(item.id);
}
},
[accountIds, addAccountItem, removeAccountItem],
);
const instantRemoveAccountItem = useCallback(
(accountId: string) => {
const itemId = collectionItems?.find(
@ -289,23 +226,16 @@ export const CollectionAccounts: React.FC<{
);
const instantAddAccountItem = useCallback(
(collectionId: string, accountId: string) => {
confirmFollowStatus(accountId, () => {
void dispatch(addCollectionItem({ collectionId, accountId }));
(item: SuggestionItem) => {
confirmFollowStatus(item.id, () => {
if (id) {
void dispatch(
addCollectionItem({ collectionId: id, accountId: item.id }),
);
}
});
},
[confirmFollowStatus, dispatch],
);
const instantToggleAccountItem = useCallback(
(item: SuggestionItem) => {
if (accountIds.includes(item.id)) {
instantRemoveAccountItem(item.id);
} else if (id) {
instantAddAccountItem(id, item.id);
}
},
[accountIds, id, instantAddAccountItem, instantRemoveAccountItem],
[confirmFollowStatus, dispatch, id],
);
const handleRemoveAccountItem = useCallback(
@ -319,6 +249,20 @@ export const CollectionAccounts: React.FC<{
[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();
@ -333,117 +277,114 @@ export const CollectionAccounts: React.FC<{
);
const inputId = useId();
const inputLabel = intl.formatMessage({
id: 'collections.search_accounts_label',
defaultMessage: 'Search for accounts to add…',
});
const AccountsHeadingElement = id ? 'h2' : 'h3';
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.'
/>
}
/>
)}
<label htmlFor={inputId} className='sr-only'>
{inputLabel}
</label>
<Combobox
id={inputId}
placeholder={inputLabel}
value={hasMaxAccounts ? '' : searchValue}
onChange={handleSearchValueChange}
onKeyDown={handleSearchKeyDown}
disabled={hasMaxAccounts}
isLoading={isLoadingSuggestions}
items={suggestedItems}
getItemId={getItemId}
getIsItemSelected={getIsItemSelected}
renderItem={renderAccountItem}
onSelectItem={
isEditMode ? instantToggleAccountItem : toggleAccountItem
}
closeOnSelect={false}
/>
{hasMaxAccounts && (
<FormattedMessage
id='collections.search_accounts_max_reached'
defaultMessage='You have added the maximum number of accounts'
/>
)}
<Scrollable className={classes.scrollableWrapper}>
<ItemList
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,
}}
/>
}
/>
}
>
{accountIds.map((accountId, index) => (
<Article
key={accountId}
aria-posinset={index}
aria-setsize={accountIds.length}
>
<AddedAccountItem
accountId={accountId}
onRemove={handleRemoveAccountItem}
/>
</Article>
))}
</ItemList>
</Scrollable>
</FormStack>
{!isEditMode && (
<div className={classes.stickyFooter}>
<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>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<header className={classes.header}>
{!id && (
<WizardStepTitle
step={1}
title={
<FormattedMessage
id='collections.continue'
defaultMessage='Continue'
id='collections.create.accounts_title'
defaultMessage='Who will you feature in this collection?'
/>
)}
</Button>
</div>
}
/>
)}
<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>

View File

@ -36,7 +36,7 @@ import {
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header';
import { WizardStepTitle } from './wizard_step_title';
export const CollectionDetails: React.FC = () => {
const dispatch = useAppDispatch();
@ -152,7 +152,7 @@ export const CollectionDetails: React.FC = () => {
<form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}>
{!id && (
<WizardStepHeader
<WizardStepTitle
step={2}
title={
<FormattedMessage

View File

@ -44,7 +44,7 @@ export const messages = defineMessages({
},
});
function usePageTitle(id: string | undefined) {
function usePageTitle(id: string | null) {
const { path } = useRouteMatch();
const location = useLocation();
@ -66,7 +66,7 @@ export const CollectionEditorPage: React.FC<{
}> = ({ multiColumn }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const { id = null } = useParams<{ id?: string }>();
const { path } = useRouteMatch();
const collection = useAppSelector((state) =>
id ? state.collections.collections[id] : undefined,

View File

@ -1,10 +1,17 @@
.header {
display: flex;
flex-direction: column;
gap: 12px;
}
.step {
font-size: 13px;
color: var(--color-text-secondary);
}
.title {
font-size: 22px;
font-size: 17px;
font-weight: 500;
line-height: 1.2;
margin-top: 4px;
}
@ -14,79 +21,40 @@
margin-top: 8px;
}
/* Make form stretch full height of the column */
.listHeading {
margin-bottom: 8px;
font-size: 15px;
font-weight: 500;
line-height: 1.2;
}
/* Ensure sticky footer isn't covered by mobile bottom nav */
.form {
--bottom-spacing: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 100%;
padding-bottom: var(--bottom-spacing);
padding-bottom: 0;
@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,
.scrollableInner {
margin-inline: -8px;
}
.submitDisabledCallout {
align-self: center;
}
.stickyFooter {
position: sticky;
bottom: var(--bottom-spacing);
margin-top: -16px;
padding: 16px;
background-image: linear-gradient(
to bottom,
transparent,
var(--color-bg-primary) 32px
var(--color-bg-primary) 24px
);
}
.itemCountReadout {
text-align: center;
.formFieldStack {
gap: 16px;
}
.actionWrapper {
display: flex;
flex-direction: column;
width: min-content;
min-width: 240px;
margin-inline: auto;
gap: 8px;
}
.accountBadge {
margin-inline-start: 56px;
@container (width < 360px) {
margin-top: 4px;
margin-inline-start: 46px;
}
.scrollableWrapper {
margin-inline: -16px;
}

View File

@ -1,23 +0,0 @@
import { FormattedMessage } from 'react-intl';
import classes from './styles.module.scss';
export const WizardStepHeader: React.FC<{
step: number;
title: React.ReactElement;
description?: React.ReactElement;
}> = ({ step, title, description }) => {
return (
<header>
<FormattedMessage
id='collections.create.steps'
defaultMessage='Step {step}/{total}'
values={{ step, total: 2 }}
>
{(content) => <p className={classes.step}>{content}</p>}
</FormattedMessage>
<h2 className={classes.title}>{title}</h2>
{!!description && <p className={classes.description}>{description}</p>}
</header>
);
};

View File

@ -0,0 +1,21 @@
import { FormattedMessage } from 'react-intl';
import classes from './styles.module.scss';
export const WizardStepTitle: React.FC<{
step: number;
title: React.ReactElement;
}> = ({ step, title }) => {
return (
<div>
<p className={classes.step}>
<FormattedMessage
id='collections.create.steps'
defaultMessage='Step {step}/{total}'
values={{ step, total: 2 }}
/>
</p>
<h2 className={classes.title}>{title}</h2>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
@ -73,8 +73,13 @@ export function useSearchAccounts({
{ leading: true, trailing: true },
);
const resetAccounts = useCallback(() => {
setAccountIds([]);
}, []);
return {
searchAccounts,
resetAccounts,
accountIds,
isLoading: loadingState === 'loading',
isError: loadingState === 'error',

View File

@ -359,7 +359,8 @@
"collection.share_template_other": "Check out this cool collection: {link}",
"collection.share_template_own": "Check out my new collection: {link}",
"collections.account_count": "{count, plural, one {# account} other {# accounts}}",
"collections.accounts.empty_description": "Add up to {count} accounts you follow",
"collections.accounts.empty_description": "Add up to {count} accounts",
"collections.accounts.empty_editor_title": "No one is in this collection yet",
"collections.accounts.empty_title": "This collection is empty",
"collections.block_collection_owner": "Block account",
"collections.by_account": "by {account_handle}",
@ -373,7 +374,6 @@
"collections.continue": "Continue",
"collections.copy_link": "Copy link",
"collections.copy_link_confirmation": "Copied collection link to clipboard",
"collections.create.accounts_subtitle": "Only accounts you follow who have opted into discovery can be added.",
"collections.create.accounts_title": "Who will you feature in this collection?",
"collections.create.basic_details_title": "Basic details",
"collections.create.steps": "Step {step}/{total}",
@ -393,7 +393,7 @@
"collections.error_loading_collections": "There was an error when trying to load your collections.",
"collections.hidden_accounts_description": "Youve blocked or muted {count, plural, one {this user} other {these users}}",
"collections.hidden_accounts_link": "{count, plural, one {# hidden account} other {# hidden accounts}}",
"collections.hints.accounts_counter": "{count} / {max} accounts",
"collections.hints.accounts_counter": "{count}/{max} accounts",
"collections.last_updated_at": "Last updated: {date}",
"collections.manage_accounts": "Manage accounts",
"collections.mark_as_sensitive": "Mark as sensitive",
@ -401,13 +401,12 @@
"collections.name_length_hint": "40 characters limit",
"collections.new_collection": "New collection",
"collections.no_collections_yet": "No collections yet.",
"collections.old_last_post_note": "Last posted over a week ago",
"collections.remove_account": "Remove this account",
"collections.remove_account": "Remove",
"collections.report_collection": "Report this collection",
"collections.revoke_collection_inclusion": "Remove myself from this collection",
"collections.revoke_inclusion.confirmation": "You've been removed from \"{collection}\"",
"collections.revoke_inclusion.error": "There was an error, please try again later.",
"collections.search_accounts_label": "Search for accounts to add",
"collections.search_accounts_label": "Search for an account to add",
"collections.search_accounts_max_reached": "You have added the maximum number of accounts",
"collections.sensitive": "Sensitive",
"collections.share_short": "Share",