Skip to content

Commit 8b13863

Browse files
authored
feat: track upload progress in attachment preview components (#3060)
### 🎯 Goal https://linear.app/stream/issue/REACT-925/upload-progress-tracking Depends on: https://github.com/GetStream/stream-chat-js/pull/1708/changes#diff-61c3f170c2f20982af303989006c8317adf3006784ed2b37513e1c50487353d0 Docs PR: GetStream/docs-content#1161 ### 🛠 Implementation details - New component: `CircularProgressIndicator` -> displays a circular progress indicator to track progress from 0 - 100% - New component: `UploadProgressIndicator` -> if upload progress is available, it displays `CircularProgressIndicator`, otherwise the `LoadingIndicator`. When is upload progress not available? - It's possible that axios can't retrieve upload progress info - If someone uses custom CDN uploads, they may not be able to/want to provide progress tracking - New component: `UploadedSizeIndicator`: displays: 5 MB / 24 MB - New component: `AttachmentUploadedSizeIndicator`: - During upload: 4 MB / 24 MB - After upload finished: 24 MB ### 🎨 UI Changes Implementing this design: https://www.figma.com/design/Us73erK1xFNcB5EH3hyq6Y/Chat-SDK-Design-System?node-id=3517-102932&t=fizGA6SsyGt3g08F-0 <img width="316" height="96" alt="Screenshot 2026-03-25 at 13 51 12" src="https://github.com/user-attachments/assets/12a41113-d391-44a3-84c2-a60721dbcdcf" /> <img width="110" height="83" alt="Screenshot 2026-03-25 at 16 01 36" src="https://github.com/user-attachments/assets/466a72a5-c090-48e7-b30f-ea37721a2063" />
1 parent dc16bb5 commit 8b13863

36 files changed

+476
-99
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
"emoji-mart": "^5.4.0",
111111
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
112112
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
113-
"stream-chat": "^9.38.0"
113+
"stream-chat": "^9.41.0"
114114
},
115115
"peerDependenciesMeta": {
116116
"@breezystack/lamejs": {
@@ -176,7 +176,7 @@
176176
"react-dom": "^19.0.0",
177177
"sass": "^1.97.2",
178178
"semantic-release": "^25.0.2",
179-
"stream-chat": "^9.38.0",
179+
"stream-chat": "^9.41.0",
180180
"typescript": "^5.4.5",
181181
"typescript-eslint": "^8.17.0",
182182
"vite": "^7.3.1",

src/components/Attachment/Audio.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import React from 'react';
22
import type { Attachment } from 'stream-chat';
33

4-
import { FileSizeIndicator } from './components';
4+
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';
55
import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer';
66
import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback';
77
import { useStateStore } from '../../store';
8-
import { useMessageContext } from '../../context';
8+
import { useComponentContext, useMessageContext } from '../../context';
99
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';
1010
import { PlayButton } from '../Button/PlayButton';
1111
import { FileIcon } from '../FileIcon';
@@ -17,6 +17,7 @@ type AudioAttachmentUIProps = {
1717

1818
// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
1919
const AudioAttachmentUI = ({ audioPlayer }: AudioAttachmentUIProps) => {
20+
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
2021
const dataTestId = 'audio-widget';
2122
const rootClassName = 'str-chat__message-attachment-audio-widget';
2223

src/components/Attachment/FileAttachment.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { useComponentContext } from '../../context/ComponentContext';
33
import { FileIcon } from '../FileIcon';
44
import type { Attachment } from 'stream-chat';
55

6-
import { FileSizeIndicator } from './components';
6+
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';
77

88
export type FileAttachmentProps = {
99
attachment: Attachment;
1010
};
1111

1212
export const FileAttachment = ({ attachment }: FileAttachmentProps) => {
13-
const { AttachmentFileIcon } = useComponentContext();
13+
const { AttachmentFileIcon, FileSizeIndicator = DefaultFileSizeIndicator } =
14+
useComponentContext();
1415
const FileIconComponent = AttachmentFileIcon ?? FileIcon;
1516
return (
1617
<div

src/components/Attachment/VoiceRecording.tsx

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import React from 'react';
22
import type { Attachment } from 'stream-chat';
33

4-
import { FileSizeIndicator } from './components';
4+
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';
55
import { FileIcon } from '../FileIcon';
6-
import { useMessageContext, useTranslationContext } from '../../context';
6+
import {
7+
useComponentContext,
8+
useMessageContext,
9+
useTranslationContext,
10+
} from '../../context';
711
import {
812
type AudioPlayer,
913
type AudioPlayerState,
@@ -32,6 +36,7 @@ type VoiceRecordingPlayerUIProps = {
3236

3337
// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
3438
const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {
39+
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
3540
const {
3641
canPlayRecord,
3742
durationSeconds,
@@ -130,31 +135,32 @@ export const VoiceRecordingPlayer = ({
130135

131136
export type QuotedVoiceRecordingProps = Pick<VoiceRecordingProps, 'attachment'>;
132137

133-
export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => (
134-
// const { t } = useTranslationContext();
135-
// const title = attachment.title || t('Voice message');
136-
<div className={rootClassName} data-testid='quoted-voice-recording-widget'>
137-
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
138-
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
139-
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
140-
{attachment.duration ? (
141-
<DurationDisplay
142-
duration={attachment.duration}
143-
isPlaying={false}
144-
secondsElapsed={undefined}
145-
/>
146-
) : (
147-
<FileSizeIndicator
148-
fileSize={attachment.file_size}
149-
maximumFractionDigits={0}
150-
/>
151-
)}
138+
export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => {
139+
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
140+
return (
141+
<div className={rootClassName} data-testid='quoted-voice-recording-widget'>
142+
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
143+
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
144+
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
145+
{attachment.duration ? (
146+
<DurationDisplay
147+
duration={attachment.duration}
148+
isPlaying={false}
149+
secondsElapsed={undefined}
150+
/>
151+
) : (
152+
<FileSizeIndicator
153+
fileSize={attachment.file_size}
154+
maximumFractionDigits={0}
155+
/>
156+
)}
157+
</div>
152158
</div>
153159
</div>
160+
<FileIcon mimeType={attachment.mime_type} />
154161
</div>
155-
<FileIcon mimeType={attachment.mime_type} />
156-
</div>
157-
);
162+
);
163+
};
158164

159165
export type VoiceRecordingProps = {
160166
/** The attachment object from the message's attachment list. */

src/components/Attachment/components/FileSizeIndicator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { prettifyFileSize } from '../../MessageComposer/hooks/utils';
33

4-
type FileSizeIndicatorProps = {
4+
export type FileSizeIndicatorProps = {
55
/** file size in byte */
66
fileSize?: number | string;
77
/**
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
3+
import { useComponentContext } from '../../context';
4+
import { CircularProgressIndicator as DefaultProgressIndicator } from './progress-indicators';
5+
import { LoadingIndicator as DefaultLoadingIndicator } from './LoadingIndicator';
6+
7+
export type UploadProgressIndicatorProps = {
8+
uploadProgress?: number;
9+
};
10+
11+
export const UploadProgressIndicator = ({
12+
uploadProgress,
13+
}: UploadProgressIndicatorProps) => {
14+
const {
15+
LoadingIndicator = DefaultLoadingIndicator,
16+
ProgressIndicator = DefaultProgressIndicator,
17+
} = useComponentContext();
18+
19+
if (uploadProgress === undefined) {
20+
return <LoadingIndicator data-testid='loading-indicator' />;
21+
}
22+
23+
return <ProgressIndicator percent={uploadProgress} />;
24+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
3+
import { useComponentContext } from '../../context';
4+
import { FileSizeIndicator as DefaultFileSizeIndicator } from '../Attachment/components/FileSizeIndicator';
5+
6+
export type UploadedSizeIndicatorProps = {
7+
fullBytes: number;
8+
uploadedBytes: number;
9+
};
10+
11+
export const UploadedSizeIndicator = ({
12+
fullBytes,
13+
uploadedBytes,
14+
}: UploadedSizeIndicatorProps) => {
15+
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
16+
return (
17+
<div
18+
className='str-chat__attachment-preview-file__upload-size-fraction'
19+
data-testid='upload-size-fraction'
20+
>
21+
<FileSizeIndicator fileSize={uploadedBytes} /> {` / `}
22+
<FileSizeIndicator fileSize={fullBytes} />
23+
</div>
24+
);
25+
};

src/components/Loading/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@ export * from './LoadingChannel';
22
export * from './LoadingChannels';
33
export * from './LoadingErrorIndicator';
44
export * from './LoadingIndicator';
5+
export * from './progress-indicators';
6+
export * from './UploadProgressIndicator';
7+
export * from './UploadedSizeIndicator';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from 'react';
2+
3+
import { useTranslationContext } from '../../context/TranslationContext';
4+
5+
export type ProgressIndicatorProps = {
6+
/** Clamped 0–100 completion. */
7+
percent: number;
8+
};
9+
10+
const RING_RADIUS = 12;
11+
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
12+
13+
/** Circular progress indicator with input from 0 to 100. */
14+
export const CircularProgressIndicator = ({ percent }: ProgressIndicatorProps) => {
15+
const { t } = useTranslationContext('CircularProgressIndicator');
16+
const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100);
17+
18+
return (
19+
<div className='str-chat__circular-progress-indicator str-chat__progress-indicator'>
20+
<svg
21+
aria-label={t('aria/Percent complete', { percent })}
22+
aria-valuemax={100}
23+
aria-valuemin={0}
24+
aria-valuenow={percent}
25+
data-testid='circular-progress-ring'
26+
height='100%'
27+
role='progressbar'
28+
viewBox='0 0 32 32'
29+
width='100%'
30+
xmlns='http://www.w3.org/2000/svg'
31+
>
32+
<circle
33+
cx='16'
34+
cy='16'
35+
fill='none'
36+
r={RING_RADIUS}
37+
stroke='currentColor'
38+
strokeOpacity={0.35}
39+
strokeWidth='2.5'
40+
/>
41+
<circle
42+
cx='16'
43+
cy='16'
44+
fill='none'
45+
r={RING_RADIUS}
46+
stroke='currentColor'
47+
strokeDasharray={RING_CIRCUMFERENCE}
48+
strokeDashoffset={dashOffset}
49+
strokeLinecap='round'
50+
strokeWidth='2.5'
51+
transform='rotate(-90 16 16)'
52+
/>
53+
</svg>
54+
</div>
55+
);
56+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.str-chat__circular-progress-indicator {
2+
width: 100%;
3+
height: 100%;
4+
5+
svg {
6+
display: block;
7+
}
8+
}

0 commit comments

Comments
 (0)