bf75f4657d
Glassmorphism: the sidebar is a flex sibling of the room view, so
backdrop-filter had nothing behind it to blur. Fix: apply the active
chat background to document.body when glassmorphismSidebar is on
(cleaned up when it's turned off or the component unmounts). Now the
sidebar blurs through the same background pattern as the room view,
making the frosted-glass effect obvious.
Image upload cleanup: delete the pre-uploaded original MXC from the
homeserver after the compressed version is successfully uploaded
(Synapse 1.97+ DELETE /_matrix/client/v1/media/{server}/{mediaId}).
Also delete on cancel when a successful upload is removed by the user.
Both are best-effort — failures are swallowed so UX is unaffected.
Added tryDeleteMxcContent() utility to src/app/utils/matrix.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
|
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
|
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
|
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { TUploadContent } from '../../utils/matrix';
|
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
|
import {
|
|
roomUploadAtomFamily,
|
|
TUploadItem,
|
|
TUploadMetadata,
|
|
} from '../../state/room/roomInputDrafts';
|
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
|
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
|
|
import { tryDeleteMxcContent } from '../../utils/matrix';
|
|
|
|
type PreviewImageProps = {
|
|
fileItem: TUploadItem;
|
|
};
|
|
function PreviewImage({ fileItem }: PreviewImageProps) {
|
|
const { originalFile, metadata } = fileItem;
|
|
const fileUrl = useObjectURL(originalFile);
|
|
|
|
return (
|
|
<img
|
|
style={{
|
|
objectFit: 'contain',
|
|
width: '100%',
|
|
height: toRem(152),
|
|
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
|
}}
|
|
alt={(originalFile as File).name}
|
|
src={fileUrl}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type PreviewVideoProps = {
|
|
fileItem: TUploadItem;
|
|
};
|
|
function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|
const { originalFile, metadata } = fileItem;
|
|
const fileUrl = useObjectURL(originalFile);
|
|
|
|
return (
|
|
<video
|
|
style={{
|
|
objectFit: 'contain',
|
|
width: '100%',
|
|
height: toRem(152),
|
|
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
|
}}
|
|
src={fileUrl}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type MediaPreviewProps = {
|
|
fileItem: TUploadItem;
|
|
onSpoiler: (marked: boolean) => void;
|
|
children: ReactNode;
|
|
};
|
|
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
|
const { originalFile, metadata } = fileItem;
|
|
const fileUrl = useObjectURL(originalFile);
|
|
|
|
return fileUrl ? (
|
|
<Box
|
|
style={{
|
|
borderRadius: config.radii.R300,
|
|
overflow: 'hidden',
|
|
backgroundColor: 'black',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{children}
|
|
<Box
|
|
justifyContent="End"
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: config.space.S100,
|
|
left: config.space.S100,
|
|
right: config.space.S100,
|
|
}}
|
|
>
|
|
<Chip
|
|
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
|
|
fill="Soft"
|
|
radii="Pill"
|
|
aria-pressed={metadata.markedAsSpoiler}
|
|
before={<Icon src={Icons.EyeBlind} size="50" />}
|
|
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
|
|
>
|
|
<Text size="B300">Spoiler</Text>
|
|
</Chip>
|
|
</Box>
|
|
</Box>
|
|
) : null;
|
|
}
|
|
|
|
type CompressionCheckboxProps = {
|
|
fileItem: TUploadItem;
|
|
metadata: TUploadMetadata;
|
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
|
};
|
|
function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) {
|
|
const originalFile = fileItem.originalFile as File;
|
|
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;
|
|
};
|
|
|
|
const checked = !!metadata.compressImage;
|
|
const result = metadata.compressionResult;
|
|
|
|
const savingPct =
|
|
result && result.originalSize > 0
|
|
? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100)
|
|
: null;
|
|
|
|
const originalSize = formatFileSize(originalFile.size);
|
|
|
|
return (
|
|
<Box
|
|
direction="Column"
|
|
gap="200"
|
|
style={{
|
|
marginTop: config.space.S100,
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
background: color.SurfaceVariant.Container,
|
|
borderRadius: config.radii.R300,
|
|
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
|
}}
|
|
>
|
|
{/* Checkbox row */}
|
|
<Box alignItems="Center" gap="200">
|
|
<input
|
|
id={`compress-${originalFile.name}-${originalFile.size}`}
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={handleChange}
|
|
style={{ cursor: 'pointer', flexShrink: 0 }}
|
|
/>
|
|
<label
|
|
htmlFor={`compress-${originalFile.name}-${originalFile.size}`}
|
|
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>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Before / after row — always show original; show compressed when ready */}
|
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
|
<Box
|
|
gap="100"
|
|
alignItems="Center"
|
|
style={{
|
|
padding: `2px ${config.space.S200}`,
|
|
borderRadius: config.radii.R300,
|
|
background: color.Surface.Container,
|
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
|
}}
|
|
>
|
|
<Text size="T200" style={{ opacity: 0.6 }}>
|
|
Original
|
|
</Text>
|
|
<Text size="T200" style={{ fontWeight: 600 }}>
|
|
{originalSize}
|
|
</Text>
|
|
</Box>
|
|
|
|
{checked && !compressing && result !== undefined && (
|
|
<>
|
|
<Text size="T200" style={{ opacity: 0.5 }}>
|
|
→
|
|
</Text>
|
|
<Box
|
|
gap="100"
|
|
alignItems="Center"
|
|
style={{
|
|
padding: `2px ${config.space.S200}`,
|
|
borderRadius: config.radii.R300,
|
|
background:
|
|
savingPct !== null && savingPct > 5
|
|
? (color.Success?.Container ?? color.Primary.Container)
|
|
: color.Surface.Container,
|
|
border: `1px solid ${
|
|
savingPct !== null && savingPct > 5
|
|
? (color.Success?.ContainerLine ?? color.Primary.ContainerLine)
|
|
: color.Surface.ContainerLine
|
|
}`,
|
|
}}
|
|
>
|
|
<Text size="T200" style={{ opacity: 0.6 }}>
|
|
Compressed
|
|
</Text>
|
|
{result ? (
|
|
<Text size="T200" style={{ fontWeight: 600 }}>
|
|
{formatFileSize(result.compressedSize)}
|
|
{savingPct !== null && savingPct > 0 && (
|
|
<span style={{ opacity: 0.7, marginLeft: 4 }}>({savingPct}% smaller)</span>
|
|
)}
|
|
</Text>
|
|
) : (
|
|
<Text size="T200" style={{ opacity: 0.6 }}>
|
|
unavailable
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type UploadCardRendererProps = {
|
|
isEncrypted?: boolean;
|
|
fileItem: TUploadItem;
|
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
|
onRemove: (file: TUploadContent) => void;
|
|
onComplete?: (upload: UploadSuccess) => void;
|
|
};
|
|
export function UploadCardRenderer({
|
|
isEncrypted,
|
|
fileItem,
|
|
setMetadata,
|
|
onRemove,
|
|
onComplete,
|
|
}: UploadCardRendererProps) {
|
|
const mx = useMatrixClient();
|
|
const mediaConfig = useMediaConfig();
|
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
|
|
|
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
|
const { metadata } = fileItem;
|
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
|
const { file } = upload;
|
|
const fileSizeExceeded = file.size >= allowSize;
|
|
|
|
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
|
startUpload();
|
|
}
|
|
|
|
const handleSpoiler = (marked: boolean) => {
|
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
|
};
|
|
|
|
const removeUpload = () => {
|
|
if (upload.status === UploadStatus.Success) {
|
|
// Upload already completed — delete the orphaned MXC from the server.
|
|
tryDeleteMxcContent(mx, upload.mxc);
|
|
}
|
|
cancelUpload();
|
|
onRemove(file);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (upload.status === UploadStatus.Success) {
|
|
onComplete?.(upload);
|
|
}
|
|
}, [upload, onComplete]);
|
|
|
|
return (
|
|
<UploadCard
|
|
radii="300"
|
|
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
|
|
after={
|
|
<>
|
|
{upload.status === UploadStatus.Error && (
|
|
<Chip
|
|
as="button"
|
|
onClick={startUpload}
|
|
aria-label="Retry Upload"
|
|
variant="Critical"
|
|
radii="Pill"
|
|
outlined
|
|
>
|
|
<Text size="B300">Retry</Text>
|
|
</Chip>
|
|
)}
|
|
<IconButton
|
|
onClick={removeUpload}
|
|
aria-label="Cancel Upload"
|
|
variant="SurfaceVariant"
|
|
radii="Pill"
|
|
size="300"
|
|
>
|
|
<Icon src={Icons.Cross} size="200" />
|
|
</IconButton>
|
|
</>
|
|
}
|
|
bottom={
|
|
<>
|
|
{fileItem.originalFile.type.startsWith('image') && (
|
|
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
|
<PreviewImage fileItem={fileItem} />
|
|
</MediaPreview>
|
|
)}
|
|
{fileItem.originalFile.type.startsWith('video') && (
|
|
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
|
<PreviewVideo fileItem={fileItem} />
|
|
</MediaPreview>
|
|
)}
|
|
{(fileItem.originalFile.type.startsWith('image') ||
|
|
fileItem.originalFile.type.startsWith('video')) && (
|
|
<input
|
|
type="text"
|
|
placeholder="Add a caption… (optional)"
|
|
value={metadata.caption ?? ''}
|
|
onChange={(e) => setMetadata(fileItem, { ...metadata, caption: e.target.value })}
|
|
data-caption-input
|
|
style={{
|
|
marginTop: '6px',
|
|
width: '100%',
|
|
background: 'var(--bg-surface-low)',
|
|
border: '1px solid var(--bg-surface-border)',
|
|
borderRadius: '6px',
|
|
padding: '5px 8px',
|
|
fontSize: '0.85rem',
|
|
color: 'var(--text-primary)',
|
|
outline: 'none',
|
|
boxSizing: 'border-box',
|
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
|
}}
|
|
/>
|
|
)}
|
|
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
|
)}
|
|
{upload.status === UploadStatus.Loading && (
|
|
<UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
|
|
)}
|
|
{upload.status === UploadStatus.Error && (
|
|
<UploadCardError>
|
|
<Text size="T200">{upload.error.message}</Text>
|
|
</UploadCardError>
|
|
)}
|
|
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
|
<UploadCardError>
|
|
<Text size="T200">
|
|
The file size exceeds the limit. Maximum allowed size is{' '}
|
|
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
|
|
<b>{bytesToSize(file.size)}</b>.
|
|
</Text>
|
|
</UploadCardError>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<Text size="H6" truncate>
|
|
{(file as File).name}
|
|
</Text>
|
|
{upload.status === UploadStatus.Success && (
|
|
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
|
|
)}
|
|
</UploadCard>
|
|
);
|
|
}
|