diff --git a/package.json b/package.json index 119953e4f..72b6b4443 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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", diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index a8aca18af..3f27e937d 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -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'; @@ -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'; diff --git a/src/components/Attachment/FileAttachment.tsx b/src/components/Attachment/FileAttachment.tsx index fe2f8e19c..7baed26cd 100644 --- a/src/components/Attachment/FileAttachment.tsx +++ b/src/components/Attachment/FileAttachment.tsx @@ -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 (
{ + const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext(); const { canPlayRecord, durationSeconds, @@ -130,31 +135,32 @@ export const VoiceRecordingPlayer = ({ export type QuotedVoiceRecordingProps = Pick; -export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => ( - // const { t } = useTranslationContext(); - // const title = attachment.title || t('Voice message'); -
-
-
-
- {attachment.duration ? ( - - ) : ( - - )} +export const QuotedVoiceRecording = ({ attachment }: QuotedVoiceRecordingProps) => { + const { FileSizeIndicator = DefaultFileSizeIndicator } = useComponentContext(); + return ( +
+
+
+
+ {attachment.duration ? ( + + ) : ( + + )} +
+
- -
-); + ); +}; export type VoiceRecordingProps = { /** The attachment object from the message's attachment list. */ diff --git a/src/components/Attachment/components/FileSizeIndicator.tsx b/src/components/Attachment/components/FileSizeIndicator.tsx index c95291db3..40926c828 100644 --- a/src/components/Attachment/components/FileSizeIndicator.tsx +++ b/src/components/Attachment/components/FileSizeIndicator.tsx @@ -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; /** diff --git a/src/components/Loading/UploadProgressIndicator.tsx b/src/components/Loading/UploadProgressIndicator.tsx new file mode 100644 index 000000000..5596518fe --- /dev/null +++ b/src/components/Loading/UploadProgressIndicator.tsx @@ -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 ; + } + + return ; +}; diff --git a/src/components/Loading/UploadedSizeIndicator.tsx b/src/components/Loading/UploadedSizeIndicator.tsx new file mode 100644 index 000000000..6a7b3c4b6 --- /dev/null +++ b/src/components/Loading/UploadedSizeIndicator.tsx @@ -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 ( +
+ {` / `} + +
+ ); +}; diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts index 75b5c332f..e23c6a7c0 100644 --- a/src/components/Loading/index.ts +++ b/src/components/Loading/index.ts @@ -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'; diff --git a/src/components/Loading/progress-indicators.tsx b/src/components/Loading/progress-indicators.tsx new file mode 100644 index 000000000..ccc53db25 --- /dev/null +++ b/src/components/Loading/progress-indicators.tsx @@ -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 ( +
+ + + + +
+ ); +}; diff --git a/src/components/Loading/styling/ProgressIndicator.scss b/src/components/Loading/styling/ProgressIndicator.scss new file mode 100644 index 000000000..5cb6f1a28 --- /dev/null +++ b/src/components/Loading/styling/ProgressIndicator.scss @@ -0,0 +1,8 @@ +.str-chat__circular-progress-indicator { + width: 100%; + height: 100%; + + svg { + display: block; + } +} diff --git a/src/components/Loading/styling/index.scss b/src/components/Loading/styling/index.scss index 1a37d4328..e3b441c01 100644 --- a/src/components/Loading/styling/index.scss +++ b/src/components/Loading/styling/index.scss @@ -1,2 +1,3 @@ @use 'LoadingChannels'; @use 'LoadingIndicator'; +@use 'ProgressIndicator'; diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index 332ea6604..51aa62629 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -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 { @@ -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 { @@ -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; @@ -79,7 +81,11 @@ export const AudioRecorderRecordingControls = () => { size='sm' variant='primary' > - {isUploadingFile ? : } + {isUploadingFile ? ( + + ) : ( + + )}
); diff --git a/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx new file mode 100644 index 000000000..323be3cb7 --- /dev/null +++ b/src/components/MessageComposer/AttachmentPreviewList/AttachmentUploadedSizeIndicator.tsx @@ -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 ; + } + + if (uploadState === 'finished') { + return ; + } + + return null; +}; diff --git a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx index 4c2d94035..071ecfeca 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/AudioAttachmentPreview.tsx @@ -7,10 +7,9 @@ import { import { useTranslationContext } from '../../../context'; import React, { useEffect } from 'react'; import clsx from 'clsx'; -import { LoadingIndicatorIcon } from '../icons'; +import { UploadProgressIndicator } 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 { @@ -21,6 +20,7 @@ import { } from '../../AudioPlayback'; import { useAudioPlayer } from '../../AudioPlayback/WithAudioPlayback'; import { useStateStore } from '../../../store'; +import { AttachmentUploadedSizeIndicator } from './AttachmentUploadedSizeIndicator'; export type AudioAttachmentPreviewProps> = UploadAttachmentPreviewProps< @@ -42,7 +42,7 @@ export const AudioAttachmentPreview = ({ removeAttachments, }: AudioAttachmentPreviewProps) => { const { t } = useTranslationContext(); - const { id, previewUri, uploadPermissionCheck, uploadState } = + const { id, previewUri, uploadPermissionCheck, uploadProgress, uploadState } = attachment.localMetadata ?? {}; const url = attachment.asset_url || previewUri; @@ -93,11 +93,13 @@ export const AudioAttachmentPreview = ({ {isVoiceRecordingAttachment(attachment) ? t('Voice message') : attachment.title}
- {uploadState === 'uploading' && } + {uploadState === 'uploading' && ( + + )} {showProgressControls ? ( <> {!resolvedDuration && !progressPercent && !isPlaying && ( - + )} {hasWaveform ? ( <> diff --git a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx index 01103c593..225f12d5a 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { useTranslationContext } from '../../../context'; import { FileIcon } from '../../FileIcon'; -import { LoadingIndicatorIcon } from '../icons'; - +import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; +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, IconExclamationTriangleFill } from '../../Icons'; export type FileAttachmentPreviewProps = @@ -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 (
- {uploadState === 'uploading' && } - {!hasError && } + {uploadState === 'uploading' && ( + + )} + {hasFatalError && (
diff --git a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx index 8336ab399..923a955a0 100644 --- a/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx +++ b/src/components/MessageComposer/AttachmentPreviewList/MediaAttachmentPreview.tsx @@ -17,8 +17,8 @@ import clsx from 'clsx'; import { IconExclamationMark, IconRetry, IconVideoFill } from '../../Icons'; import { RemoveAttachmentPreviewButton } from '../RemoveAttachmentPreviewButton'; import { Button } from '../../Button'; +import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator'; import { AttachmentPreviewRoot } from './utils/AttachmentPreviewRoot'; -import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; export type MediaAttachmentPreviewProps> = UploadAttachmentPreviewProps< @@ -34,11 +34,11 @@ export const MediaAttachmentPreview = ({ removeAttachments, }: MediaAttachmentPreviewProps) => { const { t } = useTranslationContext(); - const { BaseImage = DefaultBaseImage, LoadingIndicator = DefaultLoadingIndicator } = - useComponentContext(); + const { BaseImage = DefaultBaseImage } = 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 +94,7 @@ export const MediaAttachmentPreview = ({ )}
- {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"