- {isUploading &&
}
+ {isUploading &&
}
{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/hooks/utils.ts b/src/components/MessageComposer/hooks/utils.ts
index 2fc762258..95b210f36 100644
--- a/src/components/MessageComposer/hooks/utils.ts
+++ b/src/components/MessageComposer/hooks/utils.ts
@@ -1,9 +1,9 @@
export function prettifyFileSize(bytes: number, precision = 3) {
const units = ['B', 'kB', 'MB', 'GB'];
- const exponent = Math.min(
- Math.floor(Math.log(bytes) / Math.log(1024)),
- units.length - 1,
- );
+ const exponent =
+ bytes === 0
+ ? 0
+ : Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const mantissa = bytes / 1024 ** exponent;
const formattedMantissa =
precision === 0 ? Math.round(mantissa).toString() : mantissa.toPrecision(precision);
diff --git a/src/components/MessageComposer/icons.tsx b/src/components/MessageComposer/icons.tsx
index 94b8de703..cdbdcdcd0 100644
--- a/src/components/MessageComposer/icons.tsx
+++ b/src/components/MessageComposer/icons.tsx
@@ -1,40 +1,5 @@
-import React, { useMemo } from 'react';
-import { nanoid } from 'nanoid';
-
import { useTranslationContext } from '../../context/TranslationContext';
-export const LoadingIndicatorIcon = () => {
- const id = useMemo(() => nanoid(), []);
-
- return (
-
- );
-};
-
export const UploadIcon = () => {
const { t } = useTranslationContext('UploadIcon');
return (
diff --git a/src/components/MessageComposer/styling/AttachmentPreview.scss b/src/components/MessageComposer/styling/AttachmentPreview.scss
index 5ca9af316..5336cd781 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__progress-indicator {
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,11 @@
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__progress-indicator {
width: var(--icon-size-sm);
height: var(--icon-size-sm);
+ color: var(--accent-primary);
}
.str-chat__attachment-preview-file__fatal-error {
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index b61a3cfe5..eae76a199 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -73,7 +73,10 @@ import type { StopAIGenerationButtonProps } from '../components/MessageComposer/
import type { VideoPlayerProps } from '../components/VideoPlayer';
import type { EditedMessagePreviewProps } from '../components/MessageComposer/EditedMessagePreview';
import type { FileIconProps } from '../components/FileIcon/FileIcon';
+import type { FileSizeIndicatorProps } from '../components/Attachment/components/FileSizeIndicator';
import type { CommandChipProps } from '../components/MessageComposer/CommandChip';
+import type { ProgressIndicatorProps } from '../components/Loading/progress-indicators';
+import type { UploadedSizeIndicatorProps } from '../components/Loading/UploadedSizeIndicator';
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) */
@@ -124,6 +127,8 @@ export type ComponentContextValue = {
DateSeparator?: React.ComponentType
;
/** Custom UI component to display the contents on file drag-and-drop overlay, defaults to and accepts same props as: [FileDragAndDropContent](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/WithDragAndDropUpload.tsx) */
FileDragAndDropContent?: React.ComponentType;
+ /** Custom UI component to display a formatted file byte size (message attachments, upload previews), defaults to and accepts same props as: [FileSizeIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/components/FileSizeIndicator.tsx) */
+ FileSizeIndicator?: React.ComponentType;
/** Custom UI component to override default preview of edited message, defaults to and accepts same props as: [EditedMessagePreview](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageComposer/EditedMessagePreview.tsx) */
EditedMessagePreview?: React.ComponentType;
/** Custom UI component for rendering button with emoji picker in MessageComposer */
@@ -148,6 +153,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: [ProgressIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/progress-indicators.tsx) */
+ ProgressIndicator?: 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) */
@@ -267,6 +274,8 @@ export type ComponentContextValue = {
HeaderStartContent?: React.ComponentType;
/** Custom UI component that separates read messages from unread, defaults to and accepts same props as: [UnreadMessagesSeparator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/UnreadMessagesSeparator.tsx) */
UnreadMessagesSeparator?: React.ComponentType;
+ /** Custom UI component for uploaded vs total byte size during attachment upload (MessageComposer previews), defaults to and accepts same props as: [UploadedSizeIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/UploadedSizeIndicator.tsx) */
+ UploadedSizeIndicator?: React.ComponentType;
/** Component used to play video. If not provided, ReactPlayer is used as a default video player. */
VideoPlayer?: React.ComponentType;
/** Custom UI component to display a message in the `VirtualizedMessageList`, does not have a default implementation */
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",
diff --git a/yarn.lock b/yarn.lock
index a511f1a7b..6e124788a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7753,10 +7753,10 @@ stdin-discarder@^0.2.2:
resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be"
integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==
-stream-chat@^9.38.0:
- version "9.38.0"
- resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.38.0.tgz#5c13eb8bbc2fa4adb774687b0c9c51f129d1b458"
- integrity sha512-nyTFKHnhGfk1Op/xuZzPKzM9uNTy4TBma69+ApwGj/UtrK2pT6rSaU0Qy/oAqub+Bh7jR2/5vlV/8FWJ2BObFg==
+stream-chat@^9.41.0:
+ version "9.41.0"
+ resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.0.tgz#ad88d7919aaf1d3c35b4a431a8cd464cb640f146"
+ integrity sha512-Rgp3vULGKYxHZ/aCeundly6ngdBGttTPz+YknmWhbqvNlEhPB/RM61CpQPHgPyfkSm+osJT3tEV9fKd+I/S77g==
dependencies:
"@types/jsonwebtoken" "^9.0.8"
"@types/ws" "^8.5.14"