From 58cd1f36443a20b730d03378a7dc4691f7f18011 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 24 Mar 2026 12:08:23 +0100 Subject: [PATCH 01/10] Disable locales with Vite 8 (#38357) --- .storybook/preview.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 1067b0e041..b8b9bd385e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -26,9 +26,10 @@ import { modes } from './modes'; import '../app/javascript/styles/application.scss'; import './styles.css'; -const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { - query: { as: 'json' }, -}); +// Disabling locales in Storybook as it's breaking with Vite 8. +// const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { +// query: { as: 'json' }, +// }); // Initialize MSW initialize({ @@ -39,17 +40,17 @@ const preview: Preview = { // Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], globalTypes: { - locale: { - description: 'Locale for the story', - toolbar: { - title: 'Locale', - icon: 'globe', - items: Object.keys(localeFiles).map((path) => - path.replace('/mastodon/locales/', '').replace('.json', ''), - ), - dynamicTitle: true, - }, - }, + // locale: { + // description: 'Locale for the story', + // toolbar: { + // title: 'Locale', + // icon: 'globe', + // items: Object.keys(localeFiles).map((path) => + // path.replace('/mastodon/locales/', '').replace('.json', ''), + // ), + // dynamicTitle: true, + // }, + // }, theme: { description: 'Theme for the story', toolbar: { From 5ba5a2e55250ad52556caa627a0e486338f9d5ea Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 24 Mar 2026 13:58:19 +0100 Subject: [PATCH 02/10] Profile redesign: Ensure boost and languages menu items are only for following (#38365) --- .../mastodon/features/account_timeline/components/menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/account_timeline/components/menu.tsx b/app/javascript/mastodon/features/account_timeline/components/menu.tsx index 1fcd2f691e..cd1ec82e4f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/menu.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/menu.tsx @@ -620,7 +620,7 @@ function redesignMenuItems({ ); // Timeline options - if (relationship && !relationship.muting) { + if (relationship?.following && !relationship.muting) { items.push( { text: intl.formatMessage( From 000199f003eecafbf2e6a6166d5e179fea4e5948 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 24 Mar 2026 14:03:44 +0100 Subject: [PATCH 03/10] Profile redesign: Simplify header for follower/following lists (#38366) --- .../features/followers/components/header.tsx | 36 +++++++++++++++++++ .../features/followers/components/list.tsx | 6 ++-- .../mastodon/features/followers/index.tsx | 17 ++++++++- .../features/followers/styles.module.scss | 11 ++++++ .../mastodon/features/following/index.tsx | 17 ++++++++- app/javascript/mastodon/locales/en.json | 3 ++ 6 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 app/javascript/mastodon/features/followers/components/header.tsx create mode 100644 app/javascript/mastodon/features/followers/styles.module.scss diff --git a/app/javascript/mastodon/features/followers/components/header.tsx b/app/javascript/mastodon/features/followers/components/header.tsx new file mode 100644 index 0000000000..2733e1d0f4 --- /dev/null +++ b/app/javascript/mastodon/features/followers/components/header.tsx @@ -0,0 +1,36 @@ +import type { FC } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import { DisplayNameSimple } from '@/mastodon/components/display_name/simple'; +import { useAccount } from '@/mastodon/hooks/useAccount'; + +import classes from '../styles.module.scss'; + +export const AccountListHeader: FC<{ + accountId: string; + total?: number; + titleText: MessageDescriptor; +}> = ({ accountId, total, titleText }) => { + const intl = useIntl(); + const account = useAccount(accountId); + return ( + <> +

+ {intl.formatMessage(titleText, { + name: , + })} +

+ {!!total && ( +

+ +

+ )} + + ); +}; diff --git a/app/javascript/mastodon/features/followers/components/list.tsx b/app/javascript/mastodon/features/followers/components/list.tsx index 24d442d229..8134a96ece 100644 --- a/app/javascript/mastodon/features/followers/components/list.tsx +++ b/app/javascript/mastodon/features/followers/components/list.tsx @@ -11,8 +11,6 @@ import { useAccount } from '@/mastodon/hooks/useAccount'; import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; import { useLayout } from '@/mastodon/hooks/useLayout'; -import { AccountHeader } from '../../account_timeline/components/account_header'; - import { RemoteHint } from './remote'; export interface AccountList { @@ -25,6 +23,7 @@ interface AccountListProps { accountId?: string | null; append?: ReactNode; emptyMessage: ReactNode; + header?: ReactNode; footer?: ReactNode; list?: AccountList | null; loadMore: () => void; @@ -36,6 +35,7 @@ export const AccountList: FC = ({ accountId, append, emptyMessage, + header, footer, list, loadMore, @@ -90,7 +90,7 @@ export const AccountList: FC = ({ hasMore={!forceEmptyState && list?.hasMore} isLoading={list?.isLoading ?? true} onLoadMore={loadMore} - prepend={} + prepend={header} alwaysPrepend append={append ?? } emptyMessage={emptyMessage} diff --git a/app/javascript/mastodon/features/followers/index.tsx b/app/javascript/mastodon/features/followers/index.tsx index 15dcbb5a69..bba2f4cb08 100644 --- a/app/javascript/mastodon/features/followers/index.tsx +++ b/app/javascript/mastodon/features/followers/index.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import type { FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessage, FormattedMessage } from 'react-intl'; import { useDebouncedCallback } from 'use-debounce'; @@ -14,8 +14,14 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import type { EmptyMessageProps } from './components/empty'; import { BaseEmptyMessage } from './components/empty'; +import { AccountListHeader } from './components/header'; import { AccountList } from './components/list'; +const titleText = defineMessage({ + id: 'followers.title', + defaultMessage: 'Following {name}', +}); + const Followers: FC = () => { const accountId = useAccountId(); const account = useAccount(accountId); @@ -64,6 +70,15 @@ const Followers: FC = () => { return ( + ) + } footer={footer} emptyMessage={} list={followerList} diff --git a/app/javascript/mastodon/features/followers/styles.module.scss b/app/javascript/mastodon/features/followers/styles.module.scss new file mode 100644 index 0000000000..f58b345bec --- /dev/null +++ b/app/javascript/mastodon/features/followers/styles.module.scss @@ -0,0 +1,11 @@ +.title { + font-size: 20px; + font-weight: 600; + margin: 20px 16px 10px; +} + +.subtitle { + font-size: 14px; + color: var(--color-text-secondary); + margin: 10px 16px; +} diff --git a/app/javascript/mastodon/features/following/index.tsx b/app/javascript/mastodon/features/following/index.tsx index 85894c18af..6bc7abda69 100644 --- a/app/javascript/mastodon/features/following/index.tsx +++ b/app/javascript/mastodon/features/following/index.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import type { FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessage, FormattedMessage } from 'react-intl'; import { useDebouncedCallback } from 'use-debounce'; @@ -14,10 +14,16 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import type { EmptyMessageProps } from '../followers/components/empty'; import { BaseEmptyMessage } from '../followers/components/empty'; +import { AccountListHeader } from '../followers/components/header'; import { AccountList } from '../followers/components/list'; import { RemoteHint } from './components/remote'; +const titleText = defineMessage({ + id: 'following.title', + defaultMessage: 'Followed by {name}', +}); + const Followers: FC = () => { const accountId = useAccountId(); const account = useAccount(accountId); @@ -69,6 +75,15 @@ const Followers: FC = () => { accountId={accountId} append={domain && } emptyMessage={} + header={ + accountId && ( + + ) + } footer={footer} list={followingList} loadMore={loadMore} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index c33e48dcdd..948b0f5110 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -234,6 +234,7 @@ "account_edit_tags.search_placeholder": "Enter a hashtag…", "account_edit_tags.suggestions": "Suggestions:", "account_edit_tags.tag_status_count": "{count, plural, one {# post} other {# posts}}", + "account_list.total": "{total, plural, one {# account} other {# accounts}}", "account_note.placeholder": "Click to add note", "admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up", @@ -674,7 +675,9 @@ "follow_suggestions.who_to_follow": "Who to follow", "followed_tags": "Followed hashtags", "followers.hide_other_followers": "This user has chosen to not make their other followers visible", + "followers.title": "Following {name}", "following.hide_other_following": "This user has chosen to not make the rest of who they follow visible", + "following.title": "Followed by {name}", "footer.about": "About", "footer.about_mastodon": "About Mastodon", "footer.about_server": "About {domain}", From aef70991f862ab0dfd173b54bb9de8609e6787f3 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 24 Mar 2026 14:10:27 +0100 Subject: [PATCH 04/10] Profile redesign: Remove hashtags from featured page (#38363) --- .../mastodon/features/account_featured/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 59e632aa10..5cec2250ef 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -111,8 +111,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ); } + const noTags = + featuredTags.isEmpty() || isServerFeatureEnabled('profile_redesign'); + if ( - featuredTags.isEmpty() && + noTags && featuredAccountIds.isEmpty() && listedCollections.length === 0 ) { @@ -158,7 +161,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ )} - {!featuredTags.isEmpty() && ( + {!noTags && ( <>

Date: Tue, 24 Mar 2026 14:28:09 +0100 Subject: [PATCH 05/10] Remove column header button (#38362) Co-authored-by: diondiondion --- .../mastodon/components/column_header.tsx | 43 ++++++++++++------- .../styles/mastodon/components.scss | 1 - 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index 9f2f38e7d0..49c2bb1d4a 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -267,6 +267,15 @@ export const ColumnHeader: React.FC = ({ const hasTitle = (hasIcon || backButton) && title; const columnIndex = useColumnIndexContext(); + const titleContents = ( + <> + {!backButton && hasIcon && ( + + )} + {title} + + ); + const component = (

@@ -274,21 +283,25 @@ export const ColumnHeader: React.FC = ({ <> {backButton} - + {onClick && ( + + )} + {!onClick && ( + + {titleContents} + + )} )} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index d3b2789314..e38193c30b 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4608,7 +4608,6 @@ a.status-card { border: 1px solid var(--color-border-primary); border-radius: 4px 4px 0 0; flex: 0 0 auto; - cursor: pointer; position: relative; z-index: 2; outline: 0; From 2d4b5b6c51d4acf3ae35c3e479ba179a9bb5f27d Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 24 Mar 2026 14:47:07 +0100 Subject: [PATCH 06/10] Profile editing: Visual fixes (#38346) --- .../form_fields/range_input.module.scss | 1 + .../form_fields/range_input_field.tsx | 16 ++- .../account_edit/components/column.tsx | 38 ++++--- .../account_edit/components/edit_button.tsx | 58 +++------- .../account_edit/components/field_actions.tsx | 31 ++++-- .../account_edit/components/item_list.tsx | 21 +++- .../features/account_edit/featured_tags.tsx | 32 ++++-- .../mastodon/features/account_edit/index.tsx | 61 +++++++---- .../account_edit/modals/fields_modals.tsx | 100 ++++++++++++++---- .../account_edit/modals/image_alt.tsx | 81 +++++++------- .../account_edit/modals/image_upload.tsx | 65 +++++++----- .../modals/profile_display_modal.tsx | 38 +++---- .../account_edit/modals/styles.module.scss | 12 +-- .../features/account_edit/styles.module.scss | 13 ++- .../components/number_fields.tsx | 12 +-- .../components/redesign.module.scss | 21 ++-- app/javascript/mastodon/locales/en-GB.json | 2 +- app/javascript/mastodon/locales/en.json | 30 ++++-- 18 files changed, 393 insertions(+), 239 deletions(-) diff --git a/app/javascript/mastodon/components/form_fields/range_input.module.scss b/app/javascript/mastodon/components/form_fields/range_input.module.scss index cbace07dcc..5aa46b52cc 100644 --- a/app/javascript/mastodon/components/form_fields/range_input.module.scss +++ b/app/javascript/mastodon/components/form_fields/range_input.module.scss @@ -13,6 +13,7 @@ margin: 6px 0; background-color: transparent; appearance: none; + display: block; &:focus { outline: none; diff --git a/app/javascript/mastodon/components/form_fields/range_input_field.tsx b/app/javascript/mastodon/components/form_fields/range_input_field.tsx index 8fb2620339..d619d6455a 100644 --- a/app/javascript/mastodon/components/form_fields/range_input_field.tsx +++ b/app/javascript/mastodon/components/form_fields/range_input_field.tsx @@ -14,7 +14,9 @@ export type RangeInputProps = Omit< markers?: { value: number; label: string }[] | number[]; }; -interface Props extends RangeInputProps, CommonFieldWrapperProps {} +interface Props extends RangeInputProps, CommonFieldWrapperProps { + inputPlacement?: 'inline-start' | 'inline-end'; // TODO: Move this to the common field wrapper props for other fields. +} /** * A simple form field for single-line text. @@ -25,7 +27,16 @@ interface Props extends RangeInputProps, CommonFieldWrapperProps {} export const RangeInputField = forwardRef( ( - { id, label, hint, status, required, wrapperClassName, ...otherProps }, + { + id, + label, + hint, + status, + required, + wrapperClassName, + inputPlacement, + ...otherProps + }, ref, ) => ( ( required={required} status={status} inputId={id} + inputPlacement={inputPlacement} className={wrapperClassName} > {(inputProps) => } diff --git a/app/javascript/mastodon/features/account_edit/components/column.tsx b/app/javascript/mastodon/features/account_edit/components/column.tsx index 5f0ad929a1..9fb83e444c 100644 --- a/app/javascript/mastodon/features/account_edit/components/column.tsx +++ b/app/javascript/mastodon/features/account_edit/components/column.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import { Column } from '@/mastodon/components/column'; @@ -36,22 +37,27 @@ export const AccountEditColumn: FC<{ const { multiColumn } = useColumnsContext(); return ( - - - - - } - /> + <> + + + + + } + /> - {children} - + {children} + + + {title} + + ); }; diff --git a/app/javascript/mastodon/features/account_edit/components/edit_button.tsx b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx index f2fecf21d0..eaf6e291cd 100644 --- a/app/javascript/mastodon/features/account_edit/components/edit_button.tsx +++ b/app/javascript/mastodon/features/account_edit/components/edit_button.tsx @@ -1,8 +1,5 @@ import type { FC, MouseEventHandler } from 'react'; -import type { MessageDescriptor } from 'react-intl'; -import { defineMessages, useIntl } from 'react-intl'; - import classNames from 'classnames'; import { Button } from '@/mastodon/components/button'; @@ -12,43 +9,19 @@ import EditIcon from '@/material-icons/400-24px/edit.svg?react'; import classes from '../styles.module.scss'; -const messages = defineMessages({ - add: { - id: 'account_edit.button.add', - defaultMessage: 'Add {item}', - }, - edit: { - id: 'account_edit.button.edit', - defaultMessage: 'Edit {item}', - }, - delete: { - id: 'account_edit.button.delete', - defaultMessage: 'Delete {item}', - }, -}); - export interface EditButtonProps { onClick: MouseEventHandler; - item: string | MessageDescriptor; - edit?: boolean; + label: string; icon?: boolean; disabled?: boolean; } export const EditButton: FC = ({ onClick, - item, - edit = false, - icon = edit, + label, + icon = false, disabled, }) => { - const intl = useIntl(); - - const itemText = typeof item === 'string' ? item : intl.formatMessage(item); - const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], { - item: itemText, - }); - if (icon) { return ( @@ -83,18 +56,15 @@ export const EditIconButton: FC<{ export const DeleteIconButton: FC<{ onClick: MouseEventHandler; - item: string; + label: string; disabled?: boolean; -}> = ({ onClick, item, disabled }) => { - const intl = useIntl(); - return ( - - ); -}; +}> = ({ onClick, label, disabled }) => ( + +); diff --git a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx index aed2fc3e0b..fecdcb8eff 100644 --- a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx +++ b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx @@ -1,15 +1,25 @@ import type { FC } from 'react'; import { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + import { openModal } from '@/mastodon/actions/modal'; import { useAppDispatch } from '@/mastodon/store'; import { EditButton, DeleteIconButton } from './edit_button'; -export const AccountFieldActions: FC<{ item: string; id: string }> = ({ - item, - id, -}) => { +const messages = defineMessages({ + edit: { + id: 'account_edit.field_actions.edit', + defaultMessage: 'Edit field', + }, + delete: { + id: 'account_edit.field_actions.delete', + defaultMessage: 'Delete field', + }, +}); + +export const AccountFieldActions: FC<{ id: string }> = ({ id }) => { const dispatch = useAppDispatch(); const handleEdit = useCallback(() => { dispatch( @@ -28,10 +38,19 @@ export const AccountFieldActions: FC<{ item: string; id: string }> = ({ ); }, [dispatch, id]); + const intl = useIntl(); + return ( <> - - + + ); }; diff --git a/app/javascript/mastodon/features/account_edit/components/item_list.tsx b/app/javascript/mastodon/features/account_edit/components/item_list.tsx index eb6cf590f5..2b5dc9ab9f 100644 --- a/app/javascript/mastodon/features/account_edit/components/item_list.tsx +++ b/app/javascript/mastodon/features/account_edit/components/item_list.tsx @@ -1,5 +1,7 @@ import { useCallback } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + import classes from '../styles.module.scss'; import { DeleteIconButton, EditButton } from './edit_button'; @@ -50,6 +52,17 @@ type AccountEditItemButtonsProps = Pick< 'onEdit' | 'onDelete' | 'disabled' > & { item: Item }; +const messages = defineMessages({ + edit: { + id: 'account_edit.item_list.edit', + defaultMessage: 'Edit {name}', + }, + delete: { + id: 'account_edit.item_list.delete', + defaultMessage: 'Delete {name}', + }, +}); + const AccountEditItemButtons = ({ item, onDelete, @@ -63,6 +76,8 @@ const AccountEditItemButtons = ({ onDelete?.(item); }, [item, onDelete]); + const intl = useIntl(); + if (!onEdit && !onDelete) { return null; } @@ -71,15 +86,15 @@ const AccountEditItemButtons = ({
{onEdit && ( )} {onDelete && ( diff --git a/app/javascript/mastodon/features/account_edit/featured_tags.tsx b/app/javascript/mastodon/features/account_edit/featured_tags.tsx index 929fd554d5..5fc602208d 100644 --- a/app/javascript/mastodon/features/account_edit/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_edit/featured_tags.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Callout } from '@/mastodon/components/callout'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; import { Tag } from '@/mastodon/components/tags/tag'; import { useAccount } from '@/mastodon/hooks/useAccount'; @@ -28,17 +29,25 @@ import classes from './styles.module.scss'; const messages = defineMessages({ columnTitle: { id: 'account_edit_tags.column_title', - defaultMessage: 'Edit featured hashtags', + defaultMessage: 'Edit Tags', }, }); const selectTags = createAppSelector( - [(state) => state.profileEdit], - (profileEdit) => ({ + [ + (state) => state.profileEdit, + (state) => + state.server.getIn( + ['server', 'accounts', 'max_featured_tags'], + 10, + ) as number, + ], + (profileEdit, maxTags) => ({ tags: profileEdit.profile?.featuredTags ?? [], tagSuggestions: profileEdit.tagSuggestions ?? [], isLoading: !profileEdit.profile || !profileEdit.tagSuggestions, isPending: profileEdit.isPending, + maxTags, }), ); @@ -47,7 +56,7 @@ export const AccountEditFeaturedTags: FC = () => { const account = useAccount(accountId); const intl = useIntl(); - const { tags, tagSuggestions, isLoading, isPending } = + const { tags, tagSuggestions, isLoading, isPending, maxTags } = useAppSelector(selectTags); const dispatch = useAppDispatch(); @@ -67,6 +76,8 @@ export const AccountEditFeaturedTags: FC = () => { return ; } + const canAddMoreTags = tags.length < maxTags; + return ( { tagName='p' /> - + {canAddMoreTags && } - {tagSuggestions.length > 0 && ( + {tagSuggestions.length > 0 && canAddMoreTags && (
{
)} + {!canAddMoreTags && ( + + + + )} + {isLoading && } { buttons={ } > @@ -197,8 +221,10 @@ export const AccountEdit: FC = () => { buttons={ } > @@ -214,7 +240,7 @@ export const AccountEdit: FC = () => { description={messages.customFieldsPlaceholder} showDescription={!hasFields} buttons={ - <> +
= maxFieldCount} /> - +
} > {hasFields && ( @@ -240,10 +266,7 @@ export const AccountEdit: FC = () => {
- + ))} @@ -278,8 +301,8 @@ export const AccountEdit: FC = () => { buttons={ } > diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx index b5a095cf68..41991da7d0 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx @@ -1,10 +1,17 @@ -import { useCallback, useMemo, useState } from 'react'; +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useState, +} from 'react'; import type { FC } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import type { Map as ImmutableMap } from 'immutable'; +import { closeModal } from '@/mastodon/actions/modal'; import { Button } from '@/mastodon/components/button'; import { Callout } from '@/mastodon/components/callout'; import { EmojiTextInputField } from '@/mastodon/components/form_fields'; @@ -51,14 +58,19 @@ const messages = defineMessages({ id: 'account_edit.field_edit_modal.value_hint', defaultMessage: 'E.g. “https://example.me”', }, - limitHeader: { - id: 'account_edit.field_edit_modal.limit_header', - defaultMessage: 'Recommended character limit exceeded', - }, save: { id: 'account_edit.save', defaultMessage: 'Save', }, + discardMessage: { + id: 'account_edit.field_edit_modal.discard_message', + defaultMessage: + 'You have unsaved changes. Are you sure you want to discard them?', + }, + discardConfirm: { + id: 'account_edit.field_edit_modal.discard_confirm', + defaultMessage: 'Discard', + }, }); // We have two different values- the hard limit set by the server, @@ -83,19 +95,39 @@ const selectEmojiCodes = createAppSelector( (emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(), ); -export const EditFieldModal: FC = ({ - onClose, - fieldKey, -}) => { +interface ConfirmationMessage { + message: string; + confirm: string; + props: { fieldKey?: string; lastLabel: string; lastValue: string }; +} + +interface ModalRef { + getCloseConfirmationMessage: () => null | ConfirmationMessage; +} + +export const EditFieldModal = forwardRef< + ModalRef, + DialogModalProps & { + fieldKey?: string; + lastLabel?: string; + lastValue?: string; + } +>(({ onClose, fieldKey, lastLabel, lastValue }, ref) => { const intl = useIntl(); const field = useAppSelector((state) => selectFieldById(state, fieldKey)); - const [newLabel, setNewLabel] = useState(field?.name ?? ''); - const [newValue, setNewValue] = useState(field?.value ?? ''); + const oldLabel = lastLabel ?? field?.name; + const oldValue = lastValue ?? field?.value; + const [newLabel, setNewLabel] = useState(oldLabel ?? ''); + const [newValue, setNewValue] = useState(oldValue ?? ''); + const isDirty = newLabel !== oldLabel || newValue !== oldValue; const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits); const isPending = useAppSelector((state) => state.profileEdit.isPending); const disabled = + !newLabel.trim() || + !newValue.trim() || + !isDirty || !nameLimit || !valueLimit || newLabel.length > nameLimit || @@ -122,11 +154,41 @@ export const EditFieldModal: FC = ({ } void dispatch( updateField({ id: fieldKey, name: newLabel, value: newValue }), - ).then(onClose); - }, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]); + ).then(() => { + // Close without confirmation. + dispatch( + closeModal({ + modalType: 'ACCOUNT_EDIT_FIELD_EDIT', + ignoreFocus: false, + }), + ); + }); + }, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]); + + useImperativeHandle( + ref, + () => ({ + getCloseConfirmationMessage: () => { + if (!newLabel || !newValue || !isDirty) { + return null; + } + return { + message: intl.formatMessage(messages.discardMessage), + confirm: intl.formatMessage(messages.discardConfirm), + props: { + fieldKey, + lastLabel: newLabel, + lastValue: newValue, + }, + }; + }, + }), + [fieldKey, intl, isDirty, newLabel, newValue], + ); return ( = ({ {(newLabel.length > RECOMMENDED_LIMIT || newValue.length > RECOMMENDED_LIMIT) && ( - + )} @@ -195,7 +254,8 @@ export const EditFieldModal: FC = ({ )} ); -}; +}); +EditFieldModal.displayName = 'EditFieldModal'; export const DeleteFieldModal: FC = ({ onClose, diff --git a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx index fb9392185d..973c74ff83 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_alt.tsx @@ -3,7 +3,6 @@ import { useCallback, useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { CharacterCounter } from '@/mastodon/components/character_counter'; import { Details } from '@/mastodon/components/details'; import { TextAreaField } from '@/mastodon/components/form_fields'; import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; @@ -69,6 +68,7 @@ export const ImageAltModal: FC< imageSrc={imageSrc} altText={altText} onChange={setAltText} + hideTip={location === 'header'} />
@@ -79,7 +79,8 @@ export const ImageAltTextField: FC<{ imageSrc: string; altText: string; onChange: (altText: string) => void; -}> = ({ imageSrc, altText, onChange }) => { + hideTip?: boolean; +}> = ({ imageSrc, altText, onChange, hideTip }) => { const altLimit = useAppSelector( (state) => state.server.getIn( @@ -99,49 +100,45 @@ export const ImageAltTextField: FC<{ <> -
- - } - hint={ - - } - onChange={handleChange} - value={altText} - /> - -
- -
} - className={classes.altHint} - > -
    {chunks}
, - li: (chunks) =>
  • {chunks}
  • , - }} - tagName='div' - /> -
    + hint={ + + } + onChange={handleChange} + value={altText} + maxLength={altLimit} + /> + + {!hideTip && ( +
    + } + className={classes.altHint} + > +
      {chunks}
    , + li: (chunks) =>
  • {chunks}
  • , + }} + tagName='div' + /> +
    + )} ); }; diff --git a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx index ccf65cceed..23636083de 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx @@ -1,14 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEventHandler, FC } from 'react'; -import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import type { Area } from 'react-easy-crop'; import Cropper from 'react-easy-crop'; import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed'; import { Button } from '@/mastodon/components/button'; -import { RangeInput } from '@/mastodon/components/form_fields/range_input_field'; +import { RangeInputField } from '@/mastodon/components/form_fields/range_input_field'; import { selectImageInfo, uploadImage, @@ -24,16 +24,42 @@ import classes from './styles.module.scss'; import 'react-easy-crop/react-easy-crop.css'; +const messages = defineMessages({ + avatarAdd: { + id: 'account_edit.upload_modal.title_add.avatar', + defaultMessage: 'Add profile photo', + }, + headerAdd: { + id: 'account_edit.upload_modal.title_add.header', + defaultMessage: 'Add cover photo', + }, + avatarReplace: { + id: 'account_edit.upload_modal.title_replace.avatar', + defaultMessage: 'Replace profile photo', + }, + headerReplace: { + id: 'account_edit.upload_modal.title_replace.header', + defaultMessage: 'Replace cover photo', + }, + zoomLabel: { + id: 'account_edit.upload_modal.step_crop.zoom', + defaultMessage: 'Zoom', + }, +}); + export const ImageUploadModal: FC< DialogModalProps & { location: ImageLocation } > = ({ onClose, location }) => { const { src: oldSrc } = useAppSelector((state) => selectImageInfo(state, location), ); - const hasImage = !!oldSrc; - const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); + const intl = useIntl(); + const title = intl.formatMessage( + oldSrc ? messages[`${location}Replace`] : messages[`${location}Add`], + ); // State for individual steps. + const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); const [imageSrc, setImageSrc] = useState(null); const [imageBlob, setImageBlob] = useState(null); @@ -94,19 +120,7 @@ export const ImageUploadModal: FC< return ( - ) : ( - - ) - } + title={title} onClose={onClose} wrapperClassName={classes.uploadWrapper} noCancelButton @@ -124,6 +138,7 @@ export const ImageUploadModal: FC< )} {step === 'alt' && imageBlob && (
    -