Profile editing: Finish image editing (#38235)

This commit is contained in:
Echo
2026-03-16 16:56:30 +01:00
committed by GitHub
parent 703f2d0263
commit 4328807f28
16 changed files with 373 additions and 104 deletions

View File

@@ -69,3 +69,9 @@ export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) => export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) =>
apiRequestPatch<ApiProfileJSON>('v1/profile', params); apiRequestPatch<ApiProfileJSON>('v1/profile', params);
export const apiDeleteProfileAvatar = () =>
apiRequestDelete('v1/profile/avatar');
export const apiDeleteProfileHeader = () =>
apiRequestDelete('v1/profile/header');

View File

@@ -27,6 +27,8 @@ export interface ApiProfileJSON {
export type ApiProfileUpdateParams = Partial< export type ApiProfileUpdateParams = Partial<
Pick< Pick<
ApiProfileJSON, ApiProfileJSON,
| 'avatar_description'
| 'header_description'
| 'display_name' | 'display_name'
| 'note' | 'note'
| 'locked' | 'locked'

View File

@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Details } from './index';
const meta = {
component: Details,
title: 'Components/Details',
args: {
summary: 'Here is the summary title',
children: (
<p>
And here are the details that are hidden until you click the summary.
</p>
),
},
render(props) {
return (
<div style={{ width: '400px' }}>
<Details {...props} />
</div>
);
},
} satisfies Meta<typeof Details>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};

View File

@@ -0,0 +1,35 @@
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import classNames from 'classnames';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import { Icon } from '../icon';
import classes from './styles.module.scss';
export const Details = forwardRef<
HTMLDetailsElement,
{
summary: ReactNode;
children: ReactNode;
className?: string;
} & ComponentPropsWithoutRef<'details'>
>(({ summary, children, className, ...rest }, ref) => {
return (
<details
ref={ref}
className={classNames(classes.details, className)}
{...rest}
>
<summary>
{summary}
<Icon icon={ExpandArrowIcon} id='arrow' />
</summary>
{children}
</details>
);
});
Details.displayName = 'Details';

View File

@@ -0,0 +1,25 @@
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom';
import type { ModalType } from '@/mastodon/actions/modal'; import type { ModalType } from '@/mastodon/actions/modal';
import { openModal } from '@/mastodon/actions/modal'; import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar'; import { Avatar } from '@/mastodon/components/avatar';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible'; import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
@@ -201,7 +202,11 @@ export const AccountEdit: FC = () => {
/> />
} }
> >
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} /> <AccountBio
showDropdown
accountId={profile.id}
className={classes.bio}
/>
</AccountEditSection> </AccountEditSection>
<AccountEditSection <AccountEditSection

View File

@@ -1,12 +1,147 @@
import type { FC } from 'react'; import type { ChangeEventHandler, FC } from 'react';
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';
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit'; import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { ConfirmationModal } from '../../ui/components/confirmation_modals';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import classes from './styles.module.scss';
export const ImageAltModal: FC< export const ImageAltModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const { profile, isPending } = useAppSelector((state) => state.profileEdit);
const initialAlt = profile?.[`${location}Description`];
const imageSrc = profile?.[`${location}Static`];
const [altText, setAltText] = useState(initialAlt ?? '');
const dispatch = useAppDispatch();
const handleSave = useCallback(() => {
void dispatch(
patchProfile({
[`${location}_description`]: altText,
}),
).then(onClose);
}, [altText, dispatch, location, onClose]);
if (!imageSrc) {
return <LoadingIndicator />;
}
return (
<ConfirmationModal
title={
initialAlt ? (
<FormattedMessage
id='account_edit.image_alt_modal.edit_title'
defaultMessage='Edit alt text'
/>
) : (
<FormattedMessage
id='account_edit.image_alt_modal.add_title'
defaultMessage='Add alt text'
/>
)
}
onClose={onClose}
onConfirm={handleSave}
confirm={
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
}
updating={isPending}
>
<div className={classes.wrapper}>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
</div>
</ConfirmationModal>
);
};
export const ImageAltTextField: FC<{
imageSrc: string;
altText: string;
onChange: (altText: string) => void;
}> = ({ imageSrc, altText, onChange }) => {
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
['server', 'configuration', 'media_attachments', 'description_limit'],
150,
) as number,
);
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
onChange(event.currentTarget.value);
},
[onChange],
);
return (
<>
<img src={imageSrc} alt='' className={classes.altImage} />
<div>
<TextAreaField
label={
<FormattedMessage
id='account_edit.image_alt_modal.text_label'
defaultMessage='Alt text'
/>
}
hint={
<FormattedMessage
id='account_edit.image_alt_modal.text_hint'
defaultMessage='Alt text helps screen reader users to understand your content.'
/>
}
onChange={handleChange}
value={altText}
/>
<CharacterCounter
currentString={altText}
maxLength={altLimit}
className={classes.altCounter}
/>
</div>
<Details
summary={
<FormattedMessage
id='account_edit.image_alt_modal.details_title'
defaultMessage='Tips: Alt text for profile photos'
/>
}
className={classes.altHint}
>
<FormattedMessage
id='account_edit.image_alt_modal.details_content'
defaultMessage='DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct a few words is often enough</li> </ul> DONT: <ul> <li>Start with “Photo of” its redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>'
values={{
ul: (chunks) => <ul>{chunks}</ul>,
li: (chunks) => <li>{chunks}</li>,
}}
tagName='div'
/>
</Details>
</>
);
}; };

