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

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

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

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

/** Circular progress indicator with input from 0 to 100. */
export const ProgressIndicator = ({ percent }: ProgressIndicatorProps) => {
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.

Suggested change
export const ProgressIndicator = ({ percent }: ProgressIndicatorProps) => {
export const CircularProgressIndicator = ({ percent }: ProgressIndicatorProps) => {

In case we added LinearProgressIndicator or any other type of progress indicator.

const { t } = useTranslationContext('ProgressIndicator');
const dashOffset = RING_CIRCUMFERENCE * (1 - percent / 100);

return (
<div className='str-chat__progress-indicator'>
<svg
aria-label={t('aria/Percent complete', { percent })}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={percent}
data-testid='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>
);
};
1 change: 1 addition & 0 deletions src/components/Loading/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './LoadingChannel';
export * from './LoadingChannels';
export * from './LoadingErrorIndicator';
export * from './LoadingIndicator';
export * from './ProgressIndicator';
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__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
@@ -0,0 +1,43 @@
import clsx from 'clsx';
import React, { type ReactNode } from 'react';

import { ProgressIndicator as DefaultProgressIndicator } from '../../Loading';
import { useComponentContext } from '../../../context';
import { LoadingIndicatorIcon } from '../icons';

export type AttachmentUploadProgressVariant = 'inline' | 'overlay';

export type AttachmentUploadProgressIndicatorProps = {
className?: string;
/** Shown when `uploadProgress` is `undefined` (e.g. progress tracking disabled). */
fallback?: ReactNode;
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.

Could this be made ComponentType instead of ReactNode? I am thinking that it provides more flexibility to render the component in place, where it is returned, instead somewhere up in the tree.

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.

Actually this prop can be removed if the LoadingIndicator is consumed from the context

uploadProgress?: number;
variant: AttachmentUploadProgressVariant;
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.

We may need to document what inline and overlay variants mean.

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.

I think, these names were just derived by AI from the specific way it used them - on overlay and somewhere in text. I see in the CSS that overlay styles icons and inline styles text. But if the designer decided to have the x MB / y MB indicator on the overlay, then we would have the inline variant on the overlay :).

I would be also thinking about what other possible progress indicators are being used generally in other apps, so that more generic names can be applied for variant.

};

export const AttachmentUploadProgressIndicator = ({
className,
fallback,
uploadProgress,
variant,
}: AttachmentUploadProgressIndicatorProps) => {
const { ProgressIndicator = DefaultProgressIndicator } = useComponentContext(
'AttachmentUploadProgressIndicator',
);
Comment on lines +24 to +26
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.

Suggested change
const { ProgressIndicator = DefaultProgressIndicator } = useComponentContext(
'AttachmentUploadProgressIndicator',
);
const { ProgressIndicator = DefaultProgressIndicator } = useComponentContext();

No need to add the hook arg.


if (uploadProgress === undefined) {
return <>{fallback ?? <LoadingIndicatorIcon />}</>;
}

return (
<div
className={clsx(
'str-chat__attachment-upload-progress',
`str-chat__attachment-upload-progress--${variant}`,
className,
)}
>
<ProgressIndicator percent={uploadProgress} />
</div>
);
};
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
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 @@ -7,10 +7,10 @@
import { useTranslationContext } from '../../../context';
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { LoadingIndicatorIcon } from '../icons';
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
import { FileSizeIndicator } from '../../Attachment';

Check warning on line 13 in src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

'FileSizeIndicator' is defined but never used
import { IconExclamationMark, IconExclamationTriangle } from '../../Icons';
import { PlayButton } from '../../Button';
import {
Expand All @@ -21,6 +21,7 @@
} from '../../AudioPlayback';
import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback';
import { useStateStore } from '../../../store';
import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator';

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

Expand Down Expand Up @@ -93,11 +94,16 @@
{isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
</div>
<div className='str-chat__attachment-preview-file__data'>
{uploadState === 'uploading' && <LoadingIndicatorIcon />}
{uploadState === 'uploading' && (
<AttachmentUploadProgressIndicator
Copy link
Copy Markdown
Contributor

@MartinCupela MartinCupela Apr 6, 2026

Choose a reason for hiding this comment

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

We could introduce UploadIndicator into component context or into the props of this component.

uploadProgress={uploadProgress}
variant='inline'
/>
)}
{showProgressControls ? (
<>
{!resolvedDuration && !progressPercent && !isPlaying && (
<FileSizeIndicator fileSize={attachment.file_size} />
<AttachmentUploadedSizeIndicator attachment={attachment} />
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.

We could introduce FileSizeIndicator into ComponentContext and the default one could be handling what AttachmentUploadedSizeIndicator handles.

)}
{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 { FileIcon } from '../../FileIcon';
import { LoadingIndicatorIcon } from '../icons';

import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
import { AttachmentUploadedSizeIndicator } 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, IconExclamationTriangle } from '../../Icons';

export type FileAttachmentPreviewProps<CustomLocalMetadata = unknown> =
Expand All @@ -21,12 +20,12 @@ export const FileAttachmentPreview = ({
removeAttachments,
}: FileAttachmentPreviewProps) => {
const { t } = useTranslationContext('FilePreview');
const { id, uploadPermissionCheck, uploadState } = attachment.localMetadata ?? {};
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 +42,13 @@ 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' && (
<AttachmentUploadProgressIndicator
uploadProgress={uploadProgress}
variant='inline'
/>
)}
<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,6 +17,7 @@ import clsx from 'clsx';
import { IconExclamationMark, IconRetry, IconVideoFill } from '../../Icons';
import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton';
import { Button } from '../../Button';
import { AttachmentUploadProgressIndicator } from './AttachmentUploadProgressIndicator';
import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot';
import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';

Expand All @@ -38,7 +39,8 @@ export const MediaAttachmentPreview = ({
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 +96,13 @@ export const MediaAttachmentPreview = ({
)}

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

{isVideoAttachment(attachment) &&
!hasUploadError &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@
attachments: [localAttachment],
});

expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument();

Check failure on line 261 in src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx

View workflow job for this annotation

GitHub Actions / Test

src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx > AttachmentPreviewList > video attachments rendering > renders loading indicator in preview

Error: expect(received).toBeInTheDocument() received value must be an HTMLElement or an SVGElement. Received has type: Null Received has value: null ❯ src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx:261:65

Check failure on line 261 in src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx

View workflow job for this annotation

GitHub Actions / Test

src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx > AttachmentPreviewList > image attachments rendering > renders loading indicator in preview

Error: expect(received).toBeInTheDocument() received value must be an HTMLElement or an SVGElement. Received has type: Null Received has value: null ❯ src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx:261:65
expect(
screen.queryByTestId(ATTACHMENT_PREVIEW_TEST_IDS[type].retry),
).not.toBeInTheDocument();
Expand Down Expand Up @@ -341,6 +341,57 @@
},
);

describe('upload progress UI', () => {
it('shows spinner while uploading when uploadProgress is omitted', async () => {
await renderComponent({
attachments: [
{
...generateFileAttachment({ title: 'f.pdf' }),
localMetadata: { id: 'a1', uploadState: 'uploading' },
},
],
});

expect(screen.getByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId('progress-ring')).not.toBeInTheDocument();
});

it('shows ring while uploading when uploadProgress is numeric', async () => {
await renderComponent({
attachments: [
{
...generateImageAttachment({ fallback: 'img.png' }),
localMetadata: {
id: 'a1',
uploadProgress: 42,
uploadState: 'uploading',
},
},
],
});

expect(screen.getByTestId('progress-ring')).toBeInTheDocument();
expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument();
});

it('shows uploaded size fraction for file attachments when progress is tracked', async () => {
await renderComponent({
attachments: [
{
...generateFileAttachment({ file_size: 1000, title: 'sized.pdf' }),
localMetadata: {
id: 'a1',
uploadProgress: 50,
uploadState: 'uploading',
},
},
],
});

expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(/\s*\/\s*/);
});
});

it('should render custom BaseImage component', async () => {
const BaseImage = (props) => <img {...props} data-testid={'custom-base-image'} />;
const { container } = await renderComponent({
Expand All @@ -352,7 +403,7 @@
),
components: { BaseImage },
});
expect(container).toMatchSnapshot();

Check failure on line 406 in src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx

View workflow job for this annotation

GitHub Actions / Test

src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx > AttachmentPreviewList > should render custom BaseImage component

Error: Snapshot `AttachmentPreviewList > should render custom BaseImage component 1` mismatched - Expected + Received @@ -28,11 +28,10 @@ <div class="str-chat__attachment-preview-media__overlay" > <svg class="str-chat__icon str-chat__icon--loading str-chat__loading-indicator" - data-testid="loading-indicator" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" > <path d="M17.5 10C17.5 14.1422 14.1422 17.5 10 17.5C5.85787 17.5 2.5 14.1422 2.5 10C2.5 5.85787 5.85787 2.5 10 2.5C14.1422 2.5 17.5 5.85787 17.5 10Z" @@ -96,11 +95,10 @@ <div class="str-chat__attachment-preview-media__overlay" > <svg class="str-chat__icon str-chat__icon--loading str-chat__loading-indicator" - data-testid="loading-indicator" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" > <path d="M17.5 10C17.5 14.1422 14.1422 17.5 10 17.5C5.85787 17.5 2.5 14.1422 2.5 10C2.5 5.85787 5.85787 2.5 10 2.5C14.1422 2.5 17.5 5.85787 17.5 10Z" ❯ src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx:406:23
});

it('opens the gallery preview for image attachments', async () => {
Expand Down
Loading
Loading