Files
cinny/src/app/components/upload-card/UploadCardRenderer.tsx
T

340 lines
11 KiB
TypeScript
Raw Normal View History

import React, { ReactNode, useEffect, useRef, useState } from 'react';
2025-02-26 21:43:43 +11:00
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
2023-06-12 21:15:23 +10:00
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
2023-06-12 21:15:23 +10:00
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import {
roomUploadAtomFamily,
TUploadItem,
TUploadMetadata,
} from '../../state/room/roomInputDrafts';
2025-02-26 21:43:43 +11:00
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
2025-02-26 21:43:43 +11:00
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) {
2025-02-26 21:43:43 +11:00
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return fileUrl ? (
<Box
style={{
borderRadius: config.radii.R300,
overflow: 'hidden',
backgroundColor: 'black',
position: 'relative',
}}
>
{children}
2025-02-26 21:43:43 +11:00
<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;
}
2023-06-12 21:15:23 +10:00
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;
}
// Optimistically mark as enabled; kick off compression in background
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;
return (
<Box
direction="Column"
gap="100"
style={{
marginTop: '4px',
padding: '6px 8px',
background: 'var(--bg-surface-low)',
borderRadius: '6px',
border: '1px solid var(--bg-surface-border)',
fontSize: '0.8rem',
}}
>
<Box alignItems="Center" gap="200">
<input
id={`compress-${originalFile.name}-${originalFile.size}`}
type="checkbox"
checked={checked}
onChange={handleChange}
style={{ cursor: 'pointer', accentColor: 'var(--bg-secondary)' }}
/>
<label
htmlFor={`compress-${originalFile.name}-${originalFile.size}`}
style={{ cursor: 'pointer', color: 'var(--text-primary)', userSelect: 'none' }}
>
Compress before uploading
</label>
{compressing && (
<Text size="T200" style={{ color: 'var(--text-secondary)', marginLeft: '4px' }}>
estimating
</Text>
)}
</Box>
{checked && !compressing && result !== undefined && (
<Text
size="T200"
style={{
color: result
? savingPct !== null && savingPct > 0
? 'var(--tc-success-normal, #2e7d32)'
: 'var(--text-secondary)'
: 'var(--tc-danger-normal)',
paddingLeft: '20px',
}}
>
{result
? savingPct !== null && savingPct > 0
? `→ ~${formatFileSize(result.compressedSize)} (${savingPct}% smaller)`
: `${formatFileSize(result.compressedSize)} (no significant saving)`
: 'Compression not available for this file'}
</Text>
)}
{checked && !compressing && result === undefined && (
<Text size="T200" style={{ color: 'var(--text-secondary)', paddingLeft: '20px' }}>
Original: {formatFileSize(originalFile.size)}
</Text>
)}
</Box>
);
}
2023-06-12 21:15:23 +10:00
type UploadCardRendererProps = {
isEncrypted?: boolean;
fileItem: TUploadItem;
2025-02-26 21:43:43 +11:00
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
2023-06-12 21:15:23 +10:00
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
2023-06-12 21:15:23 +10:00
};
export function UploadCardRenderer({
isEncrypted,
fileItem,
setMetadata,
2023-06-12 21:15:23 +10:00
onRemove,
onComplete,
2023-06-12 21:15:23 +10:00
}: 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;
2023-06-12 21:15:23 +10:00
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
2023-06-12 21:15:23 +10:00
2025-02-26 21:43:43 +11:00
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
};
2023-06-12 21:15:23 +10:00
const removeUpload = () => {
cancelUpload();
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
2023-06-12 21:15:23 +10:00
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={
<>
2025-02-26 21:43:43 +11:00
{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>
2025-02-26 21:43:43 +11:00
)}
{(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 && (
2023-06-12 21:15:23 +10:00
<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>
)}
2023-06-12 21:15:23 +10:00
</>
}
>
<Text size="H6" truncate>
{(file as File).name}
2023-06-12 21:15:23 +10:00
</Text>
{upload.status === UploadStatus.Success && (
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
)}
</UploadCard>
);
}