From 20713f0f1ed4bd3681c03a2601f1fc2c13fc5748 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 18 Jun 2025 12:34:56 +0530 Subject: [PATCH 1/4] feat: restructure images and files upload preview --- package/src/components/Channel/Channel.tsx | 9 +- .../useCreateInputMessageInputContext.ts | 6 +- ...ew.tsx => AttachmentUploadPreviewList.tsx} | 168 +++++++----- .../MessageInput/ImageUploadPreview.tsx | 97 ------- .../components/MessageInput/MessageInput.tsx | 38 +-- ...js => AttachmentUploadPreviewList.test.js} | 215 ++++++++++++++- .../AudioAttachmentUploadPreview.test.js | 4 +- .../__tests__/ImageUploadPreview.test.js | 248 ------------------ package/src/components/index.ts | 3 +- .../MessageInputContext.tsx | 21 +- .../hooks/useCreateMessageInputContext.ts | 6 +- .../src/contexts/themeContext/utils/theme.ts | 10 + 12 files changed, 350 insertions(+), 475 deletions(-) rename package/src/components/MessageInput/{FileUploadPreview.tsx => AttachmentUploadPreviewList.tsx} (65%) delete mode 100644 package/src/components/MessageInput/ImageUploadPreview.tsx rename package/src/components/MessageInput/__tests__/{FileUploadPreview.test.js => AttachmentUploadPreviewList.test.js} (53%) delete mode 100644 package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index e202b6b7b0..64ce61b7d8 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -161,6 +161,7 @@ import { ReactionListBottom as ReactionListBottomDefault } from '../Message/Mess import { ReactionListTop as ReactionListTopDefault } from '../Message/MessageSimple/ReactionList/ReactionListTop'; import { StreamingMessageView as DefaultStreamingMessageView } from '../Message/MessageSimple/StreamingMessageView'; import { AttachButton as AttachButtonDefault } from '../MessageInput/AttachButton'; +import { AttachmentUploadPreviewList as AttachmentUploadPreviewDefault } from '../MessageInput/AttachmentUploadPreviewList'; import { CommandsButton as CommandsButtonDefault } from '../MessageInput/CommandsButton'; import { AttachmentUploadProgressIndicator as AttachmentUploadProgressIndicatorDefault } from '../MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; import { AudioAttachmentUploadPreview as AudioAttachmentUploadPreviewDefault } from '../MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; @@ -176,8 +177,6 @@ import { CommandInput as CommandInputDefault } from '../MessageInput/components/ import { InputEditingStateHeader as InputEditingStateHeaderDefault } from '../MessageInput/components/InputEditingStateHeader'; import { InputReplyStateHeader as InputReplyStateHeaderDefault } from '../MessageInput/components/InputReplyStateHeader'; import { CooldownTimer as CooldownTimerDefault } from '../MessageInput/CooldownTimer'; -import { FileUploadPreview as FileUploadPreviewDefault } from '../MessageInput/FileUploadPreview'; -import { ImageUploadPreview as ImageUploadPreviewDefault } from '../MessageInput/ImageUploadPreview'; import { InputButtons as InputButtonsDefault } from '../MessageInput/InputButtons'; import { MoreOptionsButton as MoreOptionsButtonDefault } from '../MessageInput/MoreOptionsButton'; import { SendButton as SendButtonDefault } from '../MessageInput/SendButton'; @@ -526,6 +525,7 @@ const ChannelWithContext = (props: PropsWithChildren) = AttachmentPickerError = DefaultAttachmentPickerError, AttachmentPickerErrorImage = DefaultAttachmentPickerErrorImage, AttachmentPickerIOSSelectMorePhotos = DefaultAttachmentPickerIOSSelectMorePhotos, + AttachmentUploadPreviewList = AttachmentUploadPreviewDefault, ImageOverlaySelectedComponent = DefaultImageOverlaySelectedComponent, attachmentPickerErrorButtonText, attachmentPickerErrorText, @@ -567,7 +567,6 @@ const ChannelWithContext = (props: PropsWithChildren) = FileAttachmentUploadPreview = FileAttachmentUploadPreviewDefault, FileAttachmentGroup = FileAttachmentGroupDefault, FileAttachmentIcon = FileIconDefault, - FileUploadPreview = FileUploadPreviewDefault, FlatList = NativeHandlers.FlatList, forceAlignMessages, Gallery = GalleryDefault, @@ -600,7 +599,6 @@ const ChannelWithContext = (props: PropsWithChildren) = ImageLoadingFailedIndicator = ImageLoadingFailedIndicatorDefault, ImageLoadingIndicator = ImageLoadingIndicatorDefault, ImageReloadIndicator = ImageReloadIndicatorDefault, - ImageUploadPreview = ImageUploadPreviewDefault, initialScrollToFirstUnreadMessage = false, InlineDateSeparator = InlineDateSeparatorDefault, InlineUnreadIndicator = InlineUnreadIndicatorDefault, @@ -1742,6 +1740,7 @@ const ChannelWithContext = (props: PropsWithChildren) = attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, @@ -1765,7 +1764,6 @@ const ChannelWithContext = (props: PropsWithChildren) = editMessage, FileAttachmentUploadPreview, FileSelectorIcon, - FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands: hasCommands ?? !!clientChannelConfig?.commands?.length, @@ -1773,7 +1771,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hasImagePicker, ImageAttachmentUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, InputButtons, InputEditingStateHeader, diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index 751e3856b1..dd01ad3fd1 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -14,6 +14,7 @@ export const useCreateInputMessageInputContext = ({ attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, @@ -37,7 +38,6 @@ export const useCreateInputMessageInputContext = ({ editMessage, FileAttachmentUploadPreview, FileSelectorIcon, - FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands, @@ -45,7 +45,6 @@ export const useCreateInputMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, InputButtons, InputEditingStateHeader, @@ -82,6 +81,7 @@ export const useCreateInputMessageInputContext = ({ attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, @@ -104,7 +104,6 @@ export const useCreateInputMessageInputContext = ({ editMessage, FileAttachmentUploadPreview, FileSelectorIcon, - FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands, @@ -112,7 +111,6 @@ export const useCreateInputMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, InputButtons, InputEditingStateHeader, diff --git a/package/src/components/MessageInput/FileUploadPreview.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx similarity index 65% rename from package/src/components/MessageInput/FileUploadPreview.tsx rename to package/src/components/MessageInput/AttachmentUploadPreviewList.tsx index 0b9737bdac..7c0d896503 100644 --- a/package/src/components/MessageInput/FileUploadPreview.tsx +++ b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx @@ -1,19 +1,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FlatList, LayoutChangeEvent, StyleSheet } from 'react-native'; +import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { isAudioAttachment, isLocalAudioAttachment, isLocalFileAttachment, isLocalImageAttachment, - isLocalVideoAttachment, isLocalVoiceRecordingAttachment, isVideoAttachment, isVoiceRecordingAttachment, - LocalAudioAttachment, - LocalFileAttachment, - LocalVideoAttachment, - LocalVoiceRecordingAttachment, + LocalAttachment, + LocalImageAttachment, } from 'stream-chat'; import { useMessageComposer } from '../../contexts'; @@ -26,45 +23,50 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../native'; import { AudioConfig } from '../../types/types'; +const IMAGE_PREVIEW_SIZE = 100; const FILE_PREVIEW_HEIGHT = 60; -export type FileUploadPreviewPropsWithContext = Pick< +export type AttachmentUploadPreviewListPropsWithContext = Pick< MessageInputContextValue, - 'AudioAttachmentUploadPreview' | 'FileAttachmentUploadPreview' | 'VideoAttachmentUploadPreview' + | 'AudioAttachmentUploadPreview' + | 'FileAttachmentUploadPreview' + | 'ImageAttachmentUploadPreview' + | 'VideoAttachmentUploadPreview' >; -type FileAttachmentType> = - | LocalFileAttachment - | LocalAudioAttachment - | LocalVoiceRecordingAttachment - | LocalVideoAttachment; - /** - * FileUploadPreview + * AttachmentUploadPreviewList * UI Component to preview the files set for upload */ -const UnMemoizedFileUploadPreview = (props: FileUploadPreviewPropsWithContext) => { +const UnMemoizedAttachmentUploadListPreview = ( + props: AttachmentUploadPreviewListPropsWithContext, +) => { + const [flatListWidth, setFlatListWidth] = useState(0); + const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< + Record + >({}); + const flatListRef = useRef | null>(null); const { AudioAttachmentUploadPreview, FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, VideoAttachmentUploadPreview, } = props; const { attachmentManager } = useMessageComposer(); const { attachments } = useAttachmentManagerState(); - const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< - Record - >({}); - const flatListRef = useRef | null>(null); - const [flatListWidth, setFlatListWidth] = useState(0); + const { + theme: { + colors: { grey_whisper }, + messageInput: { + attachmentSeparator, + attachmentUploadPreviewList: { filesFlatList, imagesFlatList, wrapper }, + }, + }, + } = useTheme(); + const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); const fileUploads = useMemo(() => { - return attachments.filter( - (attachment) => - isLocalFileAttachment(attachment) || - isLocalAudioAttachment(attachment) || - isLocalVoiceRecordingAttachment(attachment) || - isLocalVideoAttachment(attachment), - ); + return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); }, [attachments]); useEffect(() => { @@ -143,18 +145,27 @@ const UnMemoizedFileUploadPreview = (props: FileUploadPreviewPropsWithContext) = } }, []); - const { - theme: { - messageInput: { - fileUploadPreview: { flatList }, - }, + const renderImageItem = useCallback( + ({ item }: { item: LocalImageAttachment }) => { + return ( + + ); }, - } = useTheme(); + [ + ImageAttachmentUploadPreview, + attachmentManager.removeAttachments, + attachmentManager.uploadAttachment, + ], + ); - const renderItem = useCallback( - ({ item }: { item: FileAttachmentType }) => { + const renderFileItem = useCallback( + ({ item }: { item: LocalAttachment }) => { if (isLocalImageAttachment(item)) { - // This is already handled in the `ImageUploadPreview` component + // This is already handled in the `renderImageItem` above, so we return null here to avoid duplication. return null; } else if (isLocalVoiceRecordingAttachment(item)) { return ( @@ -240,47 +251,80 @@ const UnMemoizedFileUploadPreview = (props: FileUploadPreviewPropsWithContext) = [flatListRef], ); - if (fileUploads.length === 0) { + if (!attachments.length) { return null; } return ( - ({ - index, - length: FILE_PREVIEW_HEIGHT + 8, - offset: (FILE_PREVIEW_HEIGHT + 8) * index, - })} - keyExtractor={(item) => item.localMetadata.id} - onLayout={onLayout} - ref={flatListRef} - renderItem={renderItem} - style={[styles.flatList, flatList]} - testID={'file-upload-preview'} - /> + + {imageUploads.length ? ( + ({ + index, + length: IMAGE_PREVIEW_SIZE + 8, + offset: (IMAGE_PREVIEW_SIZE + 8) * index, + })} + horizontal + keyExtractor={(item) => item.localMetadata.id} + renderItem={renderImageItem} + style={[styles.imagesFlatList, imagesFlatList]} + /> + ) : null} + {imageUploads.length && fileUploads.length ? ( + + ) : null} + {fileUploads.length ? ( + ({ + index, + length: FILE_PREVIEW_HEIGHT + 8, + offset: (FILE_PREVIEW_HEIGHT + 8) * index, + })} + keyExtractor={(item) => item.localMetadata.id} + onLayout={onLayout} + ref={flatListRef} + renderItem={renderFileItem} + style={[styles.filesFlatList, filesFlatList]} + testID={'file-upload-preview'} + /> + ) : null} + ); }; -export type FileUploadPreviewProps = Partial; +export type AttachmentUploadPreviewListProps = Partial; -const MemoizedFileUploadPreviewWithContext = React.memo(UnMemoizedFileUploadPreview); +const MemoizedAttachmentUploadPreviewListWithContext = React.memo( + UnMemoizedAttachmentUploadListPreview, +); /** - * FileUploadPreview + * AttachmentUploadPreviewList * UI Component to preview the files set for upload */ -export const FileUploadPreview = (props: FileUploadPreviewProps) => { +export const AttachmentUploadPreviewList = (props: AttachmentUploadPreviewListProps) => { const { AudioAttachmentUploadPreview, FileAttachmentUploadPreview, + ImageAttachmentUploadPreview, VideoAttachmentUploadPreview, } = useMessageInputContext(); return ( - { }; const styles = StyleSheet.create({ - flatList: { marginBottom: 12, maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, + attachmentSeparator: { + borderBottomWidth: 1, + marginBottom: 10, + }, + filesFlatList: { marginBottom: 12, maxHeight: FILE_PREVIEW_HEIGHT * 2.5 + 16 }, + imagesFlatList: { paddingBottom: 12 }, }); -FileUploadPreview.displayName = 'FileUploadPreview{messageInput{fileUploadPreview}}'; +AttachmentUploadPreviewList.displayName = + 'AttachmentUploadPreviewList{messageInput{attachmentUploadPreviewList}}'; diff --git a/package/src/components/MessageInput/ImageUploadPreview.tsx b/package/src/components/MessageInput/ImageUploadPreview.tsx deleted file mode 100644 index 607013d73e..0000000000 --- a/package/src/components/MessageInput/ImageUploadPreview.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback } from 'react'; -import { FlatList, StyleSheet } from 'react-native'; - -import { isLocalImageAttachment, LocalImageAttachment } from 'stream-chat'; - -import { - MessageInputContextValue, - useMessageComposer, - useMessageInputContext, -} from '../../contexts'; -import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; - -const IMAGE_PREVIEW_SIZE = 100; - -export type ImageUploadPreviewPropsWithContext = Pick< - MessageInputContextValue, - 'ImageAttachmentUploadPreview' ->; - -export type ImageAttachmentPreview> = - LocalImageAttachment; - -type ImageUploadPreviewItem = { index: number; item: ImageAttachmentPreview }; - -/** - * UI Component to preview the images set for upload - */ -const UnmemoizedImageUploadPreview = (props: ImageUploadPreviewPropsWithContext) => { - const { ImageAttachmentUploadPreview } = props; - const { attachmentManager } = useMessageComposer(); - const { attachments } = useAttachmentManagerState(); - - const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); - - const { - theme: { - messageInput: { - imageUploadPreview: { flatList }, - }, - }, - } = useTheme(); - - const renderItem = useCallback( - ({ item }: ImageUploadPreviewItem) => { - return ( - - ); - }, - [ - ImageAttachmentUploadPreview, - attachmentManager.removeAttachments, - attachmentManager.uploadAttachment, - ], - ); - - if (!imageUploads.length) { - return null; - } - - return ( - ({ - index, - length: IMAGE_PREVIEW_SIZE + 8, - offset: (IMAGE_PREVIEW_SIZE + 8) * index, - })} - horizontal - keyExtractor={(item) => item.localMetadata.id} - renderItem={renderItem} - style={[styles.flatList, flatList]} - /> - ); -}; - -const MemoizedImageUploadPreviewWithContext = React.memo(UnmemoizedImageUploadPreview); - -export type ImageUploadPreviewProps = Partial; - -/** - * UI Component to preview the images set for upload - */ -export const ImageUploadPreview = (props: ImageUploadPreviewProps) => { - const { ImageAttachmentUploadPreview } = useMessageInputContext(); - return ; -}; - -const styles = StyleSheet.create({ - flatList: { paddingBottom: 12 }, -}); - -ImageUploadPreview.displayName = 'ImageUploadPreview{messageInput{imageUploadPreview}}'; diff --git a/package/src/components/MessageInput/MessageInput.tsx b/package/src/components/MessageInput/MessageInput.tsx index 64d0e9dcf5..606dce5a80 100644 --- a/package/src/components/MessageInput/MessageInput.tsx +++ b/package/src/components/MessageInput/MessageInput.tsx @@ -16,12 +16,7 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import { - isLocalImageAttachment, - type MessageComposerState, - type TextComposerState, - type UserResponse, -} from 'stream-chat'; +import { type MessageComposerState, type TextComposerState, type UserResponse } from 'stream-chat'; import { useAudioController } from './hooks/useAudioController'; import { useCountdown } from './hooks/useCountdown'; @@ -135,6 +130,7 @@ type MessageInputPropsWithContext = Partial< | 'AttachmentPickerBottomSheetHandle' | 'AttachmentPickerSelectionBar' | 'attachmentSelectionBarHeight' + | 'AttachmentUploadPreviewList' | 'AudioRecorder' | 'AudioRecordingInProgress' | 'AudioRecordingLockIndicator' @@ -144,8 +140,6 @@ type MessageInputPropsWithContext = Partial< | 'CooldownTimer' | 'closeAttachmentPicker' | 'compressImageQuality' - | 'FileUploadPreview' - | 'ImageUploadPreview' | 'Input' | 'inputBoxRef' | 'InputButtons' @@ -198,6 +192,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { asyncMessagesMinimumPressDuration, asyncMessagesMultiSendEnabled, asyncMessagesSlideToCancelDistance, + AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -211,8 +206,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { CooldownTimer, CreatePollContent, editing, - FileUploadPreview, - ImageUploadPreview, Input, inputBoxRef, InputButtons, @@ -242,9 +235,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { const { attachments, availableUploadSlots } = useAttachmentManagerState(); const hasSendableData = useMessageComposerHasSendableData(); - const imageUploads = attachments.filter((attachment) => isLocalImageAttachment(attachment)); - const fileUploads = attachments.filter((attachment) => !isLocalImageAttachment(attachment)); - const [height, setHeight] = useState(0); const { @@ -252,7 +242,6 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { colors: { border, grey_whisper, white, white_smoke }, messageInput: { attachmentSelectionBar, - attachmentSeparator, autoCompleteInputContainer, composerContainer, container, @@ -534,20 +523,7 @@ const MessageInputWithContext = (props: MessageInputPropsWithContext) => { )} - - {imageUploads.length && fileUploads.length ? ( - - ) : null} - + {command ? ( ) : ( @@ -780,6 +756,7 @@ export const MessageInput = (props: MessageInputProps) => { attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -796,9 +773,7 @@ export const MessageInput = (props: MessageInputProps) => { CreatePollContent, CreatePollIcon, FileSelectorIcon, - FileUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, inputBoxRef, InputButtons, @@ -846,6 +821,7 @@ export const MessageInput = (props: MessageInputProps) => { attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AudioRecorder, audioRecordingEnabled, AudioRecordingInProgress, @@ -868,9 +844,7 @@ export const MessageInput = (props: MessageInputProps) => { CreatePollIcon, editing, FileSelectorIcon, - FileUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, inputBoxRef, InputButtons, diff --git a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js similarity index 53% rename from package/src/components/MessageInput/__tests__/FileUploadPreview.test.js rename to package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js index 56e5418d5f..67dc0c334f 100644 --- a/package/src/components/MessageInput/__tests__/FileUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.js @@ -14,7 +14,7 @@ import { import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { FileUploadPreview } from '../FileUploadPreview'; +import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const View = require('react-native/Libraries/Components/View/View'); @@ -38,14 +38,14 @@ const renderComponent = ({ client, channel, props }) => { - + , ); }; -describe("FileUploadPreview's render", () => { +describe('AttachmentUploadPreviewList', () => { let client; let channel; @@ -278,4 +278,213 @@ describe("FileUploadPreview's render", () => { }); }); }); + + describe('ImageAttachmentUploadPreview', () => { + it('should render ImageAttachmentUploadPreview with all uploading images', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.UPLOADING, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + }); + + await act(() => { + fireEvent.press(getAllByTestId('remove-upload-preview')[0]); + }); + + await waitFor(() => { + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); + }); + }); + + it('should return null when no images are uploaded', async () => { + const props = {}; + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + expect(queryAllByTestId('file-upload-preview')).toHaveLength(0); + }); + }); + + it('should render ImageAttachmentUploadPreview with all uploaded images', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); + }); + }); + + it('should render ImageAttachmentUploadPreview with all failed images', async () => { + const uploadAttachmentSpy = jest.fn(); + channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.FAILED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { getAllByTestId, queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); + }); + + await act(() => { + fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); + expect(uploadAttachmentSpy).toHaveBeenCalled(); + }); + }); + + it('should render ImageAttachmentUploadPreview with all unsupported', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment', + uploadState: FileState.BLOCKED, + }, + }), + ]; + const props = {}; + + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByText, queryAllByTestId } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); + expect(queryAllByText('Not supported')).toHaveLength(1); + }); + }); + + it('should render ImageAttachmentUploadPreview with 1 uploading, 1 uploaded, and 1 failed image, and 1 unsupported', async () => { + const attachments = [ + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-1', + uploadState: FileState.UPLOADING, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-2', + uploadState: FileState.FINISHED, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-3', + uploadState: FileState.FAILED, + }, + }), + generateImageAttachment({ + localMetadata: { + id: 'image-attachment-4', + uploadState: FileState.BLOCKED, + }, + }), + ]; + + const props = {}; + await act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + }); + + renderComponent({ channel, client, props }); + + const { queryAllByTestId, queryAllByText } = screen; + + await waitFor(() => { + const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); + for (const image of imageAttachments) { + fireEvent(image, 'loadEnd'); + } + }); + + await waitFor(() => { + expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(4); + expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); + expect(queryAllByText('Not supported')).toHaveLength(1); + }); + }); + }); }); diff --git a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js index fa709ff4b5..b39d2b61b5 100644 --- a/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js +++ b/package/src/components/MessageInput/__tests__/AudioAttachmentUploadPreview.test.js @@ -9,7 +9,7 @@ import { generateAudioAttachment } from '../../../mock-builders/attachments'; import { FileState } from '../../../utils/utils'; import { Channel } from '../../Channel/Channel'; import { Chat } from '../../Chat/Chat'; -import { FileUploadPreview } from '../FileUploadPreview'; +import { AttachmentUploadPreviewList } from '../AttachmentUploadPreviewList'; jest.mock('../../../native.ts', () => { const View = require('react-native/Libraries/Components/View/View'); @@ -33,7 +33,7 @@ const renderComponent = ({ client, channel, props }) => { - + , diff --git a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js b/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js deleted file mode 100644 index d0a64714f1..0000000000 --- a/package/src/components/MessageInput/__tests__/ImageUploadPreview.test.js +++ /dev/null @@ -1,248 +0,0 @@ -import React from 'react'; - -import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; - -import { OverlayProvider } from '../../../contexts'; -import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; -import { generateImageAttachment } from '../../../mock-builders/attachments'; -import { FileState } from '../../../utils/utils'; - -import { Channel } from '../../Channel/Channel'; -import { Chat } from '../../Chat/Chat'; -import { ImageUploadPreview } from '../ImageUploadPreview'; - -const renderComponent = ({ client, channel, props }) => { - return render( - - - - - - - , - ); -}; - -describe('ImageUploadPreview', () => { - let client; - let channel; - - beforeAll(async () => { - const { client: chatClient, channels } = await initiateClientWithChannels(); - client = chatClient; - channel = channels[0]; - }); - - afterEach(() => { - act(() => { - channel.messageComposer.clear(); - }); - }); - - it('should render ImageUploadPreview with all uploading images', async () => { - const attachments = [ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.UPLOADING, - }, - }), - ]; - const props = {}; - - await act(() => { - channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); - }); - - renderComponent({ channel, client, props }); - - const { getAllByTestId, queryAllByTestId } = screen; - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('active-upload-progress-indicator')).toHaveLength(1); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); - }); - - await act(() => { - userEvent.press(getAllByTestId('remove-upload-preview')[0]); - }); - - await waitFor(() => { - expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(0); - }); - }); - - it('should return null when no images are uploaded', async () => { - const props = {}; - - renderComponent({ channel, client, props }); - - const { queryAllByTestId } = screen; - - await waitFor(() => { - expect(queryAllByTestId('file-upload-preview')).toHaveLength(0); - }); - }); - - it('should render ImageUploadPreview with all uploaded images', async () => { - const attachments = [ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.FINISHED, - }, - }), - ]; - const props = {}; - - await act(() => { - channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); - }); - - renderComponent({ channel, client, props }); - - const { queryAllByTestId } = screen; - - await waitFor(() => { - const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); - for (const image of imageAttachments) { - fireEvent(image, 'loadEnd'); - } - }); - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); - }); - }); - - it('should render ImageUploadPreview with all failed images', async () => { - const uploadAttachmentSpy = jest.fn(); - channel.messageComposer.attachmentManager.uploadAttachment = uploadAttachmentSpy; - const attachments = [ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.FAILED, - }, - }), - ]; - const props = {}; - - await act(() => { - channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); - }); - - renderComponent({ channel, client, props }); - - const { getAllByTestId, queryAllByTestId } = screen; - - await waitFor(() => { - const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); - for (const image of imageAttachments) { - fireEvent(image, 'loadEnd'); - } - }); - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); - }); - - await act(() => { - fireEvent.press(getAllByTestId('retry-upload-progress-indicator')[0]); - }); - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(channel.messageComposer.attachmentManager.attachments).toHaveLength(1); - expect(uploadAttachmentSpy).toHaveBeenCalled(); - }); - }); - - it('should render ImageUploadPreview with all unsupported', async () => { - const attachments = [ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment', - uploadState: FileState.BLOCKED, - }, - }), - ]; - const props = {}; - - await act(() => { - channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); - }); - - renderComponent({ channel, client, props }); - - const { queryAllByText, queryAllByTestId } = screen; - - await waitFor(() => { - const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); - for (const image of imageAttachments) { - fireEvent(image, 'loadEnd'); - } - }); - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); - expect(queryAllByText('Not supported')).toHaveLength(1); - }); - }); - - it('should render ImageUploadPreview with 1 uploading, 1 uploaded, and 1 failed image, and 1 unsupported', async () => { - const attachments = [ - generateImageAttachment({ - localMetadata: { - id: 'image-attachment-1', - uploadState: FileState.UPLOADING, - }, - }), - generateImageAttachment({ - localMetadata: { - id: 'image-attachment-2', - uploadState: FileState.FINISHED, - }, - }), - generateImageAttachment({ - localMetadata: { - id: 'image-attachment-3', - uploadState: FileState.FAILED, - }, - }), - generateImageAttachment({ - localMetadata: { - id: 'image-attachment-4', - uploadState: FileState.BLOCKED, - }, - }), - ]; - - const props = {}; - await act(() => { - channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); - }); - - renderComponent({ channel, client, props }); - - const { queryAllByTestId, queryAllByText } = screen; - - await waitFor(() => { - const imageAttachments = queryAllByTestId('image-attachment-upload-preview-image'); - for (const image of imageAttachments) { - fireEvent(image, 'loadEnd'); - } - }); - - await waitFor(() => { - expect(queryAllByTestId('image-attachment-upload-preview')).toHaveLength(4); - expect(queryAllByTestId('upload-progress-indicator')).toHaveLength(1); - expect(queryAllByTestId('inactive-upload-progress-indicator')).toHaveLength(1); - expect(queryAllByTestId('retry-upload-progress-indicator')).toHaveLength(1); - expect(queryAllByText('Not supported')).toHaveLength(1); - }); - }); -}); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index a14b4eeb98..a36836d39b 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -124,8 +124,7 @@ export * from './MessageInput/components/AudioRecorder/AudioRecordingPreview'; export * from './MessageInput/components/AudioRecorder/AudioRecordingWaveform'; export * from './MessageInput/components/CommandInput'; export * from './MessageInput/CooldownTimer'; -export * from './MessageInput/FileUploadPreview'; -export * from './MessageInput/ImageUploadPreview'; +export * from './MessageInput/AttachmentUploadPreviewList'; export * from './MessageInput/InputButtons'; export * from './MessageInput/MessageInput'; export * from './MessageInput/MoreOptionsButton'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 5083ace6db..dbf3d41679 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -11,15 +11,11 @@ import { Alert, Keyboard, Linking, TextInput, TextInputProps } from 'react-nativ import { BottomSheetHandleProps } from '@gorhom/bottom-sheet'; import { - // createCommandInjectionMiddleware, - // createCommandStringExtractionMiddleware, - // createDraftCommandInjectionMiddleware, LocalMessage, Message, MessageComposer, SendMessageOptions, StreamChat, - // TextComposerMiddleware, Message as StreamMessage, UpdateMessageOptions, UploadRequestFn, @@ -39,6 +35,7 @@ import { } from '../../components'; import { parseLinksFromText } from '../../components/Message/MessageSimple/utils/parseLinks'; import type { AttachButtonProps } from '../../components/MessageInput/AttachButton'; +import { AttachmentUploadPreviewListProps } from '../../components/MessageInput/AttachmentUploadPreviewList'; import type { CommandsButtonProps } from '../../components/MessageInput/CommandsButton'; import type { AttachmentUploadProgressIndicatorProps } from '../../components/MessageInput/components/AttachmentPreview/AttachmentUploadProgressIndicator'; import { AudioAttachmentUploadPreviewProps } from '../../components/MessageInput/components/AttachmentPreview/AudioAttachmentUploadPreview'; @@ -53,9 +50,7 @@ import type { AudioRecordingWaveformProps } from '../../components/MessageInput/ import type { CommandInputProps } from '../../components/MessageInput/components/CommandInput'; import type { InputEditingStateHeaderProps } from '../../components/MessageInput/components/InputEditingStateHeader'; import type { CooldownTimerProps } from '../../components/MessageInput/CooldownTimer'; -import type { FileUploadPreviewProps } from '../../components/MessageInput/FileUploadPreview'; import { useCooldown } from '../../components/MessageInput/hooks/useCooldown'; -import type { ImageUploadPreviewProps } from '../../components/MessageInput/ImageUploadPreview'; import type { InputButtonsProps } from '../../components/MessageInput/InputButtons'; import type { MessageInputProps } from '../../components/MessageInput/MessageInput'; import type { MoreOptionsButtonProps } from '../../components/MessageInput/MoreOptionsButton'; @@ -216,6 +211,7 @@ export type InputMessageInputContextValue = { */ attachmentSelectionBarHeight: number; + AttachmentUploadPreviewList: React.ComponentType; /** * Custom UI component for [camera selector icon](https://github.com/GetStream/stream-chat-react-native/blob/main/screenshots/docs/1.png) * @@ -270,12 +266,6 @@ export type InputMessageInputContextValue = { localMessage: LocalMessage; options?: UpdateMessageOptions; }) => ReturnType; - /** - * Custom UI component for FileUploadPreview. - * Defaults to and accepts same props as: - * https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/FileUploadPreview.tsx - */ - FileUploadPreview: React.ComponentType; /** When false, CameraSelectorIcon will be hidden */ hasCameraPicker: boolean; @@ -286,12 +276,7 @@ export type InputMessageInputContextValue = { hasFilePicker: boolean; /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - /** - * Custom UI component for ImageUploadPreview. - * Defaults to and accepts same props as: - * https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/MessageInput/ImageUploadPreview.tsx - */ - ImageUploadPreview: React.ComponentType; + InputEditingStateHeader: React.ComponentType; /** * Boolean value to determine if the input should show a command UI. diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 443176c064..2ccc0c9fcb 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -15,6 +15,7 @@ export const useCreateMessageInputContext = ({ attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, @@ -39,7 +40,6 @@ export const useCreateMessageInputContext = ({ editMessage, FileAttachmentUploadPreview, FileSelectorIcon, - FileUploadPreview, isCommandUIEnabled, handleAttachButtonPress, hasCameraPicker, @@ -48,7 +48,6 @@ export const useCreateMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, inputBoxRef, InputButtons, @@ -94,6 +93,7 @@ export const useCreateMessageInputContext = ({ attachmentPickerBottomSheetHeight, AttachmentPickerSelectionBar, attachmentSelectionBarHeight, + AttachmentUploadPreviewList, AttachmentUploadProgressIndicator, AudioAttachmentUploadPreview, AudioRecorder, @@ -118,7 +118,6 @@ export const useCreateMessageInputContext = ({ editMessage, FileAttachmentUploadPreview, FileSelectorIcon, - FileUploadPreview, handleAttachButtonPress, hasCameraPicker, hasCommands, @@ -126,7 +125,6 @@ export const useCreateMessageInputContext = ({ hasImagePicker, ImageAttachmentUploadPreview, ImageSelectorIcon, - ImageUploadPreview, Input, inputBoxRef, InputButtons, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 11855dac7f..853cabcdf5 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -274,6 +274,11 @@ export type Theme = { warningIcon: IconProps; text: TextStyle; }; + attachmentUploadPreviewList: { + filesFlatList: ViewStyle; + imagesFlatList: ViewStyle; + wrapper: ViewStyle; + }; audioRecorder: { arrowLeftIcon: IconProps; checkContainer: ViewStyle; @@ -1077,6 +1082,11 @@ export const defaultTheme: Theme = { text: {}, warningIcon: {}, }, + attachmentUploadPreviewList: { + filesFlatList: {}, + imagesFlatList: {}, + wrapper: {}, + }, audioRecorder: { arrowLeftIcon: {}, checkContainer: {}, From 8bcd96bf4c9cd060e9b889be4e524aa2e482d154 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Mon, 23 Jun 2025 19:23:08 +0530 Subject: [PATCH 2/4] feat: create useAudioPreviewManager --- .../AttachmentUploadPreviewList.tsx | 87 ++-------------- .../hooks/useAudioPreviewManager.tsx | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+), 79 deletions(-) create mode 100644 package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx diff --git a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx index 7c0d896503..9fc1f2e6e3 100644 --- a/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/AttachmentUploadPreviewList.tsx @@ -2,17 +2,17 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FlatList, LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { - isAudioAttachment, isLocalAudioAttachment, isLocalFileAttachment, isLocalImageAttachment, isLocalVoiceRecordingAttachment, isVideoAttachment, - isVoiceRecordingAttachment, LocalAttachment, LocalImageAttachment, } from 'stream-chat'; +import { useAudioPreviewManager } from './hooks/useAudioPreviewManager'; + import { useMessageComposer } from '../../contexts'; import { useAttachmentManagerState } from '../../contexts/messageInputContext/hooks/useAttachmentManagerState'; import { @@ -21,7 +21,6 @@ import { } from '../../contexts/messageInputContext/MessageInputContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../native'; -import { AudioConfig } from '../../types/types'; const IMAGE_PREVIEW_SIZE = 100; const FILE_PREVIEW_HEIGHT = 60; @@ -42,9 +41,6 @@ const UnMemoizedAttachmentUploadListPreview = ( props: AttachmentUploadPreviewListPropsWithContext, ) => { const [flatListWidth, setFlatListWidth] = useState(0); - const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< - Record - >({}); const flatListRef = useRef | null>(null); const { AudioAttachmentUploadPreview, @@ -68,82 +64,15 @@ const UnMemoizedAttachmentUploadListPreview = ( const fileUploads = useMemo(() => { return attachments.filter((attachment) => !isLocalImageAttachment(attachment)); }, [attachments]); - - useEffect(() => { - const newAudioAttachmentsStateMap = fileUploads.reduce( - (acc, attachment) => { - if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) { - acc[attachment.localMetadata.id] = { - duration: - attachment.duration || - audioAttachmentsStateMap[attachment.localMetadata.id]?.duration || - 0, - paused: true, - progress: 0, - }; - } - return acc; - }, - {} as Record, + const audioUploads = useMemo(() => { + return fileUploads.filter( + (attachment) => + isLocalAudioAttachment(attachment) || isLocalVoiceRecordingAttachment(attachment), ); - - setAudioAttachmentsStateMap(newAudioAttachmentsStateMap); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [fileUploads]); - // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here - // and the duration is set. - const onLoad = useCallback((index: string, duration: number) => { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - duration, - }, - })); - }, []); - - // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The - // progressed duration is set here. - const onProgress = useCallback((index: string, progress: number) => { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - progress, - }, - })); - }, []); - - // The handler which controls or sets the paused/played state of the audio. - const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => { - if (pausedStatus === false) { - // In this case, all others except the index are set to paused. - setAudioAttachmentsStateMap((prevState) => { - const newState = { ...prevState }; - Object.keys(newState).forEach((key) => { - if (key !== index) { - newState[key].paused = true; - } - }); - return { - ...newState, - [index]: { - ...newState[index], - paused: false, - }, - }; - }); - } else { - setAudioAttachmentsStateMap((prevState) => ({ - ...prevState, - [index]: { - ...prevState[index], - paused: true, - }, - })); - } - }, []); + const { audioAttachmentsStateMap, onLoad, onProgress, onPlayPause } = + useAudioPreviewManager(audioUploads); const renderImageItem = useCallback( ({ item }: { item: LocalImageAttachment }) => { diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx new file mode 100644 index 0000000000..8f87220d8b --- /dev/null +++ b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { LocalAttachment } from 'stream-chat'; + +import { AudioConfig } from '../../../types/types'; + +/** + * Manages the state of audio attachments for preview and playback. + * @param files The audio files to manage. + * @returns An object containing the state and handlers for audio attachments. + */ +export const useAudioPreviewManager = (files: LocalAttachment[]) => { + const [audioAttachmentsStateMap, setAudioAttachmentsStateMap] = useState< + Record + >({}); + + useEffect(() => { + const updatedStateMap = Object.fromEntries( + files.map((attachment) => { + const id = attachment.localMetadata.id; + const existingConfig = audioAttachmentsStateMap[id]; + + const config: AudioConfig = { + duration: attachment.duration ?? existingConfig?.duration ?? 0, + paused: true, + progress: 0, + }; + + return [id, config]; + }), + ); + + setAudioAttachmentsStateMap(updatedStateMap); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [files]); + + // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here + // and the duration is set. + const onLoad = useCallback((index: string, duration: number) => { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + duration, + }, + })); + }, []); + + // The handler which is triggered when the audio progresses/ the thumb is dragged in the progress control. The + // progressed duration is set here. + const onProgress = useCallback((index: string, progress: number) => { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + progress, + }, + })); + }, []); + + // The handler which controls or sets the paused/played state of the audio. + const onPlayPause = useCallback((index: string, pausedStatus?: boolean) => { + if (pausedStatus === false) { + // In this case, all others except the index are set to paused. + setAudioAttachmentsStateMap((prevState) => { + const newState = { ...prevState }; + Object.keys(newState).forEach((key) => { + if (key !== index) { + newState[key].paused = true; + } + }); + return { + ...newState, + [index]: { + ...newState[index], + paused: false, + }, + }; + }); + } else { + setAudioAttachmentsStateMap((prevState) => ({ + ...prevState, + [index]: { + ...prevState[index], + paused: true, + }, + })); + } + }, []); + + return { + audioAttachmentsStateMap, + onLoad, + onPlayPause, + onProgress, + setAudioAttachmentsStateMap, + }; +}; From 9e4a6ff6f898c624634fd11fe14c677f0a3f39cb Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 24 Jun 2025 11:26:05 +0530 Subject: [PATCH 3/4] fix: paused bug when audio is uploadesd --- .../components/MessageInput/hooks/useAudioPreviewManager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx index 8f87220d8b..c30f8cf777 100644 --- a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx +++ b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx @@ -22,8 +22,8 @@ export const useAudioPreviewManager = (files: LocalAttachment[]) => { const config: AudioConfig = { duration: attachment.duration ?? existingConfig?.duration ?? 0, - paused: true, - progress: 0, + paused: existingConfig?.paused ?? true, + progress: existingConfig?.progress ?? 0, }; return [id, config]; From 369533a0c568128779b86500a5155db4f86f61e4 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 24 Jun 2025 13:22:11 +0530 Subject: [PATCH 4/4] fix: restructure useeffect --- .../hooks/useAudioPreviewManager.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx index c30f8cf777..b0c6936bdb 100644 --- a/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx +++ b/package/src/components/MessageInput/hooks/useAudioPreviewManager.tsx @@ -15,23 +15,23 @@ export const useAudioPreviewManager = (files: LocalAttachment[]) => { >({}); useEffect(() => { - const updatedStateMap = Object.fromEntries( - files.map((attachment) => { - const id = attachment.localMetadata.id; - const existingConfig = audioAttachmentsStateMap[id]; + setAudioAttachmentsStateMap((prevState) => { + const updatedStateMap = Object.fromEntries( + files.map((attachment) => { + const id = attachment.localMetadata.id; - const config: AudioConfig = { - duration: attachment.duration ?? existingConfig?.duration ?? 0, - paused: existingConfig?.paused ?? true, - progress: existingConfig?.progress ?? 0, - }; + const config: AudioConfig = { + duration: attachment.duration ?? prevState[id]?.duration ?? 0, + paused: prevState[id]?.paused ?? true, + progress: prevState[id]?.progress ?? 0, + }; - return [id, config]; - }), - ); + return [id, config]; + }), + ); - setAudioAttachmentsStateMap(updatedStateMap); - // eslint-disable-next-line react-hooks/exhaustive-deps + return updatedStateMap; + }); }, [files]); // Handler triggered when an audio is loaded in the message input. The initial state is defined for the audio here