View File

@@ -1,12 +1,48 @@
import { useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { Button } from '@/mastodon/components/button';
import { deleteImage } from '@/mastodon/reducers/slices/profile_edit';
import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit'; import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
export const ImageDeleteModal: FC< export const ImageDeleteModal: FC<
DialogModalProps & { location: ImageLocation } DialogModalProps & { location: ImageLocation }
> = ({ onClose }) => { > = ({ onClose, location }) => {
return <DialogModal title='TODO' onClose={onClose} />; const isPending = useAppSelector((state) => state.profileEdit.isPending);
const dispatch = useAppDispatch();
const handleDelete = useCallback(() => {
void dispatch(deleteImage({ location })).then(onClose);
}, [dispatch, location, onClose]);
return (
<DialogModal
onClose={onClose}
title={
<FormattedMessage
id='account_edit.image_delete_modal.title'
defaultMessage='Delete image?'
/>
}
buttons={
<Button dangerous onClick={handleDelete} disabled={isPending}>
<FormattedMessage
id='account_edit.image_delete_modal.delete_button'
defaultMessage='Delete'
/>
</Button>
}
>
<FormattedMessage
id='account_edit.image_delete_modal.confirm'
defaultMessage='Are you sure you want to delete this image? This action cant be undone.'
tagName='p'
/>
</DialogModal>
);
}; };

View File

@@ -8,9 +8,6 @@ import Cropper from 'react-easy-crop';
import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed'; import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed';
import { Button } from '@/mastodon/components/button'; import { Button } from '@/mastodon/components/button';
import { Callout } from '@/mastodon/components/callout';
import { CharacterCounter } from '@/mastodon/components/character_counter';
import { TextAreaField } from '@/mastodon/components/form_fields';
import { RangeInput } from '@/mastodon/components/form_fields/range_input_field'; import { RangeInput } from '@/mastodon/components/form_fields/range_input_field';
import { import {
selectImageInfo, selectImageInfo,
@@ -22,6 +19,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { ImageAltTextField } from './image_alt';
import classes from './styles.module.scss'; import classes from './styles.module.scss';
import 'react-easy-crop/react-easy-crop.css'; import 'react-easy-crop/react-easy-crop.css';
@@ -357,66 +355,19 @@ const StepAlt: FC<{
}> = ({ imageBlob, onCancel, onComplete }) => { }> = ({ imageBlob, onCancel, onComplete }) => {
const [altText, setAltText] = useState(''); const [altText, setAltText] = useState('');
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
setAltText(event.currentTarget.value);
},
[],
);
const handleComplete = useCallback(() => { const handleComplete = useCallback(() => {
onComplete(altText); onComplete(altText);
}, [altText, onComplete]); }, [altText, onComplete]);
const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]); const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]);
const altLimit = useAppSelector(
(state) =>
state.server.getIn(
['server', 'configuration', 'media_attachments', 'description_limit'],
150,
) as number,
);
return ( return (
<> <>
<img src={imageSrc} alt='' className={classes.altImage} /> <ImageAltTextField
imageSrc={imageSrc}
<div> altText={altText}
<TextAreaField onChange={setAltText}
label={ />
<FormattedMessage
id='account_edit.upload_modal.step_alt.text_label'
defaultMessage='Alt text'
/>
}
hint={
<FormattedMessage
id='account_edit.upload_modal.step_alt.text_hint'
defaultMessage='E.g. “Close-up photo of me wearing glasses and a blue shirt”'
/>
}
onChange={handleChange}
/>
<CharacterCounter
currentString={altText}
maxLength={altLimit}
className={classes.altCounter}
/>
</div>
<Callout
title={
<FormattedMessage
id='account_edit.upload_modal.step_alt.callout_title'
defaultMessage='Lets make Mastodon accessible for all'
/>
}
>
<FormattedMessage
id='account_edit.upload_modal.step_alt.callout_text'
defaultMessage='Adding alt text to media helps people using screen readers to understand your content.'
/>
</Callout>
<div className={classes.cropActions}> <div className={classes.cropActions}>
<Button onClick={onCancel} secondary> <Button onClick={onCancel} secondary>

View File

@@ -1,7 +1,7 @@
export * from './bio_modal'; export * from './bio_modal';
export * from './fields_modals'; export * from './fields_modals';
export * from './fields_reorder_modal'; export * from './fields_reorder_modal';
export * from './image_alt'; export { ImageAltModal } from './image_alt';
export * from './image_delete'; export * from './image_delete';
export * from './image_upload'; export * from './image_upload';
export * from './name_modal'; export * from './name_modal';

View File

@@ -121,14 +121,25 @@
} }
.altImage { .altImage {
max-height: 300px; max-height: 150px;
object-fit: contain; object-fit: contain;
align-self: flex-start;
border: 1px solid var(--color-border-primary);
border-radius: var(--avatar-border-radius);
} }
.altCounter { .altCounter {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.altHint {
ul {
padding-left: 1em;
list-style: disc;
margin-bottom: 1em;
}
}
.verifiedSteps { .verifiedSteps {
font-size: 15px; font-size: 15px;
@@ -157,29 +168,3 @@
} }
} }
} }
.details {
color: var(--color-text-secondary);
font-size: 13px;
margin-top: 8px;
summary {
cursor: pointer;
font-weight: 600;
list-style: none;
margin-bottom: 8px;
text-decoration: underline;
text-decoration-style: dotted;
}
:global(.icon) {
width: 1.4em;
height: 1.4em;
vertical-align: middle;
transition: transform 0.2s ease-in-out;
}
&[open] :global(.icon) {
transform: rotate(-180deg);
}
}

