From 21c27eb3affc486767334cf1b1431e4398fcafc0 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 16 Mar 2026 12:39:52 +0100 Subject: [PATCH] Profile editing: Uploading avatar and header images (#38189) --- .../mastodon/actions/compose_typed.ts | 4 + app/javascript/mastodon/api/accounts.ts | 2 +- .../components/character_counter/index.tsx | 2 + .../account_edit/components/image_edit.tsx | 35 +- .../account_edit/modals/image_alt.tsx | 3 +- .../account_edit/modals/image_delete.tsx | 3 +- .../account_edit/modals/image_upload.tsx | 484 +++++++++++++++++- .../account_edit/modals/styles.module.scss | 49 ++ app/javascript/mastodon/features/ui/index.jsx | 17 +- app/javascript/mastodon/locales/en.json | 14 + app/javascript/mastodon/reducers/compose.js | 5 + .../mastodon/reducers/slices/profile_edit.ts | 46 ++ package.json | 1 + yarn.lock | 21 + 14 files changed, 647 insertions(+), 39 deletions(-) diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 6b38b25c25c..6bf193ba923 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -273,3 +273,7 @@ export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const setComposeQuotePolicy = createAction( 'compose/setQuotePolicy', ); + +export const setDragUploadEnabled = createAction( + 'compose/setDragUploadEnabled', +); diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index da4b0e94f86..15156e156c9 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -67,5 +67,5 @@ export const apiGetFamiliarFollowers = (id: string) => export const apiGetProfile = () => apiRequestGet('v1/profile'); -export const apiPatchProfile = (params: ApiProfileUpdateParams) => +export const apiPatchProfile = (params: ApiProfileUpdateParams | FormData) => apiRequestPatch('v1/profile', params); diff --git a/app/javascript/mastodon/components/character_counter/index.tsx b/app/javascript/mastodon/components/character_counter/index.tsx index dce410a7c13..6ffe4d02f48 100644 --- a/app/javascript/mastodon/components/character_counter/index.tsx +++ b/app/javascript/mastodon/components/character_counter/index.tsx @@ -26,6 +26,7 @@ export const CharacterCounter = polymorphicForwardRef< maxLength, as: Component = 'span', recommended = false, + className, ...props }, ref, @@ -39,6 +40,7 @@ export const CharacterCounter = polymorphicForwardRef< {...props} ref={ref} className={classNames( + className, classes.counter, currentLength > maxLength && !recommended && classes.counterError, )} diff --git a/app/javascript/mastodon/features/account_edit/components/image_edit.tsx b/app/javascript/mastodon/features/account_edit/components/image_edit.tsx index b99b424aecf..340b8156eb6 100644 --- a/app/javascript/mastodon/features/account_edit/components/image_edit.tsx +++ b/app/javascript/mastodon/features/account_edit/components/image_edit.tsx @@ -12,11 +12,9 @@ import { openModal } from '@/mastodon/actions/modal'; import { Dropdown } from '@/mastodon/components/dropdown_menu'; import { IconButton } from '@/mastodon/components/icon_button'; import type { MenuItem } from '@/mastodon/models/dropdown_menu'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/mastodon/store'; +import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit'; +import { selectImageInfo } from '@/mastodon/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import AddIcon from '@/material-icons/400-24px/add.svg?react'; import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; import EditIcon from '@/material-icons/400-24px/edit.svg?react'; @@ -50,36 +48,15 @@ const messages = defineMessages({ }, }); -export type ImageLocation = 'avatar' | 'header'; - -const selectImageInfo = createAppSelector( - [ - (state) => state.profileEdit.profile, - (_, location: ImageLocation) => location, - ], - (profile, location) => { - if (!profile) { - return { - hasImage: false, - hasAlt: false, - }; - } - - return { - hasImage: !!profile[`${location}Static`], - hasAlt: !!profile[`${location}Description`], - }; - }, -); - export const AccountImageEdit: FC<{ className?: string; location: ImageLocation; }> = ({ className, location }) => { const intl = useIntl(); - const { hasAlt, hasImage } = useAppSelector((state) => + const { alt, src } = useAppSelector((state) => selectImageInfo(state, location), ); + const hasAlt = !!alt; const dispatch = useAppDispatch(); const handleModal = useCallback( @@ -125,7 +102,7 @@ export const AccountImageEdit: FC<{ const iconClassName = classNames(classes.imageButton, className); - if (!hasImage) { + if (!src) { return ( = ({ onClose }) => { - return ; +> = ({ onClose, location }) => { + const { src: oldSrc } = useAppSelector((state) => + selectImageInfo(state, location), + ); + const hasImage = !!oldSrc; + const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); + + // State for individual steps. + const [imageSrc, setImageSrc] = useState(null); + const [imageBlob, setImageBlob] = useState(null); + + const handleFile = useCallback((file: File) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + const result = reader.result; + if (typeof result === 'string' && result.length > 0) { + setImageSrc(result); + setStep('crop'); + } + }); + reader.readAsDataURL(file); + }, []); + + const handleCrop = useCallback( + (crop: Area) => { + if (!imageSrc) { + setStep('select'); + return; + } + void calculateCroppedImage(imageSrc, crop).then((blob) => { + setImageBlob(blob); + setStep('alt'); + }); + }, + [imageSrc], + ); + + const dispatch = useAppDispatch(); + const handleSave = useCallback( + (altText: string) => { + if (!imageBlob) { + setStep('crop'); + return; + } + void dispatch(uploadImage({ location, imageBlob, altText })).then( + onClose, + ); + }, + [dispatch, imageBlob, location, onClose], + ); + + const handleCancel = useCallback(() => { + switch (step) { + case 'crop': + setImageSrc(null); + setStep('select'); + break; + case 'alt': + setImageBlob(null); + setStep('crop'); + break; + default: + onClose(); + } + }, [onClose, step]); + + return ( + + ) : ( + + ) + } + onClose={onClose} + wrapperClassName={classes.uploadWrapper} + noCancelButton + > + {step === 'select' && ( + + )} + {step === 'crop' && imageSrc && ( + + )} + {step === 'alt' && imageBlob && ( + + )} + + ); }; + +// Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]; + +const StepUpload: FC<{ + location: ImageLocation; + onFile: (file: File) => void; +}> = ({ location, onFile }) => { + const inputRef = useRef(null); + const handleUploadClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleFileChange: ChangeEventHandler = useCallback( + (event) => { + const file = event.currentTarget.files?.[0]; + if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) { + return; + } + onFile(file); + }, + [onFile], + ); + + // Handle drag and drop + const [isDragging, setDragging] = useState(false); + + const handleDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + if (!event.dataTransfer?.types.includes('Files')) { + return; + } + + const items = Array.from(event.dataTransfer.items); + if ( + !items.some( + (item) => + item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type), + ) + ) { + return; + } + + setDragging(true); + }, []); + const handleDragDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + + if (!event.dataTransfer?.files) { + return; + } + + const file = Array.from(event.dataTransfer.files).find((f) => + ALLOWED_MIME_TYPES.includes(f.type), + ); + if (!file) { + return; + } + + onFile(file); + }, + [onFile], + ); + const handleDragLeave = useCallback((event: DragEvent) => { + event.preventDefault(); + setDragging(false); + }, []); + + const dispatch = useAppDispatch(); + useEffect(() => { + dispatch(setDragUploadEnabled(false)); + document.addEventListener('dragover', handleDragOver); + document.addEventListener('drop', handleDragDrop); + document.addEventListener('dragleave', handleDragLeave); + + return () => { + document.removeEventListener('dragover', handleDragOver); + document.removeEventListener('drop', handleDragDrop); + document.removeEventListener('dragleave', handleDragLeave); + dispatch(setDragUploadEnabled(true)); + }; + }, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]); + + if (isDragging) { + return ( +
+ +
+ ); + } + + return ( +
+ + , + limit: 8, + width: location === 'avatar' ? 400 : 1500, + height: location === 'avatar' ? 400 : 500, + }} + tagName='p' + /> + + + +
+ ); +}; + +const zoomLabel = defineMessage({ + id: 'account_edit.upload_modal.step_crop.zoom', + defaultMessage: 'Zoom', +}); + +const StepCrop: FC<{ + src: string; + location: ImageLocation; + onCancel: () => void; + onComplete: (crop: Area) => void; +}> = ({ src, location, onCancel, onComplete }) => { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [croppedArea, setCroppedArea] = useState(null); + const [zoom, setZoom] = useState(1); + const intl = useIntl(); + + const handleZoomChange: ChangeEventHandler = useCallback( + (event) => { + setZoom(event.currentTarget.valueAsNumber); + }, + [], + ); + const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedAreaPixels); + }, []); + + const handleNext = useCallback(() => { + if (croppedArea) { + onComplete(croppedArea); + } + }, [croppedArea, onComplete]); + + return ( + <> +
+ +
+ +
+ + + +
+ + ); +}; + +const StepAlt: FC<{ + imageBlob: Blob; + onCancel: () => void; + onComplete: (altText: string) => void; +}> = ({ imageBlob, onCancel, onComplete }) => { + const [altText, setAltText] = useState(''); + + const handleChange: ChangeEventHandler = useCallback( + (event) => { + setAltText(event.currentTarget.value); + }, + [], + ); + + const handleComplete = useCallback(() => { + onComplete(altText); + }, [altText, onComplete]); + + const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]); + const altLimit = useAppSelector( + (state) => + state.server.getIn( + ['server', 'configuration', 'media_attachments', 'description_limit'], + 150, + ) as number, + ); + + return ( + <> + + +
+ + } + hint={ + + } + onChange={handleChange} + /> + +
+ + + } + > + + + +
+ + + +
+ + ); +}; + +async function calculateCroppedImage( + imageSrc: string, + crop: Area, +): Promise { + const image = await dataUriToImage(imageSrc); + const canvas = new OffscreenCanvas(crop.width, crop.height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + ctx.imageSmoothingQuality = 'high'; + + // Draw the image + ctx.drawImage( + image, + crop.x, + crop.y, + crop.width, + crop.height, + 0, + 0, + crop.width, + crop.height, + ); + + return canvas.convertToBlob({ + quality: 0.7, + type: 'image/jpeg', + }); +} + +function dataUriToImage(dataUri: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => { + resolve(image); + }); + image.addEventListener('error', (event) => { + if (event.error instanceof Error) { + reject(event.error); + } else { + reject(new Error('Failed to load image')); + } + }); + image.src = dataUri; + }); +} diff --git a/app/javascript/mastodon/features/account_edit/modals/styles.module.scss b/app/javascript/mastodon/features/account_edit/modals/styles.module.scss index 0bd4c07a150..0a0a956eb5a 100644 --- a/app/javascript/mastodon/features/account_edit/modals/styles.module.scss +++ b/app/javascript/mastodon/features/account_edit/modals/styles.module.scss @@ -80,6 +80,55 @@ } } +.uploadWrapper { + min-height: min(400px, 70vh); + justify-content: center; +} + +.uploadStepSelect { + text-align: center; + + h2 { + color: var(--color-text-primary); + font-size: 15px; + font-weight: 600; + margin-bottom: 4px; + } + + button { + margin-top: 16px; + } +} + +.cropContainer { + position: relative; + width: 100%; + height: 300px; + overflow: hidden; +} + +.cropActions { + margin-top: 8px; // 16px gap from DialogModal, plus 8px = 24px to look like normal action buttons. + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + .zoomControl { + width: min(100%, 200px); + margin-right: auto; + } +} + +.altImage { + max-height: 300px; + object-fit: contain; +} + +.altCounter { + color: var(--color-text-secondary); +} + .verifiedSteps { font-size: 15px; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 3e14b016e97..eae6d35a5f7 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -103,7 +103,11 @@ const mapStateToProps = state => ({ layout: state.getIn(['meta', 'layout']), isComposing: state.getIn(['compose', 'is_composing']), hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null, - canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']), + canUploadMore: + !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) + && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']), + isUploadEnabled: + state.getIn(['compose', 'isDragDisabled']) !== true, firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, newAccount: !state.getIn(['accounts', me, 'note']) && !state.getIn(['accounts', me, 'bot']) && state.getIn(['accounts', me, 'following_count'], 0) === 0 && state.getIn(['accounts', me, 'statuses_count'], 0) === 0, username: state.getIn(['accounts', me, 'username']), @@ -324,6 +328,9 @@ class UI extends PureComponent { }; handleDragEnter = (e) => { + if (!this.props.isUploadEnabled) { + return; + } e.preventDefault(); if (!this.dragTargets) { @@ -340,6 +347,9 @@ class UI extends PureComponent { }; handleDragOver = (e) => { + if (!this.props.isUploadEnabled) { + return; + } if (this.dataTransferIsText(e.dataTransfer)) return false; e.preventDefault(); @@ -355,6 +365,9 @@ class UI extends PureComponent { }; handleDrop = (e) => { + if (!this.props.isUploadEnabled) { + return; + } if (this.dataTransferIsText(e.dataTransfer)) return; e.preventDefault(); @@ -429,7 +442,6 @@ class UI extends PureComponent { document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('drop', this.handleDrop, false); document.addEventListener('dragleave', this.handleDragLeave, false); - document.addEventListener('dragend', this.handleDragEnd, false); if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); @@ -456,7 +468,6 @@ class UI extends PureComponent { document.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('drop', this.handleDrop); document.removeEventListener('dragleave', this.handleDragLeave); - document.removeEventListener('dragend', this.handleDragEnd); } setRef = c => { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index db5c1313a54..26ce99c3dae 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -203,6 +203,20 @@ "account_edit.profile_tab.subtitle": "Customize the tabs on your profile and what they display.", "account_edit.profile_tab.title": "Profile tab settings", "account_edit.save": "Save", + "account_edit.upload_modal.back": "Back", + "account_edit.upload_modal.done": "Done", + "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": "Let’s 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_upload.button": "Browse files", + "account_edit.upload_modal.step_upload.dragging": "Drop to upload", + "account_edit.upload_modal.step_upload.header": "Choose an image", + "account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.", + "account_edit.upload_modal.title_add": "Add profile photo", + "account_edit.upload_modal.title_replace": "Replace profile photo", "account_edit.verified_modal.details": "Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:", "account_edit.verified_modal.invisible_link.details": "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.", "account_edit.verified_modal.invisible_link.summary": "How do I make the link invisible?", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 51508c777d8..705b3186ba2 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -8,6 +8,7 @@ import { setComposeQuotePolicy, pasteLinkCompose, cancelPasteLinkCompose, + setDragUploadEnabled, } from '@/mastodon/actions/compose_typed'; import { timelineDelete } from 'mastodon/actions/timelines_typed'; @@ -75,6 +76,7 @@ const initialState = ImmutableMap({ is_submitting: false, is_changing_upload: false, is_uploading: false, + isDragDisabled: false, should_redirect_to_compose_page: false, progress: 0, isUploadingThumbnail: false, @@ -132,6 +134,7 @@ function clearAll(state) { map.set('idempotencyKey', uuid()); map.set('quoted_status_id', null); map.set('quote_policy', state.get('default_quote_policy')); + map.set('isDragDisabled', false); }); } @@ -359,6 +362,8 @@ export const composeReducer = (state = initialState, action) => { return action.meta.requestId === state.get('fetching_link') ? state.set('fetching_link', null) : state; } else if (cancelPasteLinkCompose.match(action)) { return state.set('fetching_link', null); + } else if (setDragUploadEnabled.match(action)) { + return state.set('isDragDisabled', !action.payload); } switch(action.type) { diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index 62a908e5b12..7efd71eb3ea 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -109,6 +109,17 @@ const profileEditSlice = createSlice({ state.isPending = false; }); + builder.addCase(uploadImage.pending, (state) => { + state.isPending = true; + }); + builder.addCase(uploadImage.rejected, (state) => { + state.isPending = false; + }); + builder.addCase(uploadImage.fulfilled, (state, action) => { + state.profile = action.payload; + state.isPending = false; + }); + builder.addCase(addFeaturedTag.pending, (state) => { state.isPending = true; }); @@ -229,6 +240,41 @@ export const patchProfile = createDataLoadingThunk( }, ); +export type ImageLocation = 'avatar' | 'header'; + +export const selectImageInfo = createAppSelector( + [ + (state) => state.profileEdit.profile, + (_, location: ImageLocation) => location, + ], + (profile, location) => { + if (!profile) { + return {}; + } + + return { + src: profile[location], + static: profile[`${location}Static`], + alt: profile[`${location}Description`], + }; + }, +); + +export const uploadImage = createDataLoadingThunk( + `${profileEditSlice.name}/uploadImage`, + (arg: { location: ImageLocation; imageBlob: Blob; altText: string }) => { + // Note: Alt text is not actually supported by the API yet. + const formData = new FormData(); + formData.append(arg.location, arg.imageBlob); + + return apiPatchProfile(formData); + }, + transformProfile, + { + useLoadingBar: false, + }, +); + export const selectFieldById = createAppSelector( [(state) => state.profileEdit.profile?.fields, (_, id?: string) => id], (fields, fieldId) => { diff --git a/package.json b/package.json index e196a7c596b..9ab025be45b 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "punycode": "^2.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.5.6", "react-helmet": "^6.1.0", "react-immutable-proptypes": "^2.2.0", "react-immutable-pure-component": "^2.2.2", diff --git a/yarn.lock b/yarn.lock index d99c4b2aa54..2795e9bd97e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2919,6 +2919,7 @@ __metadata: punycode: "npm:^2.3.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" + react-easy-crop: "npm:^5.5.6" react-helmet: "npm:^6.1.0" react-immutable-proptypes: "npm:^2.2.0" react-immutable-pure-component: "npm:^2.2.2" @@ -10244,6 +10245,13 @@ __metadata: languageName: node linkType: hard +"normalize-wheel@npm:^1.0.1": + version: 1.0.1 + resolution: "normalize-wheel@npm:1.0.1" + checksum: 10c0/5daf4c97e39f36658a5263a6499bbc148676ae2bd85f12c8d03c46ffe7bc3c68d44564c00413d88d0457ac0d94450559bb1c24c2ce7ae0c107031f82d093ac06 + languageName: node + linkType: hard + "object-assign@npm:^4, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -11703,6 +11711,19 @@ __metadata: languageName: node linkType: hard +"react-easy-crop@npm:^5.5.6": + version: 5.5.6 + resolution: "react-easy-crop@npm:5.5.6" + dependencies: + normalize-wheel: "npm:^1.0.1" + tslib: "npm:^2.0.1" + peerDependencies: + react: ">=16.4.0" + react-dom: ">=16.4.0" + checksum: 10c0/ce623791d31559fc46f210ece7b22c0f659710d5de219ef9fb05650940f50445d5e6573ed229b66fad06dfda9651ae458c0f5efb8e1cabdf01511dc32942cdc8 + languageName: node + linkType: hard + "react-fast-compare@npm:^3.1.1": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2"