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 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user