View File

@@ -2,10 +2,9 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Details } from '@/mastodon/components/details';
import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field'; import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field';
import { Icon } from '@/mastodon/components/icon';
import { createAppSelector, useAppSelector } from '@/mastodon/store'; import { createAppSelector, useAppSelector } from '@/mastodon/store';
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
import type { DialogModalProps } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal';
import { DialogModal } from '../../ui/components/dialog_modal'; import { DialogModal } from '../../ui/components/dialog_modal';
@@ -53,20 +52,20 @@ export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
} }
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`} value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
/> />
<details className={classes.details}> <Details
<summary> summary={
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.summary' id='account_edit.verified_modal.invisible_link.summary'
defaultMessage='How do I make the link invisible?' defaultMessage='How do I make the link invisible?'
/> />
<Icon icon={ExpandArrowIcon} id='arrow' /> }
</summary> >
<FormattedMessage <FormattedMessage
id='account_edit.verified_modal.invisible_link.details' id='account_edit.verified_modal.invisible_link.details'
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.' defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
values={{ tag: <code>&lt;a&gt;</code> }} values={{ tag: <code>&lt;a&gt;</code> }}
/> />
</details> </Details>
</li> </li>
<li> <li>
<FormattedMessage <FormattedMessage

View File

@@ -40,6 +40,19 @@
} }
} }
.bio {
unicode-bidi: plaintext;
p:not(:last-child) {
margin-bottom: 20px;
}
a {
color: inherit;
text-decoration: underline;
}
}
.field { .field {
padding: 12px 0; padding: 12px 0;
display: flex; display: flex;

View File

@@ -211,6 +211,7 @@ export const AccountHeader: React.FC<{
))} ))}
<AccountBio <AccountBio
showDropdown
accountId={accountId} accountId={accountId}
className={classNames( className={classNames(
'account__header__content', 'account__header__content',

View File

@@ -184,6 +184,15 @@
"account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".", "account_edit.field_reorder_modal.drag_start": "Picked up field \"{item}\".",
"account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"", "account_edit.field_reorder_modal.handle_label": "Drag field \"{item}\"",
"account_edit.field_reorder_modal.title": "Rearrange fields", "account_edit.field_reorder_modal.title": "Rearrange fields",
"account_edit.image_alt_modal.add_title": "Add alt text",
"account_edit.image_alt_modal.details_content": "DO: <ul> <li>Describe yourself as pictured</li> <li>Use third person language (e.g. “Alex” instead of “me”)</li> <li>Be succinct a few words is often enough</li> </ul> DONT: <ul> <li>Start with “Photo of” its redundant for screen readers</li> </ul> EXAMPLE: <ul> <li>“Alex wearing a green shirt and glasses”</li> </ul>",
"account_edit.image_alt_modal.details_title": "Tips: Alt text for profile photos",
"account_edit.image_alt_modal.edit_title": "Edit alt text",
"account_edit.image_alt_modal.text_hint": "Alt text helps screen reader users to understand your content.",
"account_edit.image_alt_modal.text_label": "Alt text",
"account_edit.image_delete_modal.confirm": "Are you sure you want to delete this image? This action cant be undone.",
"account_edit.image_delete_modal.delete_button": "Delete",
"account_edit.image_delete_modal.title": "Delete image?",
"account_edit.image_edit.add_button": "Add image", "account_edit.image_edit.add_button": "Add image",
"account_edit.image_edit.alt_add_button": "Add alt text", "account_edit.image_edit.alt_add_button": "Add alt text",
"account_edit.image_edit.alt_edit_button": "Edit alt text", "account_edit.image_edit.alt_edit_button": "Edit alt text",
@@ -206,10 +215,6 @@
"account_edit.upload_modal.back": "Back", "account_edit.upload_modal.back": "Back",
"account_edit.upload_modal.done": "Done", "account_edit.upload_modal.done": "Done",
"account_edit.upload_modal.next": "Next", "account_edit.upload_modal.next": "Next",
"account_edit.upload_modal.step_alt.callout_text": "Adding alt text to media helps people using screen readers to understand your content.",
"account_edit.upload_modal.step_alt.callout_title": "Lets make Mastodon accessible for all",
"account_edit.upload_modal.step_alt.text_hint": "E.g. “Close-up photo of me wearing glasses and a blue shirt”",
"account_edit.upload_modal.step_alt.text_label": "Alt text",
"account_edit.upload_modal.step_crop.zoom": "Zoom", "account_edit.upload_modal.step_crop.zoom": "Zoom",
"account_edit.upload_modal.step_upload.button": "Browse files", "account_edit.upload_modal.step_upload.button": "Browse files",
"account_edit.upload_modal.step_upload.dragging": "Drop to upload", "account_edit.upload_modal.step_upload.dragging": "Drop to upload",

View File

@@ -3,8 +3,11 @@ import { createSlice } from '@reduxjs/toolkit';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { fetchAccount } from '@/mastodon/actions/accounts';
import { import {
apiDeleteFeaturedTag, apiDeleteFeaturedTag,
apiDeleteProfileAvatar,
apiDeleteProfileHeader,
apiGetCurrentFeaturedTags, apiGetCurrentFeaturedTags,
apiGetProfile, apiGetProfile,
apiGetTagSuggestions, apiGetTagSuggestions,
@@ -120,6 +123,16 @@ const profileEditSlice = createSlice({
state.isPending = false; state.isPending = false;
}); });
builder.addCase(deleteImage.pending, (state) => {
state.isPending = true;
});
builder.addCase(deleteImage.rejected, (state) => {
state.isPending = false;
});
builder.addCase(deleteImage.fulfilled, (state) => {
state.isPending = false;
});
builder.addCase(addFeaturedTag.pending, (state) => { builder.addCase(addFeaturedTag.pending, (state) => {
state.isPending = true; state.isPending = true;
}); });
@@ -231,7 +244,10 @@ export const fetchProfile = createDataLoadingThunk(
export const patchProfile = createDataLoadingThunk( export const patchProfile = createDataLoadingThunk(
`${profileEditSlice.name}/patchProfile`, `${profileEditSlice.name}/patchProfile`,
(params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params), (params: Partial<ApiProfileUpdateParams>) => apiPatchProfile(params),
transformProfile, (response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{ {
useLoadingBar: false, useLoadingBar: false,
condition(_, { getState }) { condition(_, { getState }) {
@@ -263,13 +279,39 @@ export const selectImageInfo = createAppSelector(
export const uploadImage = createDataLoadingThunk( export const uploadImage = createDataLoadingThunk(
`${profileEditSlice.name}/uploadImage`, `${profileEditSlice.name}/uploadImage`,
(arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => { (arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => {
// Note: Alt text is not actually supported by the API yet.
const formData = new FormData(); const formData = new FormData();
formData.append(arg.location, arg.imageBlob); formData.append(arg.location, arg.imageBlob);
if (arg.altText) {
formData.append(`${arg.location}_description`, arg.altText);
}
return apiPatchProfile(formData); return apiPatchProfile(formData);
}, },
transformProfile, (response, { dispatch }) => {
dispatch(fetchAccount(response.id));
return transformProfile(response);
},
{
useLoadingBar: false,
},
);
export const deleteImage = createDataLoadingThunk(
`${profileEditSlice.name}/deleteImage`,
(arg: { location: ImageLocation }) => {
if (arg.location === 'avatar') {
return apiDeleteProfileAvatar();
} else {
return apiDeleteProfileHeader();
}
},
async (_, { dispatch, getState }) => {
await dispatch(fetchProfile());
const accountId = getState().profileEdit.profile?.id;
if (accountId) {
dispatch(fetchAccount(accountId));
}
},
{ {
useLoadingBar: false, useLoadingBar: false,
}, },