diff --git a/app/javascript/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index 257c867034..ffbcd7a3f5 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/api/accounts.ts b/app/javascript/flavours/glitch/api/accounts.ts index d2e24fa5cd..c7dea94334 100644 --- a/app/javascript/flavours/glitch/api/accounts.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/character_counter/index.tsx b/app/javascript/flavours/glitch/components/character_counter/index.tsx index dce410a7c1..6ffe4d02f4 100644 --- a/app/javascript/flavours/glitch/components/character_counter/index.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/account_edit/components/image_edit.tsx b/app/javascript/flavours/glitch/features/account_edit/components/image_edit.tsx index fbb00909b3..994f58bf83 100644 --- a/app/javascript/flavours/glitch/features/account_edit/components/image_edit.tsx +++ b/app/javascript/flavours/glitch/features/account_edit/components/image_edit.tsx @@ -12,11 +12,9 @@ import { openModal } from '@/flavours/glitch/actions/modal'; import { Dropdown } from '@/flavours/glitch/components/dropdown_menu'; import { IconButton } from '@/flavours/glitch/components/icon_button'; import type { MenuItem } from '@/flavours/glitch/models/dropdown_menu'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/flavours/glitch/store'; +import type { ImageLocation } from '@/flavours/glitch/reducers/slices/profile_edit'; +import { selectImageInfo } from '@/flavours/glitch/reducers/slices/profile_edit'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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/flavours/glitch/features/account_edit/modals/styles.module.scss b/app/javascript/flavours/glitch/features/account_edit/modals/styles.module.scss index 0bd4c07a15..0a0a956eb5 100644 --- a/app/javascript/flavours/glitch/features/account_edit/modals/styles.module.scss +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index de336ba7f2..f45c6a263e 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -105,7 +105,11 @@ const messages = defineMessages({ const mapStateToProps = state => ({ layout: state.getIn(['meta', 'layout']), 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 < 4, + 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, isWide: state.getIn(['local_settings', 'stretch']), fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']), unreadNotifications: selectUnreadNotificationGroupsCount(state), @@ -339,6 +343,9 @@ class UI extends PureComponent { }; handleDragEnter = (e) => { + if (!this.props.isUploadEnabled) { + return; + } e.preventDefault(); if (!this.dragTargets) { @@ -355,6 +362,9 @@ class UI extends PureComponent { }; handleDragOver = (e) => { + if (!this.props.isUploadEnabled) { + return; + } if (this.dataTransferIsText(e.dataTransfer)) return false; e.preventDefault(); @@ -370,6 +380,9 @@ class UI extends PureComponent { }; handleDrop = (e) => { + if (!this.props.isUploadEnabled) { + return; + } if (this.dataTransferIsText(e.dataTransfer)) return; e.preventDefault(); @@ -442,7 +455,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); @@ -502,7 +514,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/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index c238536e4d..eefd8a1281 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -8,6 +8,7 @@ import { setComposeQuotePolicy, pasteLinkCompose, cancelPasteLinkCompose, + setDragUploadEnabled, } from '@/flavours/glitch/actions/compose_typed'; import { timelineDelete } from 'flavours/glitch/actions/timelines_typed'; @@ -86,6 +87,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, @@ -184,6 +186,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); }); } @@ -438,6 +441,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/flavours/glitch/reducers/slices/profile_edit.ts b/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts index f1afea5592..0b5860f7f5 100644 --- a/app/javascript/flavours/glitch/reducers/slices/profile_edit.ts +++ b/app/javascript/flavours/glitch/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) => {