diff --git a/app/javascript/mastodon/components/empty_state/empty_state.module.scss b/app/javascript/mastodon/components/empty_state/empty_state.module.scss index e9602f2e41..b58c565ac5 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.module.scss +++ b/app/javascript/mastodon/components/empty_state/empty_state.module.scss @@ -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; +} diff --git a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx index 8515a6ea1a..83fce03468 100644 --- a/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx +++ b/app/javascript/mastodon/components/empty_state/empty_state.stories.tsx @@ -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: , }, }; + +export const WithoutImage: Story = { + args: { + ...Default.args, + image: null, + }, +}; + +export const WithoutMessage: Story = { + args: { + ...Default.args, + message: undefined, + }, +}; diff --git a/app/javascript/mastodon/components/empty_state/index.tsx b/app/javascript/mastodon/components/empty_state/index.tsx index 59210bbc87..0ef0d67995 100644 --- a/app/javascript/mastodon/components/empty_state/index.tsx +++ b/app/javascript/mastodon/components/empty_state/index.tsx @@ -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: , +}; + /** * 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 = ( ), message, children, }) => { + const imageToRender = typeof image === 'string' ? images[image] : image; + return (
- {(title || message || image) && ( + {(title || message || imageToRender) && (
- {image} + {imageToRender} {!!title &&

{title}

} {!!message &&

{message}

}
diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss b/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss deleted file mode 100644 index 25dcf19433..0000000000 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.module.scss +++ /dev/null @@ -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; -} diff --git a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx index 35b67cd641..e1de024a69 100644 --- a/app/javascript/mastodon/features/account_featured/components/empty_message.tsx +++ b/app/javascript/mastodon/features/account_featured/components/empty_message.tsx @@ -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 = ({ const hasCollections = areCollectionsEnabled(); - const image = ; - if (me === accountId) { if (hasCollections) { // Return only here to insert the "Create a collection" button as the action for the empty state. return ( = ({ } } - return ; + return ; }; diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 654f6265e6..0abca399d5 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -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)) && ( - - } - icon={} - className={classes.accountBadge} - /> - ), - [lastStatusAt], - ); - return ( - - + + ); }; interface SuggestionItem { id: string; - isSelected: boolean; } -const SuggestedAccountItem: React.FC = ({ id, isSelected }) => { +const SuggestedAccountItem: React.FC = ({ id }) => { const account = useAccount(id); if (!account) return null; @@ -114,23 +68,15 @@ const SuggestedAccountItem: React.FC = ({ id, isSelected }) => { <> - {isSelected && ( - - )} ); }; const renderAccountItem = (item: SuggestionItem) => ( - + ); 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 (
- {!id && ( - - } - description={ - - } - /> - )} - - - {hasMaxAccounts && ( - - )} - - - - } - message={ - - } - /> - } - > - {accountIds.map((accountId, index) => ( -
- -
- ))} -
-
-
- {!isEditMode && ( -
-
- - {(text) =>
{text}
} -
- -
+ } + /> + )} + + + +
+ {hasAccounts && ( + + + + )} + + + + } + message={ + + } + /> + } + > + {accountIds.map((accountId, index) => ( +
+ +
+ ))} +
+
+
+ + {!isEditMode && hasAccounts && ( +
+
)} diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 04a6ad1428..f4e561e6e3 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -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 = () => {
{!id && ( - = ({ 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, diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss index 1991aa4211..577a6d7d12 100644 --- a/app/javascript/mastodon/features/collections/editor/styles.module.scss +++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss @@ -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; } diff --git a/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx b/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx deleted file mode 100644 index 3bc9a4d7ef..0000000000 --- a/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx +++ /dev/null @@ -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 ( -
- - {(content) =>

{content}

} -
-

{title}

- {!!description &&

{description}

} -
- ); -}; diff --git a/app/javascript/mastodon/features/collections/editor/wizard_step_title.tsx b/app/javascript/mastodon/features/collections/editor/wizard_step_title.tsx new file mode 100644 index 0000000000..4d328521e7 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor/wizard_step_title.tsx @@ -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 ( +
+

+ +

+

{title}

+
+ ); +}; diff --git a/app/javascript/mastodon/hooks/useSearchAccounts.ts b/app/javascript/mastodon/hooks/useSearchAccounts.ts index 1fc71f54ef..358632296f 100644 --- a/app/javascript/mastodon/hooks/useSearchAccounts.ts +++ b/app/javascript/mastodon/hooks/useSearchAccounts.ts @@ -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', diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3e2ce2c696..93f395d560 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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": "You’ve 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",