fix: image compression toggle visible + compressed size actually shows

Two bugs fixed:

1. Invisible toggle: index.css applies appearance:none to ALL
   input[type=checkbox], making the native checkbox invisible. Replaced
   the hidden <input type="checkbox"> with the folds Switch component
   which is properly styled and visible in both TDS and non-TDS themes.

2. Compressed size never appeared (stale closure bug): handleChange was
   async. After the first setMetadata() call the fileItem in selectedFiles
   was replaced with a new object. The .then() callback still held the
   OLD fileItem reference, so its setMetadata() call silently failed to
   find the item (REPLACE action couldn't match the stale ref). Fix:
   compression now runs in a useEffect triggered by the checked/compressible
   state. fileItemRef and metadataRef are updated each render so the
   async callback always writes to the current item. A stable fileKey
   string (name + size) is used as the dep instead of the File object
   reference so the effect doesn't re-run on every metadata update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 21:31:05 -04:00
parent 6ec908407b
commit 67fb0a5120
@@ -1,5 +1,5 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react'; import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Switch, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -105,38 +105,71 @@ type CompressionCheckboxProps = {
}; };
function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) { function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) {
const originalFile = fileItem.originalFile as File | Blob; const originalFile = fileItem.originalFile as File | Blob;
const compressible = isCompressible(originalFile);
// Local compression result — avoids the stale-closure bug where the async
// .then() would call setMetadata with an outdated fileItem reference.
const [localResult, setLocalResult] = useState<
import('../../utils/imageCompression').CompressionResult | null | undefined
>(undefined);
const [compressing, setCompressing] = useState(false); const [compressing, setCompressing] = useState(false);
const compressPromiseRef = useRef<Promise<void> | null>(null);
if (!isCompressible(originalFile)) return null; // Always-fresh refs so the async callback never captures stale values.
const fileItemRef = useRef(fileItem);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const metadataRef = useRef(metadata);
const checked = e.target.checked; fileItemRef.current = fileItem;
if (!checked) { metadataRef.current = metadata;
setMetadata(fileItem, { ...metadata, compressImage: false, compressionResult: undefined });
return;
}
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: undefined });
setCompressing(true);
const p = compressImage(originalFile).then((result) => {
setCompressing(false);
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: result });
});
compressPromiseRef.current = p;
};
const checked = !!metadata.compressImage; const checked = !!metadata.compressImage;
const result = metadata.compressionResult;
// Stable file identity key — the File object reference changes when metadata is
// updated (fileItem is replaced), but the underlying file never changes.
const fileKey = `${(originalFile as File).name ?? ''}-${originalFile.size}`;
// Run compression whenever the toggle is turned on; cancel on uncheck / unmount.
useEffect(() => {
if (!checked || !compressible) return undefined;
let cancelled = false;
setCompressing(true);
setLocalResult(undefined);
compressImage(originalFile).then((result) => {
if (cancelled) return;
setCompressing(false);
setLocalResult(result);
// Persist to metadata using fresh refs so the REPLACE action finds the
// correct fileItem even after a previous setMetadata call has swapped it.
setMetadata(fileItemRef.current, {
...metadataRef.current,
compressImage: true,
compressionResult: result,
});
});
return () => {
cancelled = true;
};
// fileKey is a stable primitive derived from the file; setMetadata is a stable useCallback.
// Using these instead of the object refs avoids re-running on every metadata update.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [checked, compressible, fileKey, setMetadata]);
if (!compressible) return null;
const handleToggle = (value: boolean) => {
if (!value) setLocalResult(undefined);
setMetadata(fileItem, { ...metadata, compressImage: value, compressionResult: undefined });
};
const displayResult = localResult ?? metadata.compressionResult;
const savingPct = const savingPct =
result && result.originalSize > 0 displayResult && displayResult.originalSize > 0
? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100) ? Math.round(
((displayResult.originalSize - displayResult.compressedSize) /
displayResult.originalSize) *
100,
)
: null; : null;
const originalSize = formatFileSize(originalFile.size); const originalSize = formatFileSize(originalFile.size);
const fileId = `compress-${(originalFile as File).name ?? ''}-${originalFile.size}`;
return ( return (
<Box <Box
@@ -150,34 +183,22 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
border: `1px solid ${color.SurfaceVariant.ContainerLine}`, border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
}} }}
> >
{/* Checkbox row */} {/* Toggle row */}
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
<input <Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
id={fileId} Compress image before uploading
type="checkbox" </Text>
checked={checked} <Box alignItems="Center" gap="200">
onChange={handleChange} {compressing && (
style={{ cursor: 'pointer', flexShrink: 0 }} <Text size="T200" style={{ opacity: 0.6 }}>
/> compressing
<label </Text>
htmlFor={fileId} )}
style={{ <Switch variant="Primary" value={checked} onChange={handleToggle} />
cursor: 'pointer', </Box>
color: color.SurfaceVariant.OnContainer,
userSelect: 'none',
fontSize: '0.8rem',
}}
>
Compress before uploading
</label>
{compressing && (
<Text size="T200" style={{ opacity: 0.6, marginLeft: 'auto' }}>
compressing
</Text>
)}
</Box> </Box>
{/* Before / after row — always show original; show compressed when ready */} {/* Size comparison — original always visible; compressed appears when ready */}
<Box gap="200" alignItems="Center" wrap="Wrap"> <Box gap="200" alignItems="Center" wrap="Wrap">
<Box <Box
gap="100" gap="100"
@@ -197,7 +218,7 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
</Text> </Text>
</Box> </Box>
{checked && !compressing && result !== undefined && ( {checked && !compressing && displayResult !== undefined && (
<> <>
<Text size="T200" style={{ opacity: 0.5 }}> <Text size="T200" style={{ opacity: 0.5 }}>
@@ -222,9 +243,9 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
<Text size="T200" style={{ opacity: 0.6 }}> <Text size="T200" style={{ opacity: 0.6 }}>
Compressed Compressed
</Text> </Text>
{result ? ( {displayResult ? (
<Text size="T200" style={{ fontWeight: 600 }}> <Text size="T200" style={{ fontWeight: 600 }}>
{formatFileSize(result.compressedSize)} {formatFileSize(displayResult.compressedSize)}
{savingPct !== null && savingPct > 0 && ( {savingPct !== null && savingPct > 0 && (
<span style={{ opacity: 0.7, marginLeft: 4 }}>({savingPct}% smaller)</span> <span style={{ opacity: 0.7, marginLeft: 4 }}>({savingPct}% smaller)</span>
)} )}