Files
cinny/src/app/components/upload-card/UploadCardRenderer.tsx
T
jared 8dc4c4d072
CI / Build & Quality Checks (push) Successful in 10m25s
CI / Trigger Desktop Build (push) Successful in 6s
fix(ui): resolve 29 native UI/UX inconsistencies from folds design audit
Fixes N1–N94 findings from LOTUS_BUGS.md audit pass. Key changes:

- ProfileDecoration: raw <button> → folds <Button> for save/remove; remove
  undefined --accent-cyan var
- UserRoomProfile: textarea border uses color.SurfaceVariant.ContainerLine
  and config tokens instead of undefined --border-interactive var
- LotusToastContainer: z-index raised from 9997 → 10001 so toasts appear
  above Night Light overlay (9998) and modals (9999)
- Message.tsx: DeliveryStatus replaces Unicode glyphs with Icon components;
  MessageQuickReactions returns null instead of <span />; forward menu item
  gets correct size="100" on after icon
- AudioContent: speed chip variant/radii now matches Play chip (Secondary/300)
- ReadReceiptAvatars: pill border/radius/padding → folds config tokens;
  remove dead receipt-pill-btn className
- EventReaders: Header size 600→500; close button gets radii="300";
  borderBottom shorthand → borderBottomWidth token; remove raw fontSize
- General.tsx: selected background/seasonal picker border uses
  color.Primary.Main instead of color.Critical.Main (error red)
- RoomInsights: SectionHeader drops textTransform/letterSpacing/opacity;
  chart borderRadius → config tokens; remove raw fontSize:9;
  warning banner → SequenceCard
- RoomProfile.tsx: formatting toolbar raw <button> → folds <Button>;
  topic read-mode renders formatted_body via sanitizeCustomHtml
- MsgTypeRenderers: location Open button Chip→Button; opacity:0.65→priority
- UploadCardRenderer: caption raw <input> → folds <Input>
- VoiceMessageRecorder: replace undefined --bg-surface-variant/--tc-*
  vars with color.* tokens; replace bare <audio controls> with
  IconButton play/pause toggle
- App.tsx: mention highlight uses WCAG 2.1 relative luminance (gamma
  linearization) instead of simplified approximation; border now rgba
  semi-transparent instead of same color as background
- RoomNavItem: Mute MenuItem icon moved to before prop
- SearchFilters: HasLink chip variant="Success" outlined to match filter bar
- RoomViewHeader: Server Notice chip radii Pill→300; fix jotai import order
- Fix ESLint import/order errors in DeviceVerificationSetup, RoomTopicViewer,
  MediaGallery, and RoomViewHeader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:46:19 -04:00

415 lines
13 KiB
TypeScript

import React, { ReactNode, useEffect, useRef, useState } from 'react';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Input,
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';
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 | 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);
// 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;
// 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 =
displayResult && displayResult.originalSize > 0
? Math.round(
((displayResult.originalSize - displayResult.compressedSize) /
displayResult.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}`,
}}
>
{/* 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>
{/* Size comparison — original always visible; compressed appears 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 && displayResult !== 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>
{displayResult ? (
<Text size="T200" style={{ fontWeight: 600 }}>
{formatFileSize(displayResult.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: React.ChangeEvent<HTMLInputElement>) =>
setMetadata(fileItem, { ...metadata, caption: e.target.value })
}
data-caption-input
variant="Secondary"
size="300"
radii="300"
style={{ marginTop: config.space.S200, width: '100%' }}
/>
)}
<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>
);
}