Skip to content

Commit b2f51d6

Browse files
committed
feat: track upload progress in attachment preview components
1 parent 7b5835e commit b2f51d6

22 files changed

Lines changed: 329 additions & 13 deletions
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 { IconExclamationMark, 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
<IconExclamationMark />

src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import clsx from 'clsx';
1717
import { IconExclamationMark, IconRetry, IconVideoFill } from '../../Icons';
1818
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
1919
import { Button } from '../../Button';
20+
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
2021
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
2122
import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';
23+
import { readUploadProgress } from './utils/uploadProgress';
2224

2325
export type MediaAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
2426
UploadAttachmentPreviewProps<
@@ -39,6 +41,7 @@ export const MediaAttachmentPreview = ({
3941
const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false);
4042

4143
const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
44+
const uploadProgress = readUploadProgress(attachment.localMetadata);
4245

4346
const isUploading = uploadState === 'uploading';
4447
const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []);
@@ -94,7 +97,13 @@ export const MediaAttachmentPreview = ({
9497
)}
9598

9699
<div className={clsx('str-chat__attachment-preview-media__overlay')}>
97-
{isUploading && <LoadingIndicator data-testid='loading-indicator' />}
100+
{isUploading && (
101+
<AttachmentUploadProgressIndicator
102+
fallback={<LoadingIndicator />}
103+
uploadProgress={uploadProgress}
104+
variant='overlay'
105+
/>
106+
)}
98107

99108
{isVideoAttachment(attachment) &&
100109
!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
@@ -341,6 +341,59 @@ describe('AttachmentPreviewList', () => {
341341
},
342342
);
343343

344+
describe('upload progress UI', () => {
345+
it('shows spinner while uploading when uploadProgress is omitted', async () => {
346+
await renderComponent({
347+
attachments: [
348+
{
349+
...generateFileAttachment({ title: 'f.pdf' }),
350+
localMetadata: { id: 'a1', uploadState: 'uploading' },
351+
},
352+
],
353+
});
354+
355+
expect(screen.getByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument();
356+
expect(
357+
screen.queryByTestId('attachment-upload-progress-ring'),
358+
).not.toBeInTheDocument();
359+
});
360+
361+
it('shows ring while uploading when uploadProgress is numeric', async () => {
362+
await renderComponent({
363+
attachments: [
364+
{
365+
...generateImageAttachment({ fallback: 'img.png' }),
366+
localMetadata: {
367+
id: 'a1',
368+
uploadProgress: 42,
369+
uploadState: 'uploading',
370+
},
371+
},
372+
],
373+
});
374+
375+
expect(screen.getByTestId('attachment-upload-progress-ring')).toBeInTheDocument();
376+
expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument();
377+
});
378+
379+
it('shows uploaded size fraction for file attachments when progress is tracked', async () => {
380+
await renderComponent({
381+
attachments: [
382+
{
383+
...generateFileAttachment({ file_size: 1000, title: 'sized.pdf' }),
384+
localMetadata: {
385+
id: 'a1',
386+
uploadProgress: 50,
387+
uploadState: 'uploading',
388+
},
389+
},
390+
],
391+
});
392+
393+
expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(/\s*\/\s*/);
394+
});
395+
});
396+
344397
it('should render custom BaseImage component', async () => {
345398
const BaseImage = (props) => <img {...props} data-testid={'custom-base-image'} />;
346399
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
@@ -345,6 +345,16 @@ const setupUploadRejected = async (error: unknown) => {
345345
return { customChannel, customClient, sendFileSpy, sendImageSpy };
346346
};
347347

348+
/** `channel.sendImage` / `channel.sendFile` pass upload options (e.g. `onUploadProgress`) after the file. */
349+
const expectChannelUploadCall = (spy, expectedFile) => {
350+
expect(spy).toHaveBeenCalled();
351+
const callArgs = spy.mock.calls[0];
352+
expect(callArgs[0]).toBe(expectedFile);
353+
expect(callArgs[callArgs.length - 1]).toEqual(
354+
expect.objectContaining({ onUploadProgress: expect.any(Function) }),
355+
);
356+
};
357+
348358
const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => {
349359
const {
350360
channels: [channel],
@@ -562,8 +572,8 @@ describe(`MessageInputFlat`, () => {
562572
});
563573
const filenameTexts = await screen.findAllByTitle(filename);
564574
await waitFor(() => {
565-
expect(sendFileSpy).toHaveBeenCalledWith(file);
566-
expect(sendImageSpy).toHaveBeenCalledWith(image);
575+
expectChannelUploadCall(sendFileSpy, file);
576+
expectChannelUploadCall(sendImageSpy, image);
567577
expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument();
568578
expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument();
569579
filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument());
@@ -634,7 +644,7 @@ describe(`MessageInputFlat`, () => {
634644
dropFile(file, formElement);
635645
});
636646
await waitFor(() => {
637-
expect(sendImageSpy).toHaveBeenCalledWith(file);
647+
expectChannelUploadCall(sendImageSpy, file);
638648
});
639649
const results = await axe(container);
640650
expect(results).toHaveNoViolations();

0 commit comments

Comments
 (0)