Skip to content

Commit 50c8b11

Browse files
committed
feat: track upload progress in attachment preview components
1 parent 9877da5 commit 50c8b11

22 files changed

+327
-12
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import clsx from 'clsx';
2+
import React, { type ReactNode } from 'react';
3+
4+
import { useComponentContext } from '../../../context';
5+
import { UploadProgress as DefaultUploadProgress, LoadingIndicatorIcon } from '../icons';
6+
import { clampUploadPercent } from './utils/uploadProgress';
7+
8+
export type AttachmentUploadProgressVariant = 'inline' | 'overlay';
9+
10+
export type AttachmentUploadProgressIndicatorProps = {
11+
className?: string;
12+
/** Shown when `uploadProgress` is `undefined` (e.g. progress tracking disabled). */
13+
fallback?: ReactNode;
14+
uploadProgress?: number;
15+
variant: AttachmentUploadProgressVariant;
16+
};
17+
18+
export const AttachmentUploadProgressIndicator = ({
19+
className,
20+
fallback,
21+
uploadProgress,
22+
variant,
23+
}: AttachmentUploadProgressIndicatorProps) => {
24+
const { UploadProgress = DefaultUploadProgress } = useComponentContext(
25+
'AttachmentUploadProgressIndicator',
26+
);
27+
28+
if (uploadProgress === undefined) {
29+
return <>{fallback ?? <LoadingIndicatorIcon />}</>;
30+
}
31+
32+
const percent = Math.round(clampUploadPercent(uploadProgress));
33+
34+
return (
35+
<div
36+
className={clsx(
37+
'str-chat__attachment-upload-progress',
38+
`str-chat__attachment-upload-progress--${variant}`,
39+
className,
40+
)}
41+
>
42+
<UploadProgress percent={percent} />
43+
</div>
44+
);
45+
};

src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { useTranslationContext } from '../../../context';
88
import React, { useEffect } from 'react';
99
import clsx from 'clsx';
10-
import { LoadingIndicatorIcon } from '../icons';
10+
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
1111
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
1212
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
1313
import { FileSizeIndicator } from '../../Attachment';
@@ -21,6 +21,11 @@ import {
2121
} from '../../AudioPlayback';
2222
import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback';
2323
import { useStateStore } from '../../../store';
24+
import {
25+
formatUploadByteFraction,
26+
readUploadProgress,
27+
resolveAttachmentFullByteSize,
28+
} from './utils/uploadProgress';
2429

