8dc4c4d072
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>
415 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|