fix: image compression toggle visible + compressed size actually shows
CI / Build & Quality Checks (push) Successful in 10m36s
CI / Build & Quality Checks (push) Successful in 10m36s
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:
@@ -1,5 +1,5 @@
|
||||
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 { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -105,38 +105,71 @@ type CompressionCheckboxProps = {
|
||||
};
|
||||
function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) {
|
||||
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 compressPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
if (!isCompressible(originalFile)) return null;
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked;
|
||||
if (!checked) {
|
||||
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;
|
||||
};
|
||||
// Always-fresh refs so the async callback never captures stale values.
|
||||
const fileItemRef = useRef(fileItem);
|
||||
const metadataRef = useRef(metadata);
|
||||
fileItemRef.current = fileItem;
|
||||
metadataRef.current = metadata;
|
||||
|
||||
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 =
|
||||
result && result.originalSize > 0
|
||||
? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100)
|
||||
displayResult && displayResult.originalSize > 0
|
||||
? Math.round(
|
||||
((displayResult.originalSize - displayResult.compressedSize) /
|
||||
displayResult.originalSize) *
|
||||
100,
|
||||
)
|
||||
: null;
|
||||
|
||||
const originalSize = formatFileSize(originalFile.size);
|
||||
const fileId = `compress-${(originalFile as File).name ?? ''}-${originalFile.size}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -150,34 +183,22 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
{/* Checkbox row */}
|
||||
<Box alignItems="Center" gap="200">
|
||||
<input
|
||||
id={fileId}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
style={{ cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
<label
|
||||
htmlFor={fileId}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
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>
|
||||
)}
|
||||
{/* Toggle row */}
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||
Compress image before uploading
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200">
|
||||
{compressing && (
|
||||
<Text size="T200" style={{ opacity: 0.6 }}>
|
||||
compressing…
|
||||
</Text>
|
||||
)}
|
||||
<Switch variant="Primary" value={checked} onChange={handleToggle} />
|
||||
</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="100"
|
||||
@@ -197,7 +218,7 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{checked && !compressing && result !== undefined && (
|
||||
{checked && !compressing && displayResult !== undefined && (
|
||||
<>
|
||||
<Text size="T200" style={{ opacity: 0.5 }}>
|
||||
→
|
||||
@@ -222,9 +243,9 @@ function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionChe
|
||||
<Text size="T200" style={{ opacity: 0.6 }}>
|
||||
Compressed
|
||||
</Text>
|
||||
{result ? (
|
||||
{displayResult ? (
|
||||
<Text size="T200" style={{ fontWeight: 600 }}>
|
||||
{formatFileSize(result.compressedSize)}
|
||||
{formatFileSize(displayResult.compressedSize)}
|
||||
{savingPct !== null && savingPct > 0 && (
|
||||
<span style={{ opacity: 0.7, marginLeft: 4 }}>({savingPct}% smaller)</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user