2530
export type AudioAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
2631
UploadAttachmentPreviewProps<
@@ -44,6 +49,12 @@ export const AudioAttachmentPreview = ({
4449
const { t } = useTranslationContext();
4550
const { id, previewUri, uploadPermissionCheck, uploadState } =
4651
attachment.localMetadata ?? {};
52+
const uploadProgress = readUploadProgress(attachment.localMetadata);
53+
const fullBytes = resolveAttachmentFullByteSize(attachment);
54+
const showUploadFraction =
55+
uploadState === 'uploading' &&
56+
uploadProgress !== undefined &&
57+
fullBytes !== undefined;
4758
const url = attachment.asset_url || previewUri;
4859

4960
const audioPlayer = useAudioPlayer({
@@ -93,11 +104,27 @@ export const AudioAttachmentPreview = ({
93104
{isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
94105
</div>
95106
<div className='str-chat__attachment-preview-file__data'>
96-
{uploadState === 'uploading' && <LoadingIndicatorIcon />}
107+
{uploadState === 'uploading' && (
108+
<AttachmentUploadProgressIndicator
109+
uploadProgress={uploadProgress}
110+
variant='inline'
111+
/>
112+
)}
97113
{showProgressControls ? (
98114
<>
99115
{!resolvedDuration && !progressPercent && !isPlaying && (
100-
<FileSizeIndicator fileSize={attachment.file_size} />
116+
<>
117+
{showUploadFraction ? (
118+
<span
119+
className='str-chat__attachment-preview-file__upload-size-fraction'
120+
data-testid='upload-size-fraction'
121+
>
122+
{formatUploadByteFraction(uploadProgress, fullBytes)}
123+
</span>
124+
) : (
125+
<FileSizeIndicator fileSize={attachment.file_size} />
126+
)}
127+
</>
101128
)}
102129
{hasWaveform ? (
103130
<>

src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import React from 'react';
22
import { useTranslationContext } from '../../../context';
33
import { FileIcon } from '../../FileIcon';
4-
import { LoadingIndicatorIcon } from '../icons';
5-
4+
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
65
import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat';
76
import type { UploadAttachmentPreviewProps } from './types';
87
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
98
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
9+
import {
10+
formatUploadByteFraction,
11+
readUploadProgress,
12+
resolveAttachmentFullByteSize,
13+
} from './utils/uploadProgress';
1014
import { FileSizeIndicator } from '../../Attachment';
1115
import { IconExclamationCircle, IconExclamationTriangle } from '../../Icons';
1216

@@ -22,6 +26,12 @@ export const FileAttachmentPreview = ({
2226
}: FileAttachmentPreviewProps) => {
2327
const { t } = useTranslationContext('FilePreview');
2428
const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
29+
const uploadProgress = readUploadProgress(attachment.localMetadata);
30+
const fullBytes = resolveAttachmentFullByteSize(attachment);
31+
const showUploadFraction =
32+
uploadState === 'uploading' &&
33+
uploadProgress !== undefined &&
34+
fullBytes !== undefined;
2535

2636
const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit';
2737
const hasFatalError = uploadState === 'blocked' || hasSizeLimitError;
@@ -43,8 +53,23 @@ export const FileAttachmentPreview = ({
4353
{attachment.title}
4454
</div>
4555
<div className='str-chat__attachment-preview-file__data'>
46-
{uploadState === 'uploading' && <LoadingIndicatorIcon />}
47-
{!hasError && <FileSizeIndicator fileSize={attachment.file_size} />}
56+
{uploadState === 'uploading' && (
57+
<AttachmentUploadProgressIndicator
58+
uploadProgress={uploadProgress}
59+
variant='inline'
60+
/>
61+
)}
62+
{!hasError && showUploadFraction && (
63+
<span
64+
className='str-chat__attachment-preview-file__upload-size-fraction'
65+
data-testid='upload-size-fraction'
66+
>
67+
{formatUploadByteFraction(uploadProgress, fullBytes)}
68+
</span>
69+
)}
70+
{!hasError && !showUploadFraction && (
71+
<FileSizeIndicator fileSize={attachment.file_size} />
72+
)}
4873
{hasFatalError && (
4974
<div className='str-chat__attachment-preview-file__fatal-error'>
5075
<IconExclamationCircle />

src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
} from '../../Icons';
2323
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
2424
import { Button } from '../../Button';
25+
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
2526
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
27+
import { readUploadProgress } from './utils/uploadProgress';
2628

2729
export type MediaAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
2830
UploadAttachmentPreviewProps<
@@ -43,6 +45,7 @@ export const MediaAttachmentPreview = ({
4345
const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false);
4446

4547
const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
48+
const uploadProgress = readUploadProgress(attachment.localMetadata);
4649

4750
const isUploading = uploadState === 'uploading';
4851
const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []);
@@ -98,7 +101,13 @@ export const MediaAttachmentPreview = ({
98101
)}
99102

100103
<div className={clsx('str-chat__attachment-preview-media__overlay')}>
101-
{isUploading && <LoadingIndicator />}
104+
{isUploading && (
105+
<AttachmentUploadProgressIndicator
106+
fallback={<LoadingIndicator />}
107+
uploadProgress={uploadProgress}
108+
variant='overlay'
109+
/>
110+
)}
102111

103112
{isVideoAttachment(attachment) &&
104113
!hasUploadError &&
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { prettifyFileSize } from '../../hooks/utils';
2+
3+
export function readUploadProgress(
4+
localMetadata: { uploadProgress?: unknown } | null | undefined,
5+
): number | undefined {
6+
if (!localMetadata) return undefined;
7+
const { uploadProgress } = localMetadata;
8+
if (uploadProgress === undefined) return undefined;
9+
if (typeof uploadProgress !== 'number' || !Number.isFinite(uploadProgress))
10+
return undefined;
11+
return uploadProgress;
12+
}
13+
14+
export function clampUploadPercent(value: number): number {
15+
if (!Number.isFinite(value)) return 0;
16+
return Math.min(100, Math.max(0, value));
17+
}
18+
19+
function safePrettifyFileSize(bytes: number, maximumFractionDigits?: number): string {
20+
if (!Number.isFinite(bytes) || bytes < 0) return '';
21+
if (bytes === 0) return '0 B';
22+
return prettifyFileSize(bytes, maximumFractionDigits);
23+
}
24+
25+
export function formatUploadByteFraction(
26+
uploadPercent: number,
27+
fullBytes: number,
28+
maximumFractionDigits?: number,
29+
): string {
30+
const clamped = clampUploadPercent(uploadPercent);
31+
const uploaded = Math.round((clamped / 100) * fullBytes);
32+
return `${safePrettifyFileSize(uploaded, maximumFractionDigits)} / ${safePrettifyFileSize(fullBytes, maximumFractionDigits)}`;
33+
}
34+
35+
export function resolveAttachmentFullByteSize(attachment: {
36+
file_size?: number | string;
37+
localMetadata?: { file?: { size?: unknown } } | null;
38+
}): number | undefined {
39+
const fromFile = attachment.localMetadata?.file?.size;
40+
if (typeof fromFile === 'number' && Number.isFinite(fromFile) && fromFile >= 0) {
41+
return fromFile;
42+
}
43+
const raw = attachment.file_size;
44+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
45+
if (typeof raw === 'string') {
46+
const n = parseFloat(raw);
47+
if (Number.isFinite(n) && n >= 0) return n;
48+
}
49+
return undefined;
50+
}

src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,59 @@ describe('AttachmentPreviewList', () => {
326326
},
327327
);
328328

329+
describe('upload progress UI', () => {
330+
it('shows spinner while uploading when uploadProgress is omitted', async () => {
331+
await renderComponent({
332+
attachments: [
333+
{
334+
...generateFileAttachment({ title: 'f.pdf' }),
335+
localMetadata: { id: 'a1', uploadState: 'uploading' },
336+
},
337+
],
338+
});
339+
340+
expect(screen.getByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument();
341+
expect(
342+
screen.queryByTestId('attachment-upload-progress-ring'),
343+
).not.toBeInTheDocument();
344+
});
345+
346+
it('shows ring while uploading when uploadProgress is numeric', async () => {
347+
await renderComponent({
348+
attachments: [
349+
{
350+
...generateImageAttachment({ fallback: 'img.png' }),
351+
localMetadata: {
352+
id: 'a1',
353+
uploadProgress: 42,
354+
uploadState: 'uploading',
355+
},
356+
},
357+
],
358+
});
359+
360+
expect(screen.getByTestId('attachment-upload-progress-ring')).toBeInTheDocument();
361+
expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument();
362+
});
363+
364+
it('shows uploaded size fraction for file attachments when progress is tracked', async () => {
365+
await renderComponent({
366+
attachments: [
367+
{
368+
...generateFileAttachment({ file_size: 1000, title: 'sized.pdf' }),
369+
localMetadata: {
370+
id: 'a1',
371+
uploadProgress: 50,
372+
uploadState: 'uploading',
373+
},
374+
},
375+
],
376+
});
377+
378+
expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(/\s*\/\s*/);
379+
});
380+
});
381+
329382
it('should render custom BaseImage component', async () => {
330383
const BaseImage = (props) => <img {...props} data-testid={'custom-base-image'} />;
331384
const { container } = await renderComponent({

src/components/MessageComposer/__tests__/MessageInput.test.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,16 @@ const setupUploadRejected = async (error: unknown) => {
316316
return { customChannel, customClient, sendFileSpy, sendImageSpy };
317317
};
318318

319+
/** `channel.sendImage` / `channel.sendFile` pass upload options (e.g. `onUploadProgress`) after the file. */
320+
const expectChannelUploadCall = (spy, expectedFile) => {
321+
expect(spy).toHaveBeenCalled();
322+
const callArgs = spy.mock.calls[0];
323+
expect(callArgs[0]).toBe(expectedFile);
324+
expect(callArgs[callArgs.length - 1]).toEqual(
325+
expect.objectContaining({ onUploadProgress: expect.any(Function) }),
326+
);
327+
};
328+
319329
const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => {
320330
const {
321331
channels: [channel],
@@ -527,8 +537,8 @@ describe(`MessageInputFlat`, () => {
527537
});
528538
const filenameTexts = await screen.findAllByTitle(filename);
529539
await waitFor(() => {
530-
expect(sendFileSpy).toHaveBeenCalledWith(file);
531-
expect(sendImageSpy).toHaveBeenCalledWith(image);
540+
expectChannelUploadCall(sendFileSpy, file);
541+
expectChannelUploadCall(sendImageSpy, image);
532542
expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument();
533543
expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument();
534544
filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument());
@@ -599,7 +609,7 @@ describe(`MessageInputFlat`, () => {
599609
dropFile(file, formElement);
600610
});
601611
await waitFor(() => {
602-
expect(sendImageSpy).toHaveBeenCalledWith(file);
612+
expectChannelUploadCall(sendImageSpy, file);
603613
});
604614
const results = await axe(container);
605615
expect(results).toHaveNoViolations();

0 commit comments

Comments
 (0)