Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
58 changes: 58 additions & 0 deletions src/components/Loading/CircularProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';

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

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

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

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

return (
<div className='str-chat__circular-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>
);
};
31 changes: 31 additions & 0 deletions src/components/Loading/UploadProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import clsx from 'clsx';
import React from 'react';

import { useComponentContext } from '../../context';
import { CircularProgressIndicator as DefaultCircularProgressIndicator } from './CircularProgressIndicator';
import { LoadingIndicator as DefaultLoadingIndicator } from './LoadingIndicator';

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

export const UploadProgressIndicator = ({
className,
uploadProgress,
}: UploadProgressIndicatorProps) => {
const {
CircularProgressIndicator = DefaultCircularProgressIndicator,
LoadingIndicator = DefaultLoadingIndicator,
} = useComponentContext();

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

return (
<div className={clsx('str-chat__attachment-upload-progress', className)}>
<CircularProgressIndicator percent={uploadProgress} />
</div>
);
};
2 changes: 2 additions & 0 deletions src/components/Loading/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './LoadingChannel';
export * from './LoadingChannels';
export * from './LoadingErrorIndicator';
export * from './LoadingIndicator';
export * from './CircularProgressIndicator';
export * from './UploadProgressIndicator';
8 changes: 8 additions & 0 deletions src/components/Loading/styling/CircularProgressIndicator.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 'CircularProgressIndicator';
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { CheckSignIcon, LoadingIndicatorIcon } from '../../MessageComposer/icons';
import { CheckSignIcon } from '../../MessageComposer/icons';
import { IconDelete, IconPauseFill, IconVoice } from '../../Icons';
import React from 'react';
import {
useChatContext,
useComponentContext,
useMessageComposerContext,
useTranslationContext,
} from '../../../context';
import { isRecording } from './recordingStateIdentity';
import { Button } from '../../Button';
import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications';
import { UploadProgressIndicator as DefaultUploadProgressIndicator } from '../../Loading/UploadProgressIndicator';

