08937c6278
ESLint: UploadCardRenderer.tsx had two separate imports from
../../utils/matrix — merged tryDeleteMxcContent into the existing
import statement. Removed now-unnecessary eslint-disable directive from
chatBackground.ts (the _anim prefix already suppresses the rule).
Glassmorphism: the Scroll inside SidebarNav had variant="Background"
which set a solid backgroundColor on the entire scrollable area,
completely hiding the sidebar's semi-transparent glass + backdrop-filter.
Fix: pass variant={undefined} when glassmorphismSidebar is on so the
inner scroll area is transparent and the blur effect is visible through
it. The document.body background (set by the previous useEffect fix)
now shows through the frosted glass as intended.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
388 lines
12 KiB
TypeScript
388 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, tryDeleteMxcContent } 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';
|
|
|
|
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>
|
|
);
|
|
}
|