Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
acd2c4f
feat: track upload progress in attachment preview components
szuperaz Mar 25, 2026
b1a787c
refactor: uploadProgress renamed to Progress and moved outside of mes…
szuperaz Mar 26, 2026
0663ad0
refactor: remove unnecessary helpers
szuperaz Mar 27, 2026
7705055
refactor: create AttachmentUploadedSizeIndicator component
szuperaz Mar 27, 2026
2855009
refactor: more meaningful name for "x percent complete" i18n key
szuperaz Mar 27, 2026
4688774
chore: translation rebuilt
szuperaz Apr 1, 2026
5685c59
Apply suggestion from @MartinCupela
szuperaz Apr 7, 2026
aecaa9c
fix: remove unnecessary variant prop
szuperaz Apr 7, 2026
b2c347a
chore: small fixes
szuperaz Apr 7, 2026
d4f2f66
chore: fix failing tests
szuperaz Apr 7, 2026
b45192a
fix: css fixes
szuperaz Apr 7, 2026
77ba34c
refactor: rename ProgressIndicator to CircularProgressIndicator
szuperaz Apr 7, 2026
0a8d65d
feat: make AttachmentUploadProgressIndicator and AttachmentUploadProg…
szuperaz Apr 7, 2026
ff1e879
fix: use LoadingIndicator instead of LoadingIndicatorIcon
szuperaz Apr 8, 2026
e686dca
refactor: rename AttachmentUploadProgressIndicator to UploadProgressI…
szuperaz Apr 8, 2026
3a8d1fb
feat: update stream-chat-version
szuperaz Apr 8, 2026
02e973f
refactor: remove unnecessary formatUploadByteFraction method
szuperaz Apr 8, 2026
388416d
refactor: simplify AttachmentUploadedSizeIndicator
szuperaz Apr 8, 2026
52f063d
refactor: create UploadedSizeIndicator component
szuperaz Apr 8, 2026
d5a34c5
refactor: remove UploadProgressIndicator and AttachmentUploadedSizeIn…
szuperaz Apr 8, 2026
028f6f6
refactor: use ProgressIndicator in ComponentContext
szuperaz Apr 8, 2026
225fe94
refactor: remove unused className prop from UploadProgressIndicator
szuperaz Apr 8, 2026
4b51412
refactor: remover wrapper div from UploadProgressIndicator
szuperaz Apr 8, 2026
62cd53f
feat: add FileSizeIndicator to ComponentContext
szuperaz Apr 8, 2026
76a5a6c
chore: move prop definition to the top of the file
szuperaz Apr 8, 2026
34cabb6
Merge branch 'master' into file-upload-on-progress
szuperaz Apr 8, 2026
5e3dbee
fix: 0 byte results in NaN in FileSizeIndicator
szuperaz Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"emoji-mart": "^5.4.0",
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
"stream-chat": "^9.38.0"
"stream-chat": "^9.41.0"
},
"peerDependenciesMeta": {
"@breezystack/lamejs": {
Expand Down Expand Up @@ -176,7 +176,7 @@
"react-dom": "^19.0.0",
"sass": "^1.97.2",
"semantic-release": "^25.0.2",
"stream-chat": "^9.38.0",
"stream-chat": "^9.41.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.17.0",
"vite": "^7.3.1",
Expand Down
5 changes: 3 additions & 2 deletions src/components/Attachment/Audio.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from 'react';
import type { Attachment } from 'stream-chat';

import { FileSizeIndicator } from './components';
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';
import type { AudioPlayerState } from '../AudioPlayback/AudioPlayer';
import { useAudioPlayer } from '../AudioPlayback/WithAudioPlayback';
import { useStateStore } from '../../store';
import { useMessageContext } from '../../context';
import { useComponentContext, useMessageContext } from '../../context';
import type { AudioPlayer } from '../AudioPlayback/AudioPlayer';
import { PlayButton } from '../Button/PlayButton';
import { FileIcon } from '../FileIcon';
Expand All @@ -17,6 +17,7 @@ type AudioAttachmentUIProps = {

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

Expand Down
5 changes: 3 additions & 2 deletions src/components/Attachment/FileAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { useComponentContext } from '../../context/ComponentContext';
import { FileIcon } from '../FileIcon';
import type { Attachment } from 'stream-chat';

import { FileSizeIndicator } from './components';
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';

export type FileAttachmentProps = {
attachment: Attachment;
};

export const FileAttachment = ({ attachment }: FileAttachmentProps) => {
const { AttachmentFileIcon } = useComponentContext();
const { AttachmentFileIcon, FileSizeIndicator = DefaultFileSizeIndicator } =
useComponentContext();
const FileIconComponent = AttachmentFileIcon ?? FileIcon;
return (
<div
Expand Down
54 changes: 30 additions & 24 deletions src/components/Attachment/VoiceRecording.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import React from 'react';
import type { Attachment } from 'stream-chat';

import { FileSizeIndicator } from './components';
import { FileSizeIndicator as DefaultFileSizeIndicator } from './components';
import { FileIcon } from '../FileIcon';
import { useMessageContext, useTranslationContext } from '../../context';
import {
useComponentContext,
useMessageContext,
useTranslationContext,
} from '../../context';
import {
type AudioPlayer,
type AudioPlayerState,
Expand Down Expand Up @@ -32,6 +36,7 @@ type VoiceRecordingPlayerUIProps = {

// todo: finish creating a BaseAudioPlayer derived from VoiceRecordingPlayerUI and AudioAttachmentUI
const VoiceRecordingPlayerUI = ({ audioPlayer }: VoiceRecordingPlayerUIProps) => {
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
const {
canPlayRecord,
durationSeconds,
Expand Down Expand Up @@ -130,31 +135,32 @@ export const VoiceRecordingPlayer = ({

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

export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => (
// const { t } = useTranslationContext();
// const title = attachment.title || t('Voice message');
<div className={rootClassName} data-testid='quoted-voice-recording-widget'>
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
{attachment.duration ? (
<DurationDisplay
duration={attachment.duration}
isPlaying={false}
secondsElapsed={undefined}
/>
) : (
<FileSizeIndicator
fileSize={attachment.file_size}
maximumFractionDigits={0}
/>
)}
export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => {
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
return (
<div className={rootClassName} data-testid='quoted-voice-recording-widget'>
<div className='str-chat__message-attachment__voice-recording-widget__metadata'>
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
{attachment.duration ? (
<DurationDisplay
duration={attachment.duration}
isPlaying={false}
secondsElapsed={undefined}
/>
) : (
<FileSizeIndicator
fileSize={attachment.file_size}
maximumFractionDigits={0}
/>
)}
</div>
</div>
</div>
<FileIcon mimeType={attachment.mime_type} />
</div>
<FileIcon mimeType={attachment.mime_type} />
</div>
);
);
};

export type VoiceRecordingProps = {
/** The attachment object from the message's attachment list. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { prettifyFileSize } from '../../MessageComposer/hooks/utils';

type FileSizeIndicatorProps = {
export type FileSizeIndicatorProps = {
/** file size in byte */
fileSize?: number | string;
/**
Expand Down
24 changes: 24 additions & 0 deletions src/components/Loading/UploadProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';

import { useComponentContext } from '../../context';
import { CircularProgressIndicator as DefaultProgressIndicator } from './progress-indicators';
import { LoadingIndicator as DefaultLoadingIndicator } from './LoadingIndicator';

export type UploadProgressIndicatorProps = {
uploadProgress?: number;
};

export const UploadProgressIndicator = ({
uploadProgress,
}: UploadProgressIndicatorProps) => {
const {
LoadingIndicator = DefaultLoadingIndicator,
ProgressIndicator = DefaultProgressIndicator,
} = useComponentContext();

if (uploadProgress === undefined) {
return <LoadingIndicator data-testid='loading-indicator' />;
}

return <ProgressIndicator percent={uploadProgress} />;
};
25 changes: 25 additions & 0 deletions src/components/Loading/UploadedSizeIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import { useComponentContext } from '../../context';
import { FileSizeIndicator as DefaultFileSizeIndicator } from '../Attachment/components/FileSizeIndicator';

export type UploadedSizeIndicatorProps = {
fullBytes: number;
uploadedBytes: number;
};

export const UploadedSizeIndicator = ({
fullBytes,
uploadedBytes,
}: UploadedSizeIndicatorProps) => {
const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext();
return (
<div
className='str-chat__attachment-preview-file__upload-size-fraction'
data-testid='upload-size-fraction'
>
<FileSizeIndicator fileSize={uploadedBytes} /> {` / `}
<FileSizeIndicator fileSize={fullBytes} />
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/Loading/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ export * from './LoadingChannel';
export * from './LoadingChannels';
export * from './LoadingErrorIndicator';
export * from './LoadingIndicator';
export * from './progress-indicators';
export * from './UploadProgressIndicator';
export * from './UploadedSizeIndicator';
56 changes: 56 additions & 0 deletions src/components/Loading/progress-indicators.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';

import { useTranslationContext } from '../../context/TranslationContext';

export type ProgressIndicatorProps = {
/** Clamped 0–100 completion. */
percent: number;
};

const RING_RADIUS = 12;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;

/** Circular progress indicator with input from 0 to 100. */
export const CircularProgressIndicator = ({ percent }: ProgressIndicatorProps) => {
const { t } = useTranslationContext('CircularProgressIndicator');
const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100);

return (
<div className='str-chat__circular-progress-indicator str-chat__progress-indicator'>
<svg
aria-label={t('aria/Percent complete', { percent })}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={percent}
data-testid='circular-progress-ring'
height='100%'
role='progressbar'
viewBox='0 0 32 32'
width='100%'
xmlns='http://www.w3.org/2000/svg'
>
<circle
cx='16'
cy='16'
fill='none'
r={RING_RADIUS}
stroke='currentColor'
strokeOpacity={0.35}
strokeWidth='2.5'
/>
<circle
cx='16'
cy='16'
fill='none'
r={RING_RADIUS}
stroke='currentColor'
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={dashOffset}
strokeLinecap='round'
strokeWidth='2.5'
transform='rotate(-90 16 16)'
/>
</svg>
</div>
);
};
8 changes: 8 additions & 0 deletions src/components/Loading/styling/ProgressIndicator.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.str-chat__circular-progress-indicator {
width: 100%;
height: 100%;

svg {
display: block;
}
}
1 change: 1 addition & 0 deletions src/components/Loading/styling/index.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@use 'LoadingChannels';
@use 'LoadingIndicator';
@use 'ProgressIndicator';
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CheckSignIcon, LoadingIndicatorIcon } from '../../MessageComposer/icons';
import { CheckSignIcon } from '../../MessageComposer/icons';
import { IconDelete, IconPauseFill, IconVoice } from '../../Icons';
import React from 'react';
import {
Expand All @@ -9,6 +9,7 @@ import {
import { isRecording } from './recordingStateIdentity';
import { Button } from '../../Button';
import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications';
import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator';

const ToggleRecordingButton = () => {
const {
Expand Down Expand Up @@ -40,6 +41,7 @@ export const AudioRecorderRecordingControls = () => {
const panel = useNotificationTarget();

const isUploadingFile = recording?.localMetadata?.uploadState === 'uploading';
const uploadProgress = recording?.localMetadata?.uploadProgress;

if (!recorder) return null;

Expand Down Expand Up @@ -79,7 +81,11 @@ export const AudioRecorderRecordingControls = () => {
size='sm'
variant='primary'
>
{isUploadingFile ? <LoadingIndicatorIcon /> : <CheckSignIcon />}
{isUploadingFile ? (
<UploadProgressIndicator uploadProgress={uploadProgress} />
) : (
<CheckSignIcon />
)}
</Button>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';

import { useComponentContext } from '../../../context';
import { FileSizeIndicator as DefaultFileSizeIndicator } from '../../Attachment/components/FileSizeIndicator';
import { UploadedSizeIndicator as DefaultUploadedSizeIndicator } from '../../Loading/UploadedSizeIndicator';

function resolveAttachmentFullByteSize(attachment: {
file_size?: number | string;
localMetadata?: { file?: { size?: unknown } } | null;
}): number | undefined {
const fromFile = attachment.localMetadata?.file?.size;
if (typeof fromFile === 'number' && Number.isFinite(fromFile) && fromFile >= 0) {
return fromFile;
}
const raw = attachment.file_size;
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
if (typeof raw === 'string') {
const n = parseFloat(raw);
if (Number.isFinite(n) && n >= 0) return n;
}
return undefined;
}

export type AttachmentUploadedSizeIndicatorProps = {
attachment: {
file_size?: number | string;
localMetadata?: {
file?: { size?: unknown };
uploadProgress?: number;
uploadState?: string;
} | null;
};
};

export const AttachmentUploadedSizeIndicator = ({
attachment,
}: AttachmentUploadedSizeIndicatorProps) => {
const {
FileSizeIndicator = DefaultFileSizeIndicator,
UploadedSizeIndicator = DefaultUploadedSizeIndicator,
} = useComponentContext();
const { uploadProgress, uploadState } = attachment.localMetadata ?? {};
const fullBytes = resolveAttachmentFullByteSize(attachment);
const uploaded =
uploadProgress !== undefined && fullBytes !== undefined
? Math.round((uploadProgress / 100) * fullBytes)
: undefined;

if (uploadState === 'uploading' && uploaded !== undefined && fullBytes !== undefined) {
return <UploadedSizeIndicator fullBytes={fullBytes} uploadedBytes={uploaded} />;
}

if (uploadState === 'finished') {
return <FileSizeIndicator fileSize={attachment.file_size} />;
}

return null;
};
Loading
Loading