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 23636083de..fa8b9df4df 100644 --- a/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx @@ -64,15 +64,21 @@ export const ImageUploadModal: FC< 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); + try { + parseImageFile(file, (result, isAnimated) => { + if (isAnimated) { + // If the image is animated, skip cropping and go straight to alt text. + setImageBlob(file); + setStep('alt'); + } else { + setImageSrc(result); + setStep('crop'); + } + }); + } catch (error) { + console.warn('Error with image parsing:', error); + setStep('select'); + } }, []); const handleCrop = useCallback( @@ -104,19 +110,20 @@ export const ImageUploadModal: FC< ); const handleCancel = useCallback(() => { - switch (step) { - case 'crop': - setImageSrc(null); - setStep('select'); - break; - case 'alt': - setImageBlob(null); + if (step === 'crop') { + setImageSrc(null); + setStep('select'); + } else if (step === 'alt') { + setImageBlob(null); + if (imageSrc) { setStep('crop'); - break; - default: - onClose(); + } else { + setStep('select'); + } + } else { + onClose(); } - }, [onClose, step]); + }, [imageSrc, onClose, step]); return ( void, +): void { + const reader = new FileReader(); + reader.onload = () => { + const buffer = reader.result; + if (!(buffer instanceof ArrayBuffer)) { + throw new Error('Expected an ArrayBuffer'); + } + + // Convert the ArrayBuffer to a base64 data URI. + const bytes = new Uint8Array(buffer); + const base64 = btoa(String.fromCharCode(...bytes)); + const dataUri = `data:${file.type};base64,${base64}`; + + // If the file type is not a GIF, then it's not animated as we don't support animated WebP or PNG. + if (file.type !== 'image/gif') { + cb(dataUri, false); + } + + const view = new DataView(buffer, 10); // Start from the last 4 bytes of the Logical Screen Descriptor. + let offset = 3; + + // Check the first bit for the global color table flag. + const globalColorTable = view.getInt8(0); + if (globalColorTable & 0x08) { + // Grab last three bits to calculate the global color table size, and skip it. + offset += 3 * Math.pow(2, (globalColorTable & 0x7) + 1); + } + + // Check Graphics Control Extension and Graphics Control Label to access animated data. + let delayTime = 0; + if (view.getUint8(offset) & 0x21 && view.getUint8(offset + 1) & 0xf9) { + // Skip to the delay time, which is stored in the next two bytes. + delayTime = view.getUint16(offset + 4); + } + + // If there is a delay time, the GIF is animated. + cb(dataUri, delayTime > 0); + }; + reader.readAsArrayBuffer(file); +} + async function calculateCroppedImage( imageSrc: string, crop: Area, @@ -427,10 +482,7 @@ async function calculateCroppedImage( crop.height, ); - return canvas.convertToBlob({ - quality: 0.7, - type: 'image/jpeg', - }); + return canvas.convertToBlob(); } function dataUriToImage(dataUri: string) {