const ToggleRecordingButton = () => {
const {
Expand All @@ -34,12 +36,15 @@ const ToggleRecordingButton = () => {
export const AudioRecorderRecordingControls = () => {
const { client } = useChatContext();
const { t } = useTranslationContext();
const { UploadProgressIndicator = DefaultUploadProgressIndicator } =
useComponentContext();
const {
recordingController: { completeRecording, recorder, recording, recordingState },
} = useMessageComposerContext();
const panel = useNotificationTarget();

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

if (!recorder) return null;

Expand Down Expand Up @@ -79,7 +84,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,74 @@
import React from 'react';
import { FileSizeIndicator } from '../../Attachment';
import { prettifyFileSize } from '../hooks/utils';

function safePrettifyFileSize(bytes: number, maximumFractionDigits?: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '';
if (bytes === 0) return '0 B';
return prettifyFileSize(bytes, maximumFractionDigits);
}

function formatUploadByteFraction(
uploadPercent: number,
fullBytes: number,
maximumFractionDigits?: number,
): string {
const uploaded = Math.round((uploadPercent / 100) * fullBytes);
return `${safePrettifyFileSize(uploaded, maximumFractionDigits)} / ${safePrettifyFileSize(fullBytes, maximumFractionDigits)}`;
}

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 { uploadProgress, uploadState } = attachment.localMetadata ?? {};
const fullBytes = resolveAttachmentFullByteSize(attachment);

if (
uploadState === 'uploading' &&
uploadProgress !== undefined &&
fullBytes !== undefined
) {
return (
<span
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be extracted into a React component as we have FileSizeIndicator?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could simplify it using FileSizeIndicator: 388416d

className='str-chat__attachment-preview-file__upload-size-fraction'
data-testid='upload-size-fraction'
>
{formatUploadByteFraction(uploadProgress, fullBytes)}
</span>
);
}

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

return null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import {
type LocalAudioAttachment,
type LocalVoiceRecordingAttachment,
} from 'stream-chat';
import { useTranslationContext } from '../../../context';
import { useComponentContext, useTranslationContext } from '../../../context';
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { LoadingIndicatorIcon } from '../icons';
import { UploadProgressIndicator as DefaultUploadProgressIndicator } from '../../Loading/UploadProgressIndicator';
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
import { FileSizeIndicator } from '../../Attachment';
import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons';
import { PlayButton } from '../../Button';
import {
Expand All @@ -21,6 +20,7 @@ import {
} from '../../AudioPlayback';
import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback';
import { useStateStore } from '../../../store';
import { AttachmentUploadedSizeIndicator as DefaultAttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator';

export type AudioAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
UploadAttachmentPreviewProps<
Expand All @@ -42,7 +42,11 @@ export const AudioAttachmentPreview = ({
removeAttachments,
}: AudioAttachmentPreviewProps) => {
const { t } = useTranslationContext();
const { id, previewUri, uploadPermissionCheck, uploadState } =
const {
AttachmentUploadedSizeIndicator = DefaultAttachmentUploadedSizeIndicator,
UploadProgressIndicator = DefaultUploadProgressIndicator,
} = useComponentContext();
const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } =
attachment.localMetadata ?? {};
const url = attachment.asset_url || previewUri;

Expand Down Expand Up @@ -93,11 +97,13 @@ export const AudioAttachmentPreview = ({
{isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
</div>
<div className='str-chat__attachment-preview-file__data'>
{uploadState === 'uploading' && <LoadingIndicatorIcon />}
{uploadState === 'uploading' && (
<UploadProgressIndicator uploadProgress={uploadProgress} />
)}
{showProgressControls ? (
<>
{!resolvedDuration && !progressPercent && !isPlaying && (
<FileSizeIndicator fileSize={attachment.file_size} />
<AttachmentUploadedSizeIndicator attachment={attachment} />
)}
{hasWaveform ? (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import React from 'react';
import { useTranslationContext } from '../../../context';
import { useComponentContext, useTranslationContext } from '../../../context';
import { FileIcon } from '../../FileIcon';
import { LoadingIndicatorIcon } from '../icons';

import { UploadProgressIndicator as DefaultUploadProgressIndicator } from '../../Loading/UploadProgressIndicator';
import { AttachmentUploadedSizeIndicator as DefaultAttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator';
import type { LocalAudioAttachment, LocalFileAttachment } from 'stream-chat';
import type { UploadAttachmentPreviewProps } from './types';
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
import { FileSizeIndicator } from '../../Attachment';
import { IconExclamationMark, IconExclamationTriangleFill } from '../../Icons';

export type FileAttachmentPreviewProps<CustomLocalMetadata = unknown> =
Expand All @@ -21,12 +20,16 @@ export const FileAttachmentPreview = ({
removeAttachments,
}: FileAttachmentPreviewProps) => {
const { t } = useTranslationContext('FilePreview');
const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
const {
AttachmentUploadedSizeIndicator = DefaultAttachmentUploadedSizeIndicator,
UploadProgressIndicator = DefaultUploadProgressIndicator,
} = useComponentContext();
const { id, uploadPermissionCheck, uploadProgress, uploadState } =
attachment.localMetadata ?? {};

const hasSizeLimitError = uploadPermissionCheck?.reason === 'size_limit';
const hasFatalError = uploadState === 'blocked' || hasSizeLimitError;
const hasRetriableError = uploadState === 'failed' && !!handleRetry;
const hasError = hasRetriableError || hasFatalError;

return (
<AttachmentPreviewRoot
Expand All @@ -43,8 +46,10 @@ export const FileAttachmentPreview = ({
{attachment.title}
</div>
<div className='str-chat__attachment-preview-file__data'>
{uploadState === 'uploading' && <LoadingIndicatorIcon />}
{!hasError && <FileSizeIndicator fileSize={attachment.file_size} />}
{uploadState === 'uploading' && (
<UploadProgressIndicator uploadProgress={uploadProgress} />
)}
<AttachmentUploadedSizeIndicator attachment={attachment} />
{hasFatalError && (
<div className='str-chat__attachment-preview-file__fatal-error'>
<IconExclamationMark />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import clsx from 'clsx';
import { IconExclamationMark, IconRetry, IconVideoFill } from '../../Icons';
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
import { Button } from '../../Button';
import { UploadProgressIndicator as DefaultUploadProgressIndicator } from '../../Loading/UploadProgressIndicator';
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';

export type MediaAttachmentPreviewProps<CustomLocalMetadata = Record<string, unknown>> =
UploadAttachmentPreviewProps<
Expand All @@ -34,11 +34,14 @@ export const MediaAttachmentPreview = ({
removeAttachments,
}: MediaAttachmentPreviewProps) => {
const { t } = useTranslationContext();
const { BaseImage = DefaultBaseImage, LoadingIndicator = DefaultLoadingIndicator } =
useComponentContext();
const {
BaseImage = DefaultBaseImage,
UploadProgressIndicator = DefaultUploadProgressIndicator,
} = useComponentContext();
const [thumbnailPreviewError, setThumbnailPreviewError] = useState(false);

const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
const { id, uploadPermissionCheck, uploadProgress, uploadState } =
attachment.localMetadata ?? {};

const isUploading = uploadState === 'uploading';
const handleThumbnailLoadError = useCallback(() => setThumbnailPreviewError(true), []);
Expand Down Expand Up @@ -94,7 +97,7 @@ export const MediaAttachmentPreview = ({
)}

<div className={clsx('str-chat__attachment-preview-media__overlay')}>
{isUploading && <LoadingIndicator data-testid='loading-indicator' />}
{isUploading && <UploadProgressIndicator uploadProgress={uploadProgress} />}

{isVideoAttachment(attachment) &&
!hasUploadError &&
Expand Down
Loading
Loading