diff --git a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx
index 8336ab399..24e72dbc9 100644
--- a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx
+++ b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx
@@ -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';
@@ -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), []);
@@ -94,7 +96,12 @@ export const MediaAttachmentPreview = ({
)}
- {isUploading &&
}
+ {isUploading && (
+
}
+ uploadProgress={uploadProgress}
+ />
+ )}
{isVideoAttachment(attachment) &&
!hasUploadError &&
diff --git a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx
index 88bde9223..0acd77d47 100644
--- a/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx
+++ b/src/components/MessageComposer/__tests__/AttachmentPreviewList.test.tsx
@@ -341,6 +341,57 @@ describe('AttachmentPreviewList', () => {
},
);
+ 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('circular-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('circular-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) =>
![]()
;
const { container } = await renderComponent({
diff --git a/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx b/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx
new file mode 100644
index 000000000..59ab50731
--- /dev/null
+++ b/src/components/MessageComposer/__tests__/AttachmentUploadedSizeIndicator.test.tsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { AttachmentUploadedSizeIndicator } from '../AttachmentPreviewList/AttachmentUploadedSizeIndicator';
+
+describe('AttachmentUploadedSizeIndicator', () => {
+ it('renders nothing when upload state is not uploading or finished', () => {
+ const { container } = render(
+
,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders nothing when uploading without uploadProgress', () => {
+ const { container } = render(
+
,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders nothing when uploading without a resolvable full byte size', () => {
+ const { container } = render(
+
,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders upload size fraction when uploading with numeric file_size and progress', () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(
+ '500 B / 1.00e+3 B',
+ );
+ expect(screen.getByTestId('upload-size-fraction')).toHaveClass(
+ 'str-chat__attachment-preview-file__upload-size-fraction',
+ );
+ });
+
+ it('parses string file_size for the upload fraction', () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent(
+ '500 B / 1.00e+3 B',
+ );
+ });
+
+ it('prefers localMetadata.file.size over file_size when both are present', () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId('upload-size-fraction')).toHaveTextContent('100 B / 200 B');
+ });
+
+ it('renders FileSizeIndicator when upload is finished', () => {
+ render(
+
,
+ );
+
+ expect(screen.getByTestId('file-size-indicator')).toHaveTextContent('1.00 kB');
+ });
+
+ it('renders nothing when finished but file_size is missing or invalid', () => {
+ const { container: missing } = render(
+
,
+ );
+ expect(missing.firstChild).toBeNull();
+
+ const { container: nanString } = render(
+
,
+ );
+ expect(nanString.firstChild).toBeNull();
+ });
+});
diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx
index 320ba6e85..7a6dd0444 100644
--- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx
+++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx
@@ -345,6 +345,16 @@ const setupUploadRejected = async (error: unknown) => {
return { customChannel, customClient, sendFileSpy, sendImageSpy };
};
+/** `channel.sendImage` / `channel.sendFile` pass upload options (e.g. `onUploadProgress`) after the file. */
+const expectChannelUploadCall = (spy, expectedFile) => {
+ expect(spy).toHaveBeenCalled();
+ const callArgs = spy.mock.calls[0];
+ expect(callArgs[0]).toBe(expectedFile);
+ expect(callArgs[callArgs.length - 1]).toEqual(
+ expect.objectContaining({ onUploadProgress: expect.any(Function) }),
+ );
+};
+
const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => {
const {
channels: [channel],
@@ -562,8 +572,8 @@ describe(`MessageInputFlat`, () => {
});
const filenameTexts = await screen.findAllByTitle(filename);
await waitFor(() => {
- expect(sendFileSpy).toHaveBeenCalledWith(file);
- expect(sendImageSpy).toHaveBeenCalledWith(image);
+ expectChannelUploadCall(sendFileSpy, file);
+ expectChannelUploadCall(sendImageSpy, image);
expect(screen.getByTestId(IMAGE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(FILE_PREVIEW_TEST_ID)).toBeInTheDocument();
filenameTexts.forEach((filenameText) => expect(filenameText).toBeInTheDocument());
@@ -634,7 +644,7 @@ describe(`MessageInputFlat`, () => {
dropFile(file, formElement);
});
await waitFor(() => {
- expect(sendImageSpy).toHaveBeenCalledWith(file);
+ expectChannelUploadCall(sendImageSpy, file);
});
const results = await axe(container);
expect(results).toHaveNoViolations();
diff --git a/src/components/MessageComposer/styling/AttachmentPreview.scss b/src/components/MessageComposer/styling/AttachmentPreview.scss
index 5ca9af316..efa461e75 100644
--- a/src/components/MessageComposer/styling/AttachmentPreview.scss
+++ b/src/components/MessageComposer/styling/AttachmentPreview.scss
@@ -210,14 +210,14 @@
);
}
- .str-chat__icon.str-chat__loading-indicator {
+ .str-chat__loading-indicator,
+ .str-chat__attachment-upload-progress {
width: var(--icon-size-sm);
height: var(--icon-size-sm);
position: absolute;
inset-inline-start: var(--spacing-xxs);
bottom: var(--spacing-xxs);
border-radius: var(--radius-max);
- border-radius: var(--radius-max);
background: var(--background-core-elevation-0);
color: var(--accent-primary);
}
@@ -283,9 +283,15 @@
font-size: var(--typography-font-size-xs);
line-height: var(--typography-line-height-tight);
- .str-chat__icon.str-chat__loading-indicator {
+ .str-chat__loading-indicator,
+ .str-chat__attachment-upload-progress {
width: var(--icon-size-sm);
height: var(--icon-size-sm);
+ color: var(--accent-primary);
+ }
+
+ .str-chat__attachment-preview-file__upload-size-fraction {
+ white-space: nowrap;
}
.str-chat__attachment-preview-file__fatal-error {
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index d2466c012..d1696fcc2 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -74,6 +74,7 @@ import type { VideoPlayerProps } from '../components/VideoPlayer';
import type { EditedMessagePreviewProps } from '../components/MessageComposer/EditedMessagePreview';
import type { FileIconProps } from '../components/FileIcon/FileIcon';
import type { CommandChipProps } from '../components/MessageComposer/CommandChip';
+import type { CircularProgressIndicatorProps } from '../components/Loading/CircularProgressIndicator';
export type ComponentContextValue = {
/** Custom UI component to display additional message composer action buttons left to the textarea, defaults to and accepts same props as: [AdditionalMessageComposerActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/MessageComposerActions.tsx) */
@@ -148,6 +149,8 @@ export type ComponentContextValue = {
LoadingErrorIndicator?: React.ComponentType
;
/** Custom UI component to render while the `MessageList` is loading new messages, defaults to and accepts same props as: [LoadingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingIndicator.tsx) */
LoadingIndicator?: React.ComponentType;
+ /** Custom UI component for determinate progress (0–100), defaults to and accepts same props as: [CircularProgressIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/CircularProgressIndicator.tsx) */
+ CircularProgressIndicator?: React.ComponentType;
/** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageUI](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageUI.tsx) */
Message?: React.ComponentType;
/** Custom UI component for message actions popup, accepts no props, all the defaults are set within [MessageActions (unstable)](https://github.com/GetStream/stream-chat-react/blob/master/src/experimental/MessageActions/MessageActions.tsx) */
diff --git a/src/i18n/de.json b/src/i18n/de.json
index b43c4eb31..c9ac9f639 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -83,6 +83,7 @@
"aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen",
"aria/Open Reaction Selector": "Reaktionsauswahl öffnen",
"aria/Open Thread": "Thread öffnen",
+ "aria/Percent complete": "{{percent}} Prozent abgeschlossen",
"aria/Pin Message": "Nachricht anheften",
"aria/Quote Message": "Nachricht zitieren",
"aria/Reaction list": "Reaktionsliste",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0cea529a0..e0a1f27e8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -83,6 +83,7 @@
"aria/Open Message Actions Menu": "Open Message Actions Menu",
"aria/Open Reaction Selector": "Open Reaction Selector",
"aria/Open Thread": "Open Thread",
+ "aria/Percent complete": "{{percent}} percent complete",
"aria/Pin Message": "Pin Message",
"aria/Quote Message": "Quote Message",
"aria/Reaction list": "Reaction list",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 88104736d..e5e5d4e38 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -91,6 +91,7 @@
"aria/Open Message Actions Menu": "Abrir menú de acciones de mensaje",
"aria/Open Reaction Selector": "Abrir selector de reacciones",
"aria/Open Thread": "Abrir hilo",
+ "aria/Percent complete": "{{percent}} por ciento completado",
"aria/Pin Message": "Fijar mensaje",
"aria/Quote Message": "Citar mensaje",
"aria/Reaction list": "Lista de reacciones",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index f63942d54..3c54f13c1 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -91,6 +91,7 @@
"aria/Open Message Actions Menu": "Ouvrir le menu des actions du message",
"aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions",
"aria/Open Thread": "Ouvrir le fil",
+ "aria/Percent complete": "{{percent}} pour cent terminé",
"aria/Pin Message": "Épingler le message",
"aria/Quote Message": "Citer le message",
"aria/Reaction list": "Liste des réactions",
diff --git a/src/i18n/hi.json b/src/i18n/hi.json
index ad2da9d82..aec01fb3d 100644
--- a/src/i18n/hi.json
+++ b/src/i18n/hi.json
@@ -83,6 +83,7 @@
"aria/Open Message Actions Menu": "संदेश क्रिया मेन्यू खोलें",
"aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें",
"aria/Open Thread": "थ्रेड खोलें",
+ "aria/Percent complete": "{{percent}} प्रतिशत पूर्ण",
"aria/Pin Message": "संदेश पिन करें",
"aria/Quote Message": "संदेश उद्धरण",
"aria/Reaction list": "प्रतिक्रिया सूची",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 9204470ce..a9d840731 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -91,6 +91,7 @@
"aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio",
"aria/Open Reaction Selector": "Apri il selettore di reazione",
"aria/Open Thread": "Apri discussione",
+ "aria/Percent complete": "{{percent}} percento completato",
"aria/Pin Message": "Appunta messaggio",
"aria/Quote Message": "Citazione messaggio",
"aria/Reaction list": "Elenco delle reazioni",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 063b2c0cc..525204ee7 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -82,6 +82,7 @@
"aria/Open Message Actions Menu": "メッセージアクションメニューを開く",
"aria/Open Reaction Selector": "リアクションセレクターを開く",
"aria/Open Thread": "スレッドを開く",
+ "aria/Percent complete": "{{percent}}パーセント完了",
"aria/Pin Message": "メッセージをピン",
"aria/Quote Message": "メッセージを引用",
"aria/Reaction list": "リアクション一覧",
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 069d865b8..e9d3e6357 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -82,6 +82,7 @@
"aria/Open Message Actions Menu": "메시지 액션 메뉴 열기",
"aria/Open Reaction Selector": "반응 선택기 열기",
"aria/Open Thread": "스레드 열기",
+ "aria/Percent complete": "{{percent}}퍼센트 완료",
"aria/Pin Message": "메시지 고정",
"aria/Quote Message": "메시지 인용",
"aria/Reaction list": "반응 목록",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index 1f64ceedf..77bd5dc26 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -83,6 +83,7 @@
"aria/Open Message Actions Menu": "Menu voor berichtacties openen",
"aria/Open Reaction Selector": "Reactiekiezer openen",
"aria/Open Thread": "Draad openen",
+ "aria/Percent complete": "{{percent}} procent voltooid",
"aria/Pin Message": "Bericht vastmaken",
"aria/Quote Message": "Bericht citeren",
"aria/Reaction list": "Reactielijst",
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 0d01d0609..663bca08a 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -91,6 +91,7 @@
"aria/Open Message Actions Menu": "Abrir menu de ações de mensagem",
"aria/Open Reaction Selector": "Abrir seletor de reações",
"aria/Open Thread": "Abrir tópico",
+ "aria/Percent complete": "{{percent}} por cento concluído",
"aria/Pin Message": "Fixar mensagem",
"aria/Quote Message": "Citar mensagem",
"aria/Reaction list": "Lista de reações",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 068ae9070..aa0b92f29 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -100,6 +100,7 @@
"aria/Open Message Actions Menu": "Открыть меню действий с сообщениями",
"aria/Open Reaction Selector": "Открыть селектор реакций",
"aria/Open Thread": "Открыть тему",
+ "aria/Percent complete": "{{percent}} процентов завершено",
"aria/Pin Message": "Закрепить сообщение",
"aria/Quote Message": "Цитировать сообщение",
"aria/Reaction list": "Список реакций",
diff --git a/src/i18n/tr.json b/src/i18n/tr.json
index 0576ebd10..c792c01d3 100644
--- a/src/i18n/tr.json
+++ b/src/i18n/tr.json
@@ -83,6 +83,7 @@
"aria/Open Message Actions Menu": "Mesaj İşlemleri Menüsünü Aç",
"aria/Open Reaction Selector": "Tepki Seçiciyi Aç",
"aria/Open Thread": "Konuyu Aç",
+ "aria/Percent complete": "Yüzde {{percent}} tamamlandı",
"aria/Pin Message": "Mesajı sabitle",
"aria/Quote Message": "Mesajı alıntıla",
"aria/Reaction list": "Tepki listesi",