diff --git a/docusaurus/docs/React/components/contexts/message-input-context.mdx b/docusaurus/docs/React/components/contexts/message-input-context.mdx index 2cfc44b4f8..3d44caddb2 100644 --- a/docusaurus/docs/React/components/contexts/message-input-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-input-context.mdx @@ -250,7 +250,7 @@ Function that runs onSubmit to the underlying `textarea` component. Allows to hide MessageInput's send button. Used by `MessageSimple` to hide the send button in `EditMessageForm`. Received from `MessageInputProps`. | Type | Default | -|---------|---------| +| ------- | ------- | | boolean | false | ### imageOrder @@ -438,6 +438,28 @@ If true, triggers typing events on text input keystroke. | ------- | ------- | | boolean | true | +### removeAttachments + +Function to remove an attachment objects from the `attachments` array in the `MessageInputState`. + +| Type | +| ---------------------- | +| (id: string[]) => void | + +```jsx +const Component = () => { + const { attachments, removeAttachments } = useMessageInputContext(); + + return ( +
+ {attachments.map((att) => ( + + ))} +
+ ); +}; +``` + ### removeFile Function to remove a file from the `fileUploads` mapping. @@ -526,6 +548,33 @@ Function to upload an array of files to the `fileUploads` and `imageUploads` map | ----------------------------------- | | (files: FileList \| File[]) => void | +### upsertAttachments + +Function that adds or updates `attachments` array in `MessageInputState`. Accepts an array of objects. + +| Type | +| -------------------------------------------------------------------------------------------------- | +| `(attachments: (Attachment \| LocalAttachment)[]) => void` | + +```jsx +const Component = () => { + const { upsertAttachments } = useMessageInputContext(); + + const handleSelect = (location) => { + upsertAttachments([ + { + type: 'geolocation', + longitude: location.longitude, + latitude: location.latitude, + name: location.name, + }, + ]); + }; + + // ... +}; +``` + ### useMentionsTransliteration If true, will use an optional dependency to support transliteration in the input for mentions. See: https://github.com/sindresorhus/transliterate diff --git a/docusaurus/docs/React/components/message-input-components/ui-components.mdx b/docusaurus/docs/React/components/message-input-components/ui-components.mdx index 10129bc661..4a4a1437e3 100644 --- a/docusaurus/docs/React/components/message-input-components/ui-components.mdx +++ b/docusaurus/docs/React/components/message-input-components/ui-components.mdx @@ -26,8 +26,8 @@ The following UI components are available for use: - [`SendButton`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/icons.tsx) - on click, sends a message to the currently active channel -- [`UploadsPreview`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/UploadsPreview.tsx) - displays - a list of uploaded files prior to sending the message +- [`AttachmentPreviewList`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx) - displays + a list of message attachments ## ChatAutoComplete Props @@ -155,8 +155,46 @@ Function to send a message to the currently active channel. | ----------------------------------------- | | (event: React.BaseSyntheticEvent) => void | -## UploadsPreview +## AttachmentPreviewList -:::note -`UploadsPreview` only consumes context and does not accept any optional props. -::: +Renders message attachments in preview. The default (supported) message attachment types are: + +- `audio` +- `video` +- `image` +- `voiceRecording` +- `file` + +If the attachment object has property `og_scrape_url` or `title_link`, then it is rendered be [`LinkPreviewList` component](#linkpreviewlist-props) and not `AttachmentPreviewList`. + +### FileAttachmentPreview + +Custom component to be rendered for attachments of types `'file'`, `'video'`, `'audio'`. + +| Type | +| ------------------------------------------- | +| `ComponentType` | + +### ImageAttachmentPreview + +Custom component to be rendered for uploaded `'image'` attachment. + +| Type | +| -------------------------------------------- | +| `ComponentType` | + +### UnsupportedAttachmentPreview + +Custom component to be rendered for attachment that is not recognized as any of the default types. + +| Type | +| -------------------------------------------------- | +| `ComponentType` | + +### VoiceRecordingPreview + +Custom component to preview audio recorded using [`AudioRecorder` component](../audio_recorder). + +| Type | +| ------------------------------------------- | +| `ComponentType` | diff --git a/docusaurus/docs/React/guides/message-input/attachment-previews.mdx b/docusaurus/docs/React/guides/message-input/attachment-previews.mdx new file mode 100644 index 0000000000..22c34d747f --- /dev/null +++ b/docusaurus/docs/React/guides/message-input/attachment-previews.mdx @@ -0,0 +1,75 @@ +--- +id: attachment_previews +title: Attachment Previews in Message Input +--- + +In this section we will focus on how to customize attachment previews display in `MessageInput` component. The attachment previews are rendered by [`AttachmentPreviewList` component](../../../components/message-input-components/ui_components#attachmentpreviewlist). + +## Customize the rendering of default attachment previews + +The default attachment types recognized by `AttachmentPreviewList` are: + +- `audio` +- `video` +- `image` +- `voiceRecording` +- `file` + +If the attachment object has property `og_scrape_url` or `title_link`, then it is rendered by [`LinkPreviewList` component](#linkpreviewlist-props) and not `AttachmentPreviewList`. + +To customize attachment previews we need to override `AttachmentsPreviewList` component. + +```jsx +import { VideoAttachmentPreview } from './AttachmentPreviews'; + +const CustomAttachmentPreviewList = () => ( + +); +``` + +And pass it to `Channel` component. + +```jsx + +``` + +We can customize the following preview components: + +- `AudioAttachmentPreview` +- `FileAttachmentPreview` +- `ImageAttachmentPreview` +- `UnsupportedAttachmentPreview` +- `VideoAttachmentPreview` +- `VoiceRecordingPreview` + +## Customize the rendering of custom attachment previews + +It is possible to add custom attachments (objects) to composed messages via [`upsertAttachments` function](../../../components/contexts/message-input-context#upsertattachments) provided by `MessageInputContext`. + +The custom attachments are not recognized by `AttachmentPreviewList` component and therefore rendered via `UnsupportedAttachmentPreview` component within `AttachmentPreviewList`. The component `UnsupportedAttachmentPreview` can be customized and handle all the custom attachment objects added to the message attachments. + +```tsx +import { GeolocationPreview } from './GeolocationAttachmentPreview'; +import type { UnsupportedAttachmentPreviewProps } from 'stream-chat-react'; + +const CustomAttachmentsPreview = (props: UnsupportedAttachmentPreviewProps) => { + const { attachment } = props; + if (attachment.type === 'geolocation') { + return ; + } + // more conditions follow... +}; +``` + +The custom component is then passed to custom `AttachmentsPreviewList` component which purpose is just to specify the custom `UnsupportedAttachmentPreview` component. + +```jsx +import { CustomAttachmentsPreview } from './AttachmentPreviewList'; +const CustomAttachmentPreviewList = () => ( + +); +``` + +```jsx + +``` diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index 8f084dc97b..99d1787f33 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -110,6 +110,7 @@ "Message Input": [ "guides/theming/input_ui", "guides/customization/link-previews", + "guides/message-input/attachment_previews", "guides/customization/override_submit_handler", "guides/customization/persist_input_text_in_localstorage", "guides/customization/suggestion_list", diff --git a/package.json b/package.json index b3e9dc6df4..d492402e59 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^4.14.0", + "@stream-io/stream-chat-css": "^4.16.0", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 0f559f16b1..c895da4686 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -10,7 +10,7 @@ import { VoiceRecording as DefaultVoiceRecording } from './VoiceRecording'; import { Gallery as DefaultGallery, ImageComponent as DefaultImage } from '../Gallery'; import { Card as DefaultCard } from './Card'; import { FileAttachment as DefaultFile } from './FileAttachment'; -import { NullComponent as DefaultUnsupportedAttachment } from './UnsupportedAttachment'; +import { UnsupportedAttachment as DefaultUnsupportedAttachment } from './UnsupportedAttachment'; import { AttachmentContainerProps, isGalleryAttachmentType, diff --git a/src/components/Attachment/UnsupportedAttachment.tsx b/src/components/Attachment/UnsupportedAttachment.tsx index af46b4897f..dfcc4b6a83 100644 --- a/src/components/Attachment/UnsupportedAttachment.tsx +++ b/src/components/Attachment/UnsupportedAttachment.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { FileIcon } from '../ReactFileUtilities'; +import { useTranslationContext } from '../../context'; import type { Attachment } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; @@ -12,13 +14,21 @@ export const UnsupportedAttachment = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >({ attachment, -}: UnsupportedAttachmentProps) => ( -
-
- Unsupported attachment type {attachment.type ?? 'unknown'} +}: UnsupportedAttachmentProps) => { + const { t } = useTranslationContext('UnsupportedAttachment'); + return ( +
+ +
+
+ {attachment.title || t('Unsupported attachment')} +
+
- {JSON.stringify(attachment, null, 4)}; -
-); + ); +}; export const NullComponent = () => null; diff --git a/src/components/Attachment/__tests__/Attachment.test.js b/src/components/Attachment/__tests__/Attachment.test.js index a82b93c243..ee24ee0148 100644 --- a/src/components/Attachment/__tests__/Attachment.test.js +++ b/src/components/Attachment/__tests__/Attachment.test.js @@ -18,6 +18,9 @@ import { import { Attachment } from '../Attachment'; import { SUPPORTED_VIDEO_FORMATS } from '../utils'; import { generateScrapedVideoAttachment } from '../../../mock-builders'; +import { ChannelStateProvider } from '../../../context'; + +const UNSUPPORTED_ATTACHMENT_TEST_ID = 'attachment-unsupported'; const Audio = (props) =>
{props.customTestId}
; const Card = (props) =>
{props.customTestId}
; @@ -46,23 +49,25 @@ const ATTACHMENTS = { const renderComponent = (props) => render( - , + + + , ); describe('attachment', () => { describe('non-scraped content', () => { - it('should render empty attachment list if unrecognized type', () => { - const { container } = renderComponent({ attachments: [{}] }); - expect(container.firstChild).toBeEmptyDOMElement(); + it('should render unsupported attachment if unrecognized type', () => { + renderComponent({ attachments: [{}] }); + expect(screen.getByTestId(UNSUPPORTED_ATTACHMENT_TEST_ID)).toBeInTheDocument(); }); const cases = { @@ -122,8 +127,8 @@ describe('attachment', () => { og_scrape_url: undefined, title_link: undefined, }); - const { container } = renderComponent({ attachments: [attachment] }); - expect(container.firstChild).toBeEmptyDOMElement(); + renderComponent({ attachments: [attachment] }); + expect(screen.getByTestId(UNSUPPORTED_ATTACHMENT_TEST_ID)).toBeInTheDocument(); }); const cases = [ diff --git a/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap b/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap index 8f70b1fb9e..06b17237c5 100644 --- a/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap +++ b/src/components/Attachment/__tests__/__snapshots__/VoiceRecording.test.js.snap @@ -12,9 +12,9 @@ exports[`QuotedVoiceRecording should render the component 1`] = `
- audio_recording_Mon Feb 05 16:21:34 PST 2024 + audio_recording_Mon Feb 05 16:21:34 PST 2024.aac
; }; +export const isLocalAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: UnknownType, +): attachment is LocalAttachment => + !!(attachment.localMetadata as LocalAttachment)?.id; + export const isScrapedContent = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( @@ -57,6 +72,13 @@ export const isUploadedImage = < attachment: Attachment, ) => attachment.type === 'image' && !isScrapedContent(attachment); +export const isLocalImageAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: Attachment | LocalAttachment, +): attachment is LocalImageAttachment => + isUploadedImage(attachment) && isLocalAttachment(attachment); + export const isGalleryAttachmentType = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( @@ -69,27 +91,44 @@ export const isAudioAttachment = < attachment: Attachment | LocalAttachment, ) => attachment.type === 'audio'; +export const isLocalAudioAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: Attachment | LocalAttachment, +): attachment is LocalAudioAttachment => + isAudioAttachment(attachment) && isLocalAttachment(attachment); + export const isVoiceRecordingAttachment = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( attachment: Attachment | LocalAttachment, ): attachment is VoiceRecordingAttachment => attachment.type === 'voiceRecording'; -export const isLocalAttachment = < +export const isLocalVoiceRecordingAttachment = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( - attachment: LocalAttachment, -): attachment is LocalAttachment => !!attachment.$internal; + attachment: Attachment | LocalAttachment, +): attachment is LocalVoiceRecordingAttachment => + isVoiceRecordingAttachment(attachment) && isLocalAttachment(attachment); export const isFileAttachment = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( - attachment: Attachment, + attachment: Attachment | LocalAttachment, ) => attachment.type === 'file' || - (attachment.mime_type && + !!( + attachment.mime_type && SUPPORTED_VIDEO_FORMATS.indexOf(attachment.mime_type) === -1 && - attachment.type !== 'video'); + attachment.type !== 'video' + ); + +export const isLocalFileAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: Attachment | LocalAttachment, +): attachment is LocalFileAttachment => + isFileAttachment(attachment) && isLocalAttachment(attachment); export const isMediaAttachment = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -99,6 +138,13 @@ export const isMediaAttachment = < (attachment.mime_type && SUPPORTED_VIDEO_FORMATS.indexOf(attachment.mime_type) !== -1) || attachment.type === 'video'; +export const isLocalMediaAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: Attachment | LocalAttachment, +): attachment is LocalVideoAttachment => + isMediaAttachment(attachment) && isLocalAttachment(attachment); + export const isSvgAttachment = (attachment: Attachment) => { const filename = attachment.fallback || ''; return filename.toLowerCase().endsWith('.svg'); diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx index e2d8bcd39f..99ffce4ca3 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx @@ -17,7 +17,7 @@ export const AudioRecorder = () => { recordingController: { completeRecording, recorder, recording, recordingState }, } = useMessageInputContext(); - const isUploadingFile = recording?.$internal?.uploadState === 'uploading'; + const isUploadingFile = recording?.localMetadata?.uploadState === 'uploading'; const state = useMemo( () => ({ diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js index ac51fb9100..52aa15caef 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js @@ -302,7 +302,7 @@ describe('MessageInput', () => { }); expect(doFileUploadRequest).toHaveBeenCalledTimes(1); - const { $internal, ...uploadedRecordingAtt } = recording; + const { localMetadata, ...uploadedRecordingAtt } = recording; expect(sendMessage.mock.calls[0][0]).toStrictEqual({ attachments: [uploadedRecordingAtt], mentioned_users: [], @@ -420,7 +420,7 @@ describe('AudioRecorder', () => { it('renders loading indicators while recording being uploaded', async () => { await renderAudioRecorder({ - recording: generateVoiceRecordingAttachment({ $internal: { uploadState: 'uploading' } }), + recording: generateVoiceRecordingAttachment({ localMetadata: { uploadState: 'uploading' } }), recordingState: MediaRecordingState.STOPPED, }); expect(screen.queryByTestId('loading-indicator')).toBeInTheDocument(); diff --git a/src/components/MediaRecorder/classes/MediaRecorderController.ts b/src/components/MediaRecorder/classes/MediaRecorderController.ts index bd8f52b3cd..9d4a1205db 100644 --- a/src/components/MediaRecorder/classes/MediaRecorderController.ts +++ b/src/components/MediaRecorder/classes/MediaRecorderController.ts @@ -21,6 +21,7 @@ import { isSafari } from '../../../utils/browsers'; import { mergeDeepUndefined } from '../../../utils/mergeDeep'; import type { LocalVoiceRecordingAttachment } from '../../MessageInput'; +import type { DefaultStreamChatGenerics } from '../../../types'; const RECORDED_MIME_TYPE_BY_BROWSER = { audio: { @@ -86,7 +87,9 @@ export enum RecordingAttachmentType { VOICE_RECORDING = 'voiceRecording', } -export class MediaRecorderController { +export class MediaRecorderController< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> { permission: BrowserPermission; mediaRecorder: MediaRecorder | undefined; amplitudeRecorder: AmplitudeRecorder | undefined; @@ -101,10 +104,14 @@ export class MediaRecorderController { recordingUri: string | undefined; mediaType: RecordedMediaType; - signalRecordingReady: ((r: LocalVoiceRecordingAttachment) => void) | undefined; + signalRecordingReady: + | ((r: LocalVoiceRecordingAttachment) => void) + | undefined; recordingState = new BehaviorSubject(undefined); - recording = new BehaviorSubject(undefined); + recording = new BehaviorSubject | undefined>( + undefined, + ); error = new Subject(); notification = new Subject<{ text: string; type: 'success' | 'error' } | undefined>(); @@ -187,13 +194,13 @@ export class MediaRecorderController { }); return { - $internal: { - file, - id: nanoid(), - }, asset_url: this.recordingUri, duration: this.durationMs / 1000, file_size: blob.size, + localMetadata: { + file, + id: nanoid(), + }, mime_type: blob.type, title: file.name, type: RecordingAttachmentType.VOICE_RECORDING, @@ -353,7 +360,7 @@ export class MediaRecorderController { this.recordedChunkDurations.push(new Date().getTime() - this.startTime); this.startTime = undefined; } - const result = new Promise((res) => { + const result = new Promise>((res) => { this.signalRecordingReady = res; }); this.mediaRecorder?.stop(); diff --git a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js index fec26b8b6b..e75b68a3c3 100644 --- a/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js +++ b/src/components/MediaRecorder/classes/__tests__/MediaRecorderController.test.js @@ -488,13 +488,13 @@ describe('MediaRecorderController', () => { expect(recording).toStrictEqual( expect.objectContaining({ - $internal: { - file: fileMock, - id: nanoidMockValue, - }, asset_url: fileObjectURL, duration: dataPoints.reduce((acc, n) => acc + n), file_size: recordedChunkCount, + localMetadata: { + file: fileMock, + id: nanoidMockValue, + }, mime_type: targetMimeType, title: fileMock.name, type: RecordingAttachmentType.VOICE_RECORDING, diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts index e4e0ca931d..6ff2966e4b 100644 --- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts +++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts @@ -7,11 +7,13 @@ import type { DefaultStreamChatGenerics } from '../../../types'; export type CustomAudioRecordingConfig = Partial; -export type RecordingController = { +export type RecordingController< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { completeRecording: () => void; permissionState?: PermissionState; recorder?: MediaRecorderController; - recording?: LocalVoiceRecordingAttachment; + recording?: LocalVoiceRecordingAttachment; recordingState?: MediaRecordingState; }; @@ -35,10 +37,10 @@ export const useMediaRecorder = < handleSubmit, recordingConfig, uploadAttachment, -}: UseMediaRecorderParams): RecordingController => { +}: UseMediaRecorderParams): RecordingController => { const { t } = useTranslationContext('useMediaRecorder'); - const [recording, setRecording] = useState(); + const [recording, setRecording] = useState>(); const [recordingState, setRecordingState] = useState(); const [permissionState, setPermissionState] = useState(); const [isScheduledForSubmit, scheduleForSubmit] = useState(false); diff --git a/src/components/MessageInput/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList.tsx deleted file mode 100644 index 71eea1a4c3..0000000000 --- a/src/components/MessageInput/AttachmentPreviewList.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import clsx from 'clsx'; -import React, { useCallback, useState } from 'react'; - -import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from './icons'; -import { - isAudioAttachment, - isMediaAttachment, - isVoiceRecordingAttachment, - PlayButton, -} from '../Attachment'; -import { BaseImage as DefaultBaseImage } from '../Gallery'; -import { useAudioController } from '../Attachment/hooks/useAudioController'; -import { RecordingTimer } from '../MediaRecorder'; -import { FileIcon } from '../ReactFileUtilities'; -import { useComponentContext, useMessageInputContext } from '../../context'; - -import type { LocalAttachment } from './types'; -import type { DefaultStreamChatGenerics } from '../../types'; - -export const AttachmentPreviewList = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->() => { - const { - attachments, - fileOrder, - imageOrder, - removeAttachment, - uploadAttachment, - } = useMessageInputContext('AttachmentPreviewList'); - - return ( -
-
- {attachments.map((attachment) => { - if (isVoiceRecordingAttachment(attachment)) { - return ( - - ); - } else if (isAudioAttachment(attachment) || isMediaAttachment(attachment)) { - // unnecessary to pass handleRetry as video and audio if among attachments is already uploaded - // - user looking at the edit message input - return ( - - ); - } - return null; - })} - {imageOrder.map((id) => ( - - ))} - {fileOrder.map((id) => ( - - ))} -
-
- ); -}; - -type AttachmentPreviewProps = { - attachment: A; - removeAttachment: (id: string) => void; - handleRetry?: (attachment: A) => void | Promise; - mimeType?: string; -}; - -const VoiceRecordingPreview = ({ - attachment, - handleRetry, - mimeType, - removeAttachment, -}: AttachmentPreviewProps) => { - const { audioRef, isPlaying, secondsElapsed, togglePlay } = useAudioController({ mimeType }); - - return ( -
- - - - - - {attachment.$internal?.uploadState === 'failed' && !!handleRetry && ( - - )} - -
-
- {attachment.title} -
- {typeof attachment.duration !== 'undefined' && ( - - )} - {attachment.$internal?.uploadState === 'uploading' && } -
-
- -
-
- ); -}; - -const FilePreview = ({ attachment, handleRetry, removeAttachment }: AttachmentPreviewProps) => ( -
-); - -type PreviewItemProps = { id: string }; - -export const ImagePreviewItem = ({ id }: PreviewItemProps) => { - const { BaseImage = DefaultBaseImage } = useComponentContext('ImagePreviewItem'); - const { imageUploads, removeImage, uploadImage } = useMessageInputContext('ImagePreviewItem'); - const [previewError, setPreviewError] = useState(false); - - const handleRemove: React.MouseEventHandler = useCallback( - (e) => { - e.stopPropagation(); - removeImage(id); - }, - [removeImage, id], - ); - const handleRetry = useCallback(() => uploadImage(id), [uploadImage, id]); - - const handleLoadError = useCallback(() => setPreviewError(true), []); - - const image = imageUploads[id]; - // do not display scraped attachments - if (!image || image.og_scrape_url) return null; - - return ( -
- - - {image.state === 'failed' && ( - - )} - - {image.state === 'uploading' && ( -
- -
- )} - - {(image.previewUri || image.url) && ( - - )} -
- ); -}; - -const FilePreviewItem = ({ id }: PreviewItemProps) => { - const { fileUploads, removeFile, uploadFile } = useMessageInputContext('FilePreviewItem'); - - const handleRemove = useCallback( - (id: string) => { - removeFile(id); - }, - [removeFile], - ); - const handleRetry = useCallback( - (attachment: LocalAttachment) => attachment.$internal && uploadFile(attachment.$internal.id), - [uploadFile], - ); - - const file = fileUploads[id]; - - if (!file) return null; - - const attachment: LocalAttachment = { - $internal: { - file: file.file as File, - id, - uploadState: file.state, - }, - asset_url: file.url, - mime_type: file.file.type, - title: file.file.name, - }; - - return ( - - ); -}; diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx new file mode 100644 index 0000000000..ad4cbc11b7 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -0,0 +1,135 @@ +import React, { ComponentType } from 'react'; +import { + UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview, + UnsupportedAttachmentPreviewProps, +} from './UnsupportedAttachmentPreview'; +import { + VoiceRecordingPreview as DefaultVoiceRecordingPreview, + VoiceRecordingPreviewProps, +} from './VoiceRecordingPreview'; +import { + FileAttachmentPreview as DefaultFilePreview, + FileAttachmentPreviewProps, +} from './FileAttachmentPreview'; +import { FileUploadPreviewAdapter, ImageUploadPreviewAdapter } from './UploadPreviewItem'; +import { + ImageAttachmentPreview as DefaultImagePreview, + ImageAttachmentPreviewProps, +} from './ImageAttachmentPreview'; +import { + isLocalAttachment, + isLocalAudioAttachment, + isLocalFileAttachment, + isLocalImageAttachment, + isLocalMediaAttachment, + isLocalVoiceRecordingAttachment, +} from '../../Attachment'; +import { useMessageInputContext } from '../../../context'; + +import type { DefaultStreamChatGenerics } from '../../../types'; + +export type AttachmentPreviewListProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + AudioAttachmentPreview?: ComponentType; + FileAttachmentPreview?: ComponentType; + ImageAttachmentPreview?: ComponentType>; + UnsupportedAttachmentPreview?: ComponentType< + UnsupportedAttachmentPreviewProps + >; + VideoAttachmentPreview?: ComponentType; + VoiceRecordingPreview?: ComponentType>; +}; + +export const AttachmentPreviewList = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + AudioAttachmentPreview = DefaultFilePreview, + FileAttachmentPreview = DefaultFilePreview, + ImageAttachmentPreview = DefaultImagePreview, + UnsupportedAttachmentPreview = DefaultUnknownAttachmentPreview, + VideoAttachmentPreview = DefaultFilePreview, + VoiceRecordingPreview = DefaultVoiceRecordingPreview, +}: AttachmentPreviewListProps) => { + const { + attachments, + fileOrder, + imageOrder, + removeAttachments, + uploadAttachment, + } = useMessageInputContext('AttachmentPreviewList'); + + return ( +
+
+ {attachments.map((attachment) => { + if (isLocalVoiceRecordingAttachment(attachment)) { + return ( + + ); + } else if (isLocalAudioAttachment(attachment)) { + return ( + + ); + } else if (isLocalMediaAttachment(attachment)) { + return ( + + ); + } else if (isLocalImageAttachment(attachment)) { + return ( + + ); + } else if (isLocalFileAttachment(attachment)) { + return ( + + ); + } else if (isLocalAttachment(attachment)) { + return ( + + ); + } + return null; + })} + {imageOrder.map((id) => ( + + ))} + {fileOrder.map((id) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx new file mode 100644 index 0000000000..5414061312 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FileIcon } from '../../ReactFileUtilities'; +import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import type { AttachmentPreviewProps } from './types'; +import { LocalAttachmentCast, LocalAttachmentUploadMetadata } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +type FileLikeAttachment = { + asset_url?: string; + mime_type?: string; + title?: string; +}; + +export type FileAttachmentPreviewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = AttachmentPreviewProps< + LocalAttachmentCast, + StreamChatGenerics +>; + +export const FileAttachmentPreview = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + attachment, + handleRetry, + removeAttachments, +}: FileAttachmentPreviewProps) => ( +
+
+ +
+ + + + {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( + + )} + +
+
+ {attachment.title} +
+ {attachment.localMetadata?.uploadState === 'finished' && !!attachment.asset_url && ( + + + + )} + {attachment.localMetadata?.uploadState === 'uploading' && } +
+
+); diff --git a/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx new file mode 100644 index 0000000000..6f70cfdfce --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/ImageAttachmentPreview.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx'; +import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import React, { useCallback, useState } from 'react'; +import { BaseImage as DefaultBaseImage } from '../../Gallery'; +import { useComponentContext } from '../../../context'; +import type { AttachmentPreviewProps } from './types'; +import type { LocalImageAttachment } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +export type ImageAttachmentPreviewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = AttachmentPreviewProps< + LocalImageAttachment, + StreamChatGenerics +>; + +export const ImageAttachmentPreview = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + attachment, + handleRetry, + removeAttachments, +}: ImageAttachmentPreviewProps) => { + const { BaseImage = DefaultBaseImage } = useComponentContext('ImagePreview'); + const [previewError, setPreviewError] = useState(false); + + const { id, uploadState } = attachment.localMetadata ?? {}; + + const handleLoadError = useCallback(() => setPreviewError(true), []); + + return ( +
+ + + {uploadState === 'failed' && ( + + )} + + {uploadState === 'uploading' && ( +
+ +
+ )} + + {attachment.image_url && ( + + )} +
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx new file mode 100644 index 0000000000..cfbec5fda4 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/UnsupportedAttachmentPreview.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { CloseIcon, DownloadIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import { FileIcon } from '../../ReactFileUtilities'; +import { useTranslationContext } from '../../../context'; +import type { AttachmentPreviewProps } from './types'; +import type { AnyLocalAttachment } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +export type UnsupportedAttachmentPreviewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = AttachmentPreviewProps< + AnyLocalAttachment, + StreamChatGenerics +>; + +export const UnsupportedAttachmentPreview = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + attachment, + handleRetry, + removeAttachments, +}: UnsupportedAttachmentPreviewProps) => { + const { t } = useTranslationContext('UnsupportedAttachmentPreview'); + const title = attachment.title ?? t('Unsupported attachment'); + return ( +
+
+ +
+ + + + {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( + + )} + +
+
+ {title} +
+ {attachment.localMetadata?.uploadState === 'finished' && !!attachment.asset_url && ( + + + + )} + {attachment.localMetadata?.uploadState === 'uploading' && ( + + )} +
+
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/UploadPreviewItem.tsx b/src/components/MessageInput/AttachmentPreviewList/UploadPreviewItem.tsx new file mode 100644 index 0000000000..e6997b0ed7 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/UploadPreviewItem.tsx @@ -0,0 +1,107 @@ +import React, { ComponentType, useCallback, useMemo } from 'react'; +import { FileAttachmentPreview, FileAttachmentPreviewProps } from './FileAttachmentPreview'; +import { ImageAttachmentPreview, ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; +import { useMessageInputContext } from '../../../context'; +import { LocalAttachment, LocalFileAttachment, LocalImageAttachment } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +type PreviewAdapterProps

= { id: string; Preview?: ComponentType

}; +export const ImageUploadPreviewAdapter = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + id, + Preview = ImageAttachmentPreview, +}: PreviewAdapterProps>) => { + const { imageUploads, removeImage, uploadImage } = useMessageInputContext( + 'ImageUploadPreviewAdapter', + ); + + const removeAttachments = useCallback((ids: string[]) => removeImage(ids[0]), [removeImage]); + + const handleRetry = useCallback( + (attachment: LocalAttachment) => + attachment.localMetadata && uploadImage(attachment.localMetadata.id), + [uploadImage], + ); + + const image = imageUploads[id]; + const attachment = useMemo | undefined>( + () => + // do not display scraped attachments + !image || image.og_scrape_url + ? undefined + : { + image_url: image.previewUri ?? image.url, + localMetadata: { + file: image.file as File, + id, + uploadState: image.state, + }, + title: image.file.name, + type: 'image', + }, + [id, image], + ); + + if (!attachment) return null; + + return ( + + ); +}; + +export const FileUploadPreviewAdapter = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + id, + Preview = FileAttachmentPreview, +}: PreviewAdapterProps) => { + const { fileUploads, removeFile, uploadFile } = useMessageInputContext( + 'FileUploadPreviewAdapter', + ); + + const removeAttachments = useCallback( + (ids: string[]) => { + removeFile(ids[0]); + }, + [removeFile], + ); + const handleRetry = useCallback( + (attachment: LocalAttachment) => + attachment.localMetadata && uploadFile(attachment.localMetadata.id), + [uploadFile], + ); + + const file = fileUploads[id]; + const attachment = useMemo | undefined>( + () => + !file + ? undefined + : { + asset_url: file.url, + localMetadata: { + file: file.file as File, + id, + uploadState: file.state, + }, + mime_type: file.file.type, + title: file.file.name, + type: 'file', + }, + [file, id], + ); + + if (!attachment) return null; + + return ( + + ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx new file mode 100644 index 0000000000..5a29ea4343 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/VoiceRecordingPreview.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { PlayButton } from '../../Attachment'; +import { RecordingTimer } from '../../MediaRecorder'; +import { CloseIcon, LoadingIndicatorIcon, RetryIcon } from '../icons'; +import { FileIcon } from '../../ReactFileUtilities'; +import { useAudioController } from '../../Attachment/hooks/useAudioController'; +import type { AttachmentPreviewProps } from './types'; +import type { LocalVoiceRecordingAttachment } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +export type VoiceRecordingPreviewProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = AttachmentPreviewProps< + LocalVoiceRecordingAttachment, + StreamChatGenerics +>; + +export const VoiceRecordingPreview = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + attachment, + handleRetry, + removeAttachments, +}: VoiceRecordingPreviewProps) => { + const { audioRef, isPlaying, secondsElapsed, togglePlay } = useAudioController({ + mimeType: attachment.mime_type, + }); + + return ( +

+ + + + + + {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( + + )} + +
+
+ {attachment.title} +
+ {typeof attachment.duration !== 'undefined' && ( + + )} + {attachment.localMetadata?.uploadState === 'uploading' && ( + + )} +
+
+ +
+
+ ); +}; diff --git a/src/components/MessageInput/AttachmentPreviewList/index.ts b/src/components/MessageInput/AttachmentPreviewList/index.ts new file mode 100644 index 0000000000..8156d1e361 --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/index.ts @@ -0,0 +1,6 @@ +export * from './AttachmentPreviewList'; +export type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; +export type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; +export type { AttachmentPreviewProps } from './types'; +export type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; +export type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; diff --git a/src/components/MessageInput/AttachmentPreviewList/types.ts b/src/components/MessageInput/AttachmentPreviewList/types.ts new file mode 100644 index 0000000000..86ea32514f --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/types.ts @@ -0,0 +1,13 @@ +import { LocalAttachment } from '../types'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +export type AttachmentPreviewProps< + A extends LocalAttachment, + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + attachment: A; + handleRetry: ( + attachment: LocalAttachment, + ) => void | Promise | undefined>; + removeAttachments: (ids: string[]) => void; +}; diff --git a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js index f16ad43f0f..18aab77bfb 100644 --- a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js +++ b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js @@ -7,20 +7,27 @@ import '@testing-library/jest-dom'; import { Chat } from '../../Chat'; import { Channel } from '../../Channel'; -import { AttachmentPreviewList, ImagePreviewItem } from '../AttachmentPreviewList'; +import { AttachmentPreviewList } from '../AttachmentPreviewList'; +import { ImageUploadPreviewAdapter } from '../AttachmentPreviewList/UploadPreviewItem'; import { ChannelActionProvider, ComponentProvider, useChatContext } from '../../../context'; import { MessageInputContextProvider } from '../../../context/MessageInputContext'; import { generateAudioAttachment, + generateFileAttachment, + generateImageAttachment, generateUpload, generateVideoAttachment, generateVoiceRecordingAttachment, initClientWithChannels, } from '../../../mock-builders'; +jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(); + const RETRY_BTN_TEST_ID = 'file-preview-item-retry-button'; +const RETRY_BTN_IMAGE_TEST_ID = 'image-preview-item-retry-button'; const DELETE_BTN_TEST_ID = 'file-preview-item-delete-button'; +const DELETE_BTN_IMAGE_TEST_ID = 'image-preview-item-delete-button'; const LOADING_INDICATOR_TEST_ID = 'loading-indicator'; const uploadsReducer = (pv, cv) => { @@ -43,7 +50,7 @@ const generateMessageInputContextValue = ({ attachments = [], files = [], images fileUploads: files.reduce(uploadsReducer, {}), imageOrder: images.map(orderMapper), imageUploads: images.reduce(uploadsReducer, {}), - removeAttachment: jest.fn(), + removeAttachments: jest.fn(), removeFile: jest.fn(), removeImage: jest.fn(), uploadAttachment: jest.fn(), @@ -51,12 +58,14 @@ const generateMessageInputContextValue = ({ attachments = [], files = [], images uploadImage: jest.fn(), }); -const renderComponent = (value = {}, renderFunction = render) => - renderFunction( +const renderComponent = ({ contextValue, props, renderFunction } = {}) => + (renderFunction ?? render)( - - + + , @@ -74,21 +83,20 @@ describe('AttachmentPreviewList', () => { expect(attachmentList).toBeEmptyDOMElement(); }); - it.each(['uploading', 'failed', 'finished'])('renders previews with state "%s"', (state) => { - renderComponent( - generateMessageInputContextValue({ + renderComponent({ + contextValue: generateMessageInputContextValue({ attachments: [ generateAudioAttachment({ - $internal: { uploadState: state }, + localMetadata: { id: 'audio-attachment-id', uploadState: state }, title: `audio-attachment-${state}`, }), generateVoiceRecordingAttachment({ - $internal: { uploadState: state }, + localMetadata: { id: 'voice-recording-attachment-id', uploadState: state }, title: `voice-recording-attachment-${state}`, }), generateVideoAttachment({ - $internal: { uploadState: state }, + localMetadata: { id: 'video-attachment-id', uploadState: state }, title: `video-attachment-${state}`, }), ], @@ -105,8 +113,7 @@ describe('AttachmentPreviewList', () => { }), ], }), - render, - ); + }); expect(screen.getByTitle(`file-upload-${state}`)).toBeInTheDocument(); expect(screen.getByTitle(`image-upload-${state}`)).toBeInTheDocument(); @@ -115,158 +122,224 @@ describe('AttachmentPreviewList', () => { expect(screen.getByTitle(`video-attachment-${state}`)).toBeInTheDocument(); }); - it.each(['file', 'image'])('retries upload on click with %s', (type) => { - const file = generateUpload({ - fileOverrides: { type }, - objectOverrides: { state: 'failed' }, - }); - - const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); + describe.each(['file', 'image'])('%s uploads previews', (type) => { + it('retries upload on click', () => { + const file = generateUpload({ + fileOverrides: { type }, + objectOverrides: { state: 'failed' }, + }); - const { getByTestId } = renderComponent(contextValue); + const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); - const retryButton = getByTestId(`${type}-preview-item-retry-button`); + const { getByTestId } = renderComponent({ contextValue }); - fireEvent.click(retryButton); + const retryButton = getByTestId(`${type}-preview-item-retry-button`); - expect(contextValue[`upload${capitalize(type)}`]).toHaveBeenCalledWith(file.id); - }); + fireEvent.click(retryButton); - it.each(['audio', 'voiceRecording', 'video'])('retries upload on click with %s', (type) => { - const state = 'failed'; - const title = `${type}-attachment-${state}`; - const generate = { - audio: generateAudioAttachment, - video: generateVideoAttachment, - voiceRecording: generateVoiceRecordingAttachment, - }; - const uploadedAttachmentData = generate[type]({ - title, + expect(contextValue[`upload${capitalize(type)}`]).toHaveBeenCalledWith(file.id); }); - const localAttachment = { ...uploadedAttachmentData, $internal: { uploadState: state } }; - const contextValue = generateMessageInputContextValue({ - attachments: [localAttachment], + it('renders loading indicator', () => { + const file = generateUpload({ + fileOverrides: { type }, + objectOverrides: { state: 'uploading' }, + }); + + const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); + + renderComponent({ contextValue }); + + expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); }); - renderComponent(contextValue); + it('removes from state on button click', () => { + const file = generateUpload({ + fileOverrides: { type }, + objectOverrides: { state: 'finished' }, + }); - const retryButton = screen.getByTestId(RETRY_BTN_TEST_ID); + const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); - fireEvent.click(retryButton); + const { getByTestId } = renderComponent({ contextValue }); - expect(contextValue.uploadAttachment).toHaveBeenCalledWith( - expect.objectContaining(uploadedAttachmentData), - ); - }); + const deleteButton = getByTestId(`${type}-preview-item-delete-button`); - it.each(['file', 'image'])('renders loading indicator for %s preview', (type) => { - const file = generateUpload({ - fileOverrides: { type }, - objectOverrides: { state: 'uploading' }, - }); + fireEvent.click(deleteButton); - const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); + expect(contextValue[`remove${capitalize(type)}`]).toHaveBeenCalledWith(file.id); + }); - renderComponent(contextValue); + it('renders custom component', () => { + const file = generateUpload({ + fileOverrides: { type }, + }); + const text = `Custom ${type} component`; + const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); + const CustomPreview = () =>
{text}
; + renderComponent({ + contextValue, + props: { + [type === 'image' ? 'ImageAttachmentPreview' : 'FileAttachmentPreview']: CustomPreview, + }, + }); - expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument(); - expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); + expect(screen.queryByText(text)).toBeInTheDocument(); + }); }); - it.each(['audio', 'voiceRecording', 'video'])( - 'renders loading indicator for %s preview', + describe.each(['audio', 'file', 'image', 'unsupported', 'voiceRecording', 'video'])( + '%s attachments rendering', (type) => { - const state = 'uploading'; - const title = `${type}-attachment-${state}`; + const customAttachment = { + id: new Date().toISOString(), + latitude: 456, + longitude: 123, + mimeType: 'text/plain', + title: 'custom-title.txt', + type: 'geolocation', + }; + const generate = { audio: generateAudioAttachment, + file: generateFileAttachment, + image: generateImageAttachment, + unsupported: () => customAttachment, video: generateVideoAttachment, voiceRecording: generateVoiceRecordingAttachment, }; - const uploadedAttachmentData = generate[type]({ - title, - }); - const localAttachment = { ...uploadedAttachmentData, $internal: { uploadState: state } }; - const contextValue = generateMessageInputContextValue({ - attachments: [localAttachment], - }); + it('retries upload on upload button click', () => { + const state = 'failed'; + const title = `${type}-attachment-${state}`; + const uploadedAttachmentData = generate[type]({ + title, + }); + const localAttachment = { + ...uploadedAttachmentData, + localMetadata: { id: new Date().toISOString(), uploadState: state }, + }; - renderComponent(contextValue); + const contextValue = generateMessageInputContextValue({ + attachments: [localAttachment], + }); - expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument(); - expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); - }, - ); + renderComponent({ contextValue }); - it.each(['file', 'image'])('tests "remove" click on %s upload', (type) => { - const file = generateUpload({ - fileOverrides: { type }, - objectOverrides: { state: 'finished' }, - }); + const retryButton = screen.getByTestId( + type === 'image' ? RETRY_BTN_IMAGE_TEST_ID : RETRY_BTN_TEST_ID, + ); - const contextValue = generateMessageInputContextValue({ [`${type}s`]: [file] }); + fireEvent.click(retryButton); - const { getByTestId } = renderComponent(contextValue); + expect(contextValue.uploadAttachment).toHaveBeenCalledWith( + expect.objectContaining(uploadedAttachmentData), + ); + }); - const deleteButton = getByTestId(`${type}-preview-item-delete-button`); + it('renders loading indicator in preview', () => { + const state = 'uploading'; + const title = `${type}-attachment-${state}`; + const uploadedAttachmentData = generate[type]({ + title, + }); + const localAttachment = { + ...uploadedAttachmentData, + localMetadata: { id: new Date().toISOString(), uploadState: state }, + }; + + const contextValue = generateMessageInputContextValue({ + attachments: [localAttachment], + }); + + renderComponent({ contextValue }); + + expect(screen.queryByTestId(LOADING_INDICATOR_TEST_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); + }); - fireEvent.click(deleteButton); + it('removes retry button on successful upload', () => { + const state = 'finished'; + const title = `${type}-attachment-${state}`; + const uploadedAttachmentData = generate[type]({ + title, + }); + const localAttachment = { + ...uploadedAttachmentData, + localMetadata: { id: new Date().toISOString(), uploadState: state }, + }; - expect(contextValue[`remove${capitalize(type)}`]).toHaveBeenCalledWith(file.id); - }); + const contextValue = generateMessageInputContextValue({ + attachments: [localAttachment], + }); - it.each(['audio', 'voiceRecording', 'video'])( - 'removes retry button on %s successful upload', - (type) => { - const state = 'finished'; - const title = `${type}-attachment-${state}`; - const generate = { - audio: generateAudioAttachment, - video: generateVideoAttachment, - voiceRecording: generateVoiceRecordingAttachment, - }; - const uploadedAttachmentData = generate[type]({ - title, - }); - const localAttachment = { ...uploadedAttachmentData, $internal: { uploadState: state } }; + renderComponent({ contextValue }); - const contextValue = generateMessageInputContextValue({ - attachments: [localAttachment], + expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); }); - renderComponent(contextValue); + it('removes the preview', () => { + const state = 'finished'; + const title = `${type}-attachment-${state}`; + const id = `${type}-id`; + const uploadedAttachmentData = generate[type]({ + title, + }); + const localAttachment = { + ...uploadedAttachmentData, + localMetadata: { id, uploadState: state }, + }; + + const contextValue = generateMessageInputContextValue({ + attachments: [localAttachment], + }); + + renderComponent({ contextValue }); + + fireEvent.click( + screen.getByTestId(type === 'image' ? DELETE_BTN_IMAGE_TEST_ID : DELETE_BTN_TEST_ID), + ); + + expect(contextValue.removeAttachments).toHaveBeenCalledWith([ + localAttachment.localMetadata.id, + ]); + }); - expect(screen.queryByTestId(RETRY_BTN_TEST_ID)).not.toBeInTheDocument(); + it('renders custom preview component', () => { + const previewComponentNames = { + audio: 'AudioAttachmentPreview', + file: 'FileAttachmentPreview', + image: 'ImageAttachmentPreview', + unsupported: 'UnsupportedAttachmentPreview', + video: 'VideoAttachmentPreview', + voiceRecording: 'VoiceRecordingPreview', + }; + const title = `${type}-attachment`; + const id = `${type}-id`; + const uploadedAttachmentData = generate[type]({ + title, + }); + const localAttachment = { + ...uploadedAttachmentData, + localMetadata: { id }, + }; + + const contextValue = generateMessageInputContextValue({ + attachments: [localAttachment], + }); + const text = `custom-${title}`; + const CustomPreviewComponent = () =>
{text}
; + renderComponent({ + contextValue, + props: { [previewComponentNames[type]]: CustomPreviewComponent }, + }); + + expect(screen.queryByText(text)).toBeInTheDocument(); + }); }, ); - it.each(['audio', 'voiceRecording', 'video'])('removes the %s preview', (type) => { - const state = 'finished'; - const title = `${type}-attachment-${state}`; - const id = `${type}-id`; - const generate = { - audio: generateAudioAttachment, - video: generateVideoAttachment, - voiceRecording: generateVoiceRecordingAttachment, - }; - const uploadedAttachmentData = generate[type]({ - title, - }); - const localAttachment = { ...uploadedAttachmentData, $internal: { id, uploadState: state } }; - - const contextValue = generateMessageInputContextValue({ - attachments: [localAttachment], - }); - - renderComponent(contextValue); - - fireEvent.click(screen.getByTestId(DELETE_BTN_TEST_ID)); - - expect(contextValue.removeAttachment).toHaveBeenCalledWith(localAttachment.$internal.id); - }); - it('should render custom BaseImage component', async () => { const ActiveChannelSetter = ({ activeChannel }) => { const { setActiveChannel } = useChatContext(); @@ -307,7 +380,7 @@ describe('AttachmentPreviewList', () => { }); }); -describe('ImagePreviewItem', () => { +describe('ImageUploadPreviewAdapter', () => { const BASE_IMAGE_TEST_ID = 'str-chat__base-image'; const getImage = () => screen.queryByTestId(BASE_IMAGE_TEST_ID); const defaultId = '7VZCBda5mQQk49icgNaUJ'; @@ -327,55 +400,55 @@ describe('ImagePreviewItem', () => { removeImage: jest.fn(), uploadImage: jest.fn(), }; - const renderImagePreviewItem = ({ id, ...inputContext } = {}) => + const renderImageUploadPreviewAdapter = ({ id, ...inputContext } = {}) => render( - + , ); it('does not render images not found in the input attachment state', () => { - const { container } = renderImagePreviewItem({ id: 'X' }); + const { container } = renderImageUploadPreviewAdapter({ id: 'X' }); expect(container).toBeEmptyDOMElement(); }); it('does not render scraped images', () => { - const { container } = renderImagePreviewItem({ + const { container } = renderImageUploadPreviewAdapter({ imageUploads: { [defaultId]: { ...imageUploads[defaultId], og_scrape_url: 'og_scrape_url' } }, }); expect(container).toBeEmptyDOMElement(); }); it('renders uploading state', () => { - const { container } = renderImagePreviewItem({ + const { container } = renderImageUploadPreviewAdapter({ imageUploads: { [defaultId]: { ...imageUploads[defaultId], state: 'uploading' } }, }); expect(container).toMatchSnapshot(); }); it('renders upload finished state', () => { - const { container } = renderImagePreviewItem(); + const { container } = renderImageUploadPreviewAdapter(); expect(container).toMatchSnapshot(); }); it('renders upload failed state', () => { - const { container } = renderImagePreviewItem({ + const { container } = renderImageUploadPreviewAdapter({ imageUploads: { [defaultId]: { ...imageUploads[defaultId], state: 'failed' } }, }); expect(container).toMatchSnapshot(); }); it('reflects the image load error with str-chat__attachment-preview-image--error class', () => { - const { container } = renderImagePreviewItem(); + const { container } = renderImageUploadPreviewAdapter(); fireEvent.error(getImage()); expect(container.children[0].className).toMatch('str-chat__attachment-preview-image--error'); }); it('asks to retry uploading the image', () => { - renderImagePreviewItem({ + renderImageUploadPreviewAdapter({ imageUploads: { [defaultId]: { ...imageUploads[defaultId], state: 'failed' } }, }); fireEvent.click(screen.getByTestId('image-preview-item-retry-button')); expect(defaultInputContext.uploadImage).toHaveBeenCalledTimes(1); }); it('asks to remove image from input attachment state', () => { - renderImagePreviewItem(); + renderImageUploadPreviewAdapter(); fireEvent.click(screen.getByTestId('image-preview-item-delete-button')); expect(defaultInputContext.removeImage).toHaveBeenCalledTimes(1); }); diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index 2b56d5d8be..7bda493bc5 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -59,7 +59,7 @@ const mockedChannelData = generateChannel({ const cooldown = 30; const filename = 'some.txt'; -const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImagePreview will try to load the image +const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttachmentPreview will try to load the image const getImage = () => new File(['content'], filename, { type: 'image/png' }); const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' }); diff --git a/src/components/MessageInput/__tests__/__snapshots__/AttachmentPreviewList.test.js.snap b/src/components/MessageInput/__tests__/__snapshots__/AttachmentPreviewList.test.js.snap index 6eb4540955..aaa02bc62c 100644 --- a/src/components/MessageInput/__tests__/__snapshots__/AttachmentPreviewList.test.js.snap +++ b/src/components/MessageInput/__tests__/__snapshots__/AttachmentPreviewList.test.js.snap @@ -167,7 +167,7 @@ exports[`AttachmentPreviewList should render custom BaseImage component 1`] = `
`; -exports[`ImagePreviewItem renders upload failed state 1`] = ` +exports[`ImageUploadPreviewAdapter renders upload failed state 1`] = `
`; -exports[`ImagePreviewItem renders upload finished state 1`] = ` +exports[`ImageUploadPreviewAdapter renders upload finished state 1`] = `
`; -exports[`ImagePreviewItem renders uploading state 1`] = ` +exports[`ImageUploadPreviewAdapter renders uploading state 1`] = ` +
+
+ +
+
+ + + + + + + + + +
+
+ +
+
+`; + +exports[`ImageUploadPreviewAdapter renders upload failed state 1`] = ` +
+
+ + + +
+
+`; + +exports[`ImageUploadPreviewAdapter renders upload finished state 1`] = ` +
+
+ + +
+
+`; + +exports[`ImageUploadPreviewAdapter renders uploading state 1`] = `
( + + + + {children} + + + + ); + return renderHook(() => useMessageInputState(props ?? {}), { wrapper }); +} +describe('useMessageInputState', () => { + afterEach(jest.resetAllMocks); + + it('initiates an empty state', async () => { + const { + result: { current }, + } = await renderUseMessageInputStateHook(); + expect(current.attachments).toHaveLength(0); + expect(current.fileOrder).toHaveLength(0); + expect(current.fileUploads).toStrictEqual(expect.objectContaining({})); + expect(current.imageOrder).toStrictEqual(expect.objectContaining({})); + expect(current.imageUploads).toStrictEqual(expect.objectContaining({})); + expect(current.linkPreviews.size).toBe(0); + expect(current.mentioned_users).toHaveLength(0); + expect(current.text).toBe(''); + }); + + it('initiates the state from provided message', async () => { + const { + result: { current }, + } = await renderUseMessageInputStateHook({ props: { message } }); + expect(current.attachments).toHaveLength( + message.attachments.length - 2, // -2 for 1 file attachment & 1 image attachment + ); + expect(current.fileOrder).toHaveLength(1); + expect(Object.values(current.fileUploads)).toHaveLength(1); + expect(Object.values(current.fileUploads)[0]).toMatchObject( + expect.objectContaining({ + file: { + name: fileAttachment.title, + size: fileAttachment.file_size, + type: fileAttachment.mime_type, + }, + id: expect.any(String), + state: 'finished', + thumb_url: fileAttachment.thumb_url, + url: fileAttachment.asset_url, + }), + ); + expect(current.imageOrder).toHaveLength(1); + expect(Object.values(current.imageUploads)).toHaveLength(1); + expect(Object.values(current.imageUploads)[0]).toMatchObject( + expect.objectContaining({ + author_name: imageAttachment.author_name, + file: { + name: imageAttachment.fallback, + }, + id: expect.any(String), + og_scrape_url: imageAttachment.og_scrape_url, + state: 'finished', + text: imageAttachment.text, + title: imageAttachment.title, + title_link: imageAttachment.title_link, + url: imageAttachment.image_url, + }), + ); + expect(current.linkPreviews.size).toBe(linkPreviewAttachments.length); + expect(current.mentioned_users).toHaveLength(message.mentioned_users.length); + expect(current.text).toBe(message.text); + }); + + describe('useAttachments', () => { + describe('upsert attachments', () => { + it('does not change current attachment state if no attachments provided', async () => { + const { result } = await renderUseMessageInputStateHook(); + result.current.upsertAttachments([]); + expect(result.current.attachments).toHaveLength(0); + }); + it('adds new attachments', async () => { + const { result } = await renderUseMessageInputStateHook(); + await act(() => { + result.current.upsertAttachments(message.attachments); + }); + expect(result.current.attachments).toHaveLength(message.attachments.length); + result.current.attachments.forEach((resultAttachment, i) => { + expect(resultAttachment).toMatchObject( + expect.objectContaining({ + localMetadata: expect.objectContaining({ id: expect.any(String) }), + ...message.attachments[i], + }), + ); + }); + }); + it('updates existing attachments', async () => { + const { result } = await renderUseMessageInputStateHook({ props: { message } }); + const updatedAttachments = result.current.attachments + .slice(3) + .map((att) => ({ ...att, extra: 'extra' })); + const addedAttachments = [generateImageAttachment()]; + updatedAttachments.push(...addedAttachments); + const attachmentsAfterUpdate = [ + ...result.current.attachments.slice(0, 3), + ...updatedAttachments, + ]; + + await act(() => { + result.current.upsertAttachments(updatedAttachments); + }); + + expect(result.current.attachments).toHaveLength(attachmentsAfterUpdate.length); + result.current.attachments.forEach((resultAttachment, i) => { + expect(resultAttachment).toMatchObject( + expect.objectContaining({ + localMetadata: expect.objectContaining({ id: expect.any(String) }), + ...attachmentsAfterUpdate[i], + }), + ); + }); + }); + }); + describe('remove attachment', () => { + it('removes existing attachments', async () => { + const removeUpToIndex = 3; + const { result } = await renderUseMessageInputStateHook({ props: { message } }); + const originalCount = result.current.attachments.length; + await act(() => { + result.current.removeAttachments( + result.current.attachments + .slice(0, removeUpToIndex) + .map(({ localMetadata: { id } }) => id), + ); + }); + const attachmentsLeft = result.current.attachments.slice(removeUpToIndex); + + expect(result.current.attachments).toHaveLength(originalCount - removeUpToIndex); + + result.current.attachments.forEach((resultAttachment, i) => { + expect(resultAttachment).toMatchObject( + expect.objectContaining({ + localMetadata: expect.objectContaining({ id: expect.any(String) }), + ...attachmentsLeft[i], + }), + ); + }); + }); + it('keeps the original attachment state if attachment not found', async () => { + const { result } = await renderUseMessageInputStateHook({ props: { message } }); + const originalAttachments = result.current.attachments; + + await act(() => { + result.current.removeAttachments( + Array.from({ length: 3 }, () => new Date().toISOString()), + ); + }); + + const attachmentsAfterRemoval = result.current.attachments; + expect(originalAttachments).toStrictEqual(attachmentsAfterRemoval); + }); + }); + describe('upload attachment', () => { + const customAttachment = { + id: new Date().toISOString(), + latitude: 456, + longitude: 123, + mimeType: 'text/plain', + title: 'custom-title.txt', + type: 'geolocation', + }; + + const generateAttachment = { + audio: generateAudioAttachment, + custom: () => customAttachment, + file: generateFileAttachment, + image: generateImageAttachment, + video: generateVideoAttachment, + voiceRecording: generateVoiceRecordingAttachment, + }; + + // eslint-disable-next-line jest/no-commented-out-tests + describe.each([['audio'], ['file'], ['image'], ['video'], ['voiceRecording'], ['custom']])( + 'of type %s', + (type) => { + const data = generateAttachment[type](); + const fileDataOverrides = { + file: { + name: type === 'image' ? data.fallback : data.title, + type: type === 'image' ? data.fallback.split('.')[1] : data.mime_type ?? '', + }, + }; + const attachment = { + ...(type === 'image' + ? generateLocalImageUploadAttachmentData(fileDataOverrides) + : generateLocalFileUploadAttachmentData(fileDataOverrides)), + ...data, + }; + const getAppSettings = jest.fn(); + + it('does not upload attachment if file is missing', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const sendFileSpy = jest.spyOn(channel, 'sendFile').mockResolvedValue({}); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + }); + const att = { ...attachment, localMetadata: { ...attachment.localMetadata } }; + delete att.localMetadata.file; + await act(async () => { + await result.current.uploadAttachment(att); + }); + expect(sendFileSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'not among allowed file extensions', + { + allowed_file_extensions: [new Date().toISOString()], + }, + ], + [ + 'uploading file with blocked extension', + { + blocked_file_extensions: attachment.localMetadata.file.type?.split('/').slice(-1), + }, + ], + [ + 'mime_type not allowed', + { + allowed_mime_types: [new Date().toISOString()], + }, + ], + [ + 'uploading file with blocked mime_type', + { + blocked_mime_types: [attachment.localMetadata.file.type], + }, + ], + [ + 'file exceeds allowed size', + { + size_limit: attachment.localMetadata.file.size - 1, + }, + ], + ])('does not upload attachment if %s in app config', async (_, appConfig) => { + getAppSettings.mockReturnValueOnce({ + app: { + [type === 'image' ? 'image_upload_config' : 'file_upload_config']: appConfig, + }, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const originalConsoleError = console.error; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args) => { + if (args[0].message.match('Missing permissions to upload the attachment')) return; + originalConsoleError(...args); + }); + const sendFileSpy = jest.spyOn(channel, 'sendFile').mockResolvedValue({}); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + }); + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + expect(sendFileSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('marks attachment as being uploaded', async () => { + const assetUrl = 'asset-url'; + const { + channels: [channel], + client, + } = await initClientWithChannels(); + jest + .spyOn(channel, type === 'image' ? 'sendImage' : 'sendFile') + .mockResolvedValue({ file: assetUrl }); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + }); + if (type === 'image') { + expect(attachment.localMetadata.previewUri).toBeDefined(); + } + + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + + expect(result.all).toHaveLength(3); + expect(result.all[0].attachments).toHaveLength(0); + expect(result.all[1].attachments).toHaveLength(1); + expect(result.all[2].attachments).toHaveLength(1); + // cannot test result.all[1].attachments[0].localMetadata.uploadState === 'uploading' + // as the value is getting overridden by the current result + expect(result.all[2].attachments[0].localMetadata.uploadState).toBe('finished'); + + if (type === 'image') { + expect(result.all[2].attachments[0].image_url).toBe(assetUrl); + expect(result.all[2].attachments[0].localMetadata.previewUri).toBeUndefined(); + } else { + expect(result.all[2].attachments[0].asset_url).toBe(assetUrl); + } + }); + + it('uses custom upload function', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const customSendSpy = jest.fn(); + const sendFileSpy = jest.spyOn(channel, 'sendFile').mockResolvedValue({}); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + props: { + [type === 'image' ? 'doImageUploadRequest' : 'doFileUploadRequest']: customSendSpy, + }, + }); + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(customSendSpy).toHaveBeenCalledTimes(1); + }); + + it('removes attachment if upload response is falsy', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + jest + .spyOn(channel, type === 'image' ? 'sendImage' : 'sendFile') + .mockResolvedValue(undefined); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + }); + + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + + expect(result.all).toHaveLength(3); + expect(result.all[0].attachments).toHaveLength(0); + expect(result.all[1].attachments).toHaveLength(1); + expect(result.all[2].attachments).toHaveLength(0); + }); + + const errMsg = 'Went wrong'; + const errMsgCustom = 'Went wrong custom'; + + it('invokes custom error handler', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + jest.spyOn(channel, 'sendFile').mockRejectedValue(new Error(errMsg)); + const originalConsoleError = console.error; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args) => { + if (args[0].message === errMsg) return; + originalConsoleError(...args); + }); + const customSendSpy = jest.fn(); + const sendFileSpy = jest.spyOn(channel, 'sendFile').mockResolvedValue({}); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + props: { + [type === 'image' ? 'doImageUploadRequest' : 'doFileUploadRequest']: customSendSpy, + }, + }); + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + expect(sendFileSpy).not.toHaveBeenCalled(); + expect(customSendSpy).toHaveBeenCalledTimes(1); + consoleErrorSpy.mockRestore(); + }); + + it.each([['default'], ['custom']])( + 'marks attachment as failed on upload error of %s function', + async (scenario) => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const customSendSpy = jest.fn().mockRejectedValue(new Error(errMsgCustom)); + jest + .spyOn(channel, type === 'image' ? 'sendImage' : 'sendFile') + .mockRejectedValue(new Error(errMsg)); + const originalConsoleError = console.error; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args) => { + if ([errMsg, errMsgCustom].includes(args[0].message)) return; + originalConsoleError(...args); + }); + const { result } = await renderUseMessageInputStateHook({ + channel, + chatContext: { getAppSettings }, + client, + props: + scenario === 'custom' + ? { + [type === 'image' + ? 'doImageUploadRequest' + : 'doFileUploadRequest']: customSendSpy, + } + : {}, + }); + + await act(async () => { + await result.current.uploadAttachment(attachment); + }); + + expect(result.current.attachments[0].localMetadata.uploadState).toBe('failed'); + expect(consoleErrorSpy.mock.calls[0][0].message).toBe( + scenario === 'custom' ? errMsgCustom : errMsg, + ); + consoleErrorSpy.mockRestore(); + }, + ); + }, + ); + }); + }); +}); diff --git a/src/components/MessageInput/hooks/useAttachments.ts b/src/components/MessageInput/hooks/useAttachments.ts index 212588f94f..07262b6a21 100644 --- a/src/components/MessageInput/hooks/useAttachments.ts +++ b/src/components/MessageInput/hooks/useAttachments.ts @@ -4,6 +4,7 @@ import { nanoid } from 'nanoid'; import { useImageUploads } from './useImageUploads'; import { useFileUploads } from './useFileUploads'; import { checkUploadPermissions } from './utils'; +import { isLocalAttachment, isLocalImageAttachment, isUploadedImage } from '../../Attachment'; import { useChannelActionContext, @@ -12,15 +13,37 @@ import { useTranslationContext, } from '../../../context'; -import type { SendFileAPIResponse } from 'stream-chat'; +import type { Attachment, SendFileAPIResponse } from 'stream-chat'; import type { MessageInputReducerAction, MessageInputState } from './useMessageInputState'; import type { MessageInputProps } from '../MessageInput'; -import type { LocalAttachment } from '../types'; +import type { + AttachmentLoadingState, + BaseLocalAttachmentMetadata, + LocalAttachment, +} from '../types'; import type { FileLike } from '../../ReactFileUtilities'; import type { CustomTrigger, DefaultStreamChatGenerics } from '../../../types/types'; const apiMaxNumberOfFiles = 10; +const ensureIsLocalAttachment = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + attachment: Attachment | LocalAttachment, +): LocalAttachment => { + if (isLocalAttachment(attachment)) { + return attachment; + } + const { localMetadata, ...rest } = attachment; + return { + localMetadata: { + ...(localMetadata ?? {}), + id: (localMetadata as BaseLocalAttachmentMetadata)?.id || nanoid(), + }, + ...rest, + }; +}; + export const useAttachments = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, V extends CustomTrigger = CustomTrigger @@ -30,7 +53,7 @@ export const useAttachments = < dispatch: React.Dispatch>, textareaRef: React.MutableRefObject, ) => { - const { doFileUploadRequest, errorHandler, noFiles } = props; + const { doFileUploadRequest, doImageUploadRequest, errorHandler, noFiles } = props; const { fileUploads, imageUploads } = state; const { getAppSettings } = useChatContext('useAttachments'); const { t } = useTranslationContext('useAttachments'); @@ -89,25 +112,42 @@ export const useAttachments = < [maxFilesLeft, noFiles], ); - const removeAttachment = (id: string) => { - dispatch({ id, type: 'removeAttachment' }); - }; + const removeAttachments = useCallback( + (ids: string[]) => { + if (!ids.length) return; + dispatch({ ids, type: 'removeAttachments' }); + }, + [dispatch], + ); + + const upsertAttachments = useCallback( + (attachments: (Attachment | LocalAttachment)[]) => { + if (!attachments.length) return; + dispatch({ + attachments: attachments.map(ensureIsLocalAttachment), + type: 'upsertAttachments', + }); + }, + [dispatch], + ); const uploadAttachment = useCallback( async ( att: LocalAttachment, - ): Promise> => { - const { $internal, ...attachment } = att; - if (!$internal?.file) return att; + ): Promise | undefined> => { + const { localMetadata, ...attachment } = att; + if (!localMetadata?.file) return att; + + const isImage = isUploadedImage(attachment); + const id = localMetadata?.id ?? nanoid(); + const { file } = localMetadata; - const id = $internal?.id ?? nanoid(); - const { file } = $internal; const canUpload = await checkUploadPermissions({ addNotification, file, getAppSettings, t, - uploadType: 'file', + uploadType: isImage ? 'image' : 'file', }); if (!canUpload) { @@ -117,40 +157,26 @@ export const useAttachments = < return att; } - dispatch({ - attachment: { + upsertAttachments([ + { ...attachment, - $internal: { - ...$internal, + localMetadata: { + ...localMetadata, id, uploadState: 'uploading', }, }, - type: 'upsertAttachment', - }); + ]); + let response: SendFileAPIResponse; try { - let response: SendFileAPIResponse; - if (doFileUploadRequest) { - response = await doFileUploadRequest(file, channel); + const doUploadRequest = isImage ? doImageUploadRequest : doFileUploadRequest; + + if (doUploadRequest) { + response = await doUploadRequest(file, channel); } else { - response = await channel.sendFile(file as File); + response = await channel[isImage ? 'sendImage' : 'sendFile'](file); } - const uploadedAttachment = { - ...attachment, - $internal: { - ...$internal, - uploadState: 'finished', - }, - asset_url: response.file, - } as LocalAttachment; - - dispatch({ - attachment: uploadedAttachment, - type: 'upsertAttachment', - }); - - return uploadedAttachment; } catch (error) { let finalError: Error = { message: t('Error uploading attachment'), name: 'Error' }; if (typeof (error as Error).message === 'string') { @@ -162,18 +188,15 @@ export const useAttachments = < console.error(finalError); addNotification(finalError.message, 'error'); - const failedAttachment = { + const failedAttachment: LocalAttachment = { ...attachment, - $internal: { - ...$internal, - uploadState: 'failed', + localMetadata: { + ...localMetadata, + uploadState: 'failed' as AttachmentLoadingState, }, - } as LocalAttachment; + }; - dispatch({ - attachment: failedAttachment, - type: 'upsertAttachment', - }); + upsertAttachments([failedAttachment]); if (errorHandler) { errorHandler(finalError as Error, 'upload-attachment', file); @@ -181,19 +204,60 @@ export const useAttachments = < return failedAttachment; } + + if (!response) { + // Copied this from useImageUpload / useFileUpload. Not sure how failure could be handled on app level. + + // If doUploadRequest returns any falsy value, then don't create the upload preview. + // This is for the case if someone wants to handle failure on app level. + removeAttachments([id]); + return; + } + + const uploadedAttachment: LocalAttachment = { + ...attachment, + localMetadata: { + ...localMetadata, + uploadState: 'finished' as AttachmentLoadingState, + }, + }; + + if (isLocalImageAttachment(uploadedAttachment)) { + if (uploadedAttachment.localMetadata.previewUri) { + URL.revokeObjectURL(uploadedAttachment.localMetadata.previewUri); + delete uploadedAttachment.localMetadata.previewUri; + } + uploadedAttachment.image_url = response.file; + } else { + uploadedAttachment.asset_url = response.file; + } + upsertAttachments([uploadedAttachment]); + + return uploadedAttachment; }, - [addNotification, channel, doFileUploadRequest, dispatch, errorHandler, getAppSettings, t], + [ + addNotification, + channel, + doFileUploadRequest, + doImageUploadRequest, + errorHandler, + getAppSettings, + removeAttachments, + t, + upsertAttachments, + ], ); return { maxFilesLeft, numberOfUploads, - removeAttachment, + removeAttachments, removeFile, removeImage, uploadAttachment, uploadFile, uploadImage, uploadNewFiles, + upsertAttachments, }; }; diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index a4c4dabe77..4ed44f4618 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -57,7 +57,7 @@ export const useCreateMessageInputContext = < parent, publishTypingEvent, recordingController, - removeAttachment, + removeAttachments, removeFile, removeImage, setCooldownRemaining, @@ -71,6 +71,7 @@ export const useCreateMessageInputContext = < uploadFile, uploadImage, uploadNewFiles, + upsertAttachments, useMentionsTransliteration, } = value; @@ -136,7 +137,7 @@ export const useCreateMessageInputContext = < parent, publishTypingEvent, recordingController, - removeAttachment, + removeAttachments, removeFile, removeImage, setCooldownRemaining, @@ -150,6 +151,7 @@ export const useCreateMessageInputContext = < uploadFile, uploadImage, uploadNewFiles, + upsertAttachments, useMentionsTransliteration, }), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -173,11 +175,12 @@ export const useCreateMessageInputContext = < parentId, publishTypingEvent, recordingController, - removeAttachment, + removeAttachments, showCommandsList, showMentionsList, text, uploadAttachment, + upsertAttachments, ], ); diff --git a/src/components/MessageInput/hooks/useMessageInputState.ts b/src/components/MessageInput/hooks/useMessageInputState.ts index e70a8dead4..47802fdecb 100644 --- a/src/components/MessageInput/hooks/useMessageInputState.ts +++ b/src/components/MessageInput/hooks/useMessageInputState.ts @@ -9,10 +9,11 @@ import { useMessageInputText } from './useMessageInputText'; import { useSubmitHandler } from './useSubmitHandler'; import { usePasteHandler } from './usePasteHandler'; import { RecordingController, useMediaRecorder } from '../../MediaRecorder/hooks/useMediaRecorder'; -import { LinkPreviewState, LocalAttachment, SetLinkPreviewMode } from '../types'; +import { LinkPreviewState, SetLinkPreviewMode } from '../types'; +import type { FileUpload, ImageUpload, LinkPreviewMap, LocalAttachment } from '../types'; import type { FileLike } from '../../ReactFileUtilities'; -import type { Message, OGAttachment, UserResponse } from 'stream-chat'; +import type { Attachment, Message, OGAttachment, UserResponse } from 'stream-chat'; import type { MessageInputProps } from '../MessageInput'; @@ -21,7 +22,6 @@ import type { DefaultStreamChatGenerics, SendMessageOptions, } from '../../../types/types'; -import type { FileUpload, ImageUpload, LinkPreviewMap } from '../types'; import { mergeDeep } from '../../../utils/mergeDeep'; export type MessageInputState< @@ -38,14 +38,16 @@ export type MessageInputState< text: string; }; -type UpsertAttachmentAction = { - attachment: LocalAttachment; - type: 'upsertAttachment'; +type UpsertAttachmentsAction< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + attachments: LocalAttachment[]; + type: 'upsertAttachments'; }; -type RemoveAttachmentAction = { - id: string; - type: 'removeAttachment'; +type RemoveAttachmentsAction = { + ids: string[]; + type: 'removeAttachments'; }; type SetTextAction = { @@ -109,8 +111,8 @@ export type MessageInputReducerAction< | RemoveImageUploadAction | RemoveFileUploadAction | AddMentionedUserAction - | UpsertAttachmentAction - | RemoveAttachmentAction; + | UpsertAttachmentsAction + | RemoveAttachmentsAction; export type MessageInputHookProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -127,17 +129,20 @@ export type MessageInputHookProps< numberOfUploads: number; onPaste: (event: React.ClipboardEvent) => void; onSelectUser: (item: UserResponse) => void; - recordingController: RecordingController; - removeAttachment: (id: string) => void; + recordingController: RecordingController; + removeAttachments: (ids: string[]) => void; removeFile: (id: string) => void; removeImage: (id: string) => void; textareaRef: React.MutableRefObject; uploadAttachment: ( attachment: LocalAttachment, - ) => Promise>; + ) => Promise | undefined>; uploadFile: (id: string) => void; uploadImage: (id: string) => void; uploadNewFiles: (files: FileList | File[]) => void; + upsertAttachments: ( + attachments: (Attachment | LocalAttachment)[], + ) => void; }; const makeEmptyMessageInputState = < @@ -182,7 +187,7 @@ const initState = < name: fallback, }, id, - og_scrape_url, + og_scrape_url, // fixme: why scraped content is mixed with uploaded content? state: 'finished', text, title, @@ -236,7 +241,7 @@ const initState = < (att) => ({ ...att, - $internal: { id: nanoid(), uploadState: 'finished' }, + localMetadata: { id: nanoid() }, } as LocalAttachment), ) || []; @@ -271,31 +276,35 @@ const messageInputReducer = < case 'clear': return makeEmptyMessageInputState(); - case 'upsertAttachment': { - const attachmentIndex = state.attachments.findIndex( - (att) => att.$internal?.id && att.$internal?.id === action.attachment.$internal?.id, - ); - const upsertedAttachment = mergeDeep( - state.attachments[attachmentIndex] ?? {}, - action.attachment, - ); + case 'upsertAttachments': { const attachments = [...state.attachments]; - attachments.splice(attachmentIndex, 1, upsertedAttachment); + action.attachments.forEach((actionAttachment) => { + const attachmentIndex = state.attachments.findIndex( + (att) => + att.localMetadata?.id && att.localMetadata?.id === actionAttachment.localMetadata?.id, + ); + + if (attachmentIndex === -1) { + attachments.push(actionAttachment); + } else { + const upsertedAttachment = mergeDeep( + state.attachments[attachmentIndex] ?? {}, + actionAttachment, + ); + attachments.splice(attachmentIndex, 1, upsertedAttachment); + } + }); + return { ...state, attachments, }; } - case 'removeAttachment': { - const attachmentIndex = state.attachments.findIndex( - (att) => att.$internal?.id && att.$internal?.id === action.id, - ); - if (attachmentIndex === -1) return state; - + case 'removeAttachments': { return { ...state, - attachments: [...state.attachments.splice(attachmentIndex, 1)], + attachments: state.attachments.filter((att) => !action.ids.includes(att.localMetadata?.id)), }; } @@ -493,13 +502,14 @@ export const useMessageInputState = < const { maxFilesLeft, numberOfUploads, - removeAttachment, + removeAttachments, removeFile, removeImage, uploadAttachment, uploadFile, uploadImage, uploadNewFiles, + upsertAttachments, } = useAttachments(props, state, dispatch, textareaRef); const { handleSubmit } = useSubmitHandler( @@ -552,7 +562,7 @@ export const useMessageInputState = < openCommandsList, openMentionsList, recordingController, - removeAttachment, + removeAttachments, removeFile, removeImage, setText, @@ -563,5 +573,6 @@ export const useMessageInputState = < uploadFile, uploadImage, uploadNewFiles, + upsertAttachments, }; }; diff --git a/src/components/MessageInput/hooks/useSubmitHandler.ts b/src/components/MessageInput/hooks/useSubmitHandler.ts index 7d783a1dd6..56550646c1 100644 --- a/src/components/MessageInput/hooks/useSubmitHandler.ts +++ b/src/components/MessageInput/hooks/useSubmitHandler.ts @@ -102,9 +102,9 @@ export const useSubmitHandler = < })); const otherAttachments = attachments - .filter((att) => att.$internal?.uploadState !== 'failed') + .filter((att) => att.localMetadata?.uploadState !== 'failed') .map((localAttachment) => { - const { $internal: _, ...attachment } = localAttachment; + const { localMetadata: _, ...attachment } = localAttachment; return attachment as Attachment; }); @@ -133,7 +133,7 @@ export const useSubmitHandler = < const someAttachmentsUploading = Object.values(imageUploads).some((upload) => upload.state === 'uploading') || Object.values(fileUploads).some((upload) => upload.state === 'uploading') || - attachments.some((att) => att.$internal?.uploadState === 'uploading'); + attachments.some((att) => att.localMetadata?.uploadState === 'uploading'); if (someAttachmentsUploading) { return addNotification(t('Wait until all attachments have uploaded'), 'error'); diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts index c6672866ff..5b13d42c7e 100644 --- a/src/components/MessageInput/index.ts +++ b/src/components/MessageInput/index.ts @@ -1,4 +1,12 @@ export { AttachmentPreviewList } from './AttachmentPreviewList'; +export type { + AttachmentPreviewListProps, + FileAttachmentPreviewProps, + ImageAttachmentPreviewProps, + AttachmentPreviewProps, + UnsupportedAttachmentPreviewProps, + VoiceRecordingPreviewProps, +} from './AttachmentPreviewList'; export * from './CooldownTimer'; export * from './DefaultTriggerProvider'; export * from './EditMessageForm'; diff --git a/src/components/MessageInput/types.ts b/src/components/MessageInput/types.ts index e69416290c..391600534e 100644 --- a/src/components/MessageInput/types.ts +++ b/src/components/MessageInput/types.ts @@ -1,7 +1,7 @@ import type { Attachment, DefaultGenerics, ExtendableGenerics, OGAttachment } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; -type AttachmentLoadingState = 'uploading' | 'finished' | 'failed'; +export type AttachmentLoadingState = 'uploading' | 'finished' | 'failed'; export type FileUpload = { file: { @@ -78,11 +78,21 @@ export type VoiceRecordingAttachment< waveform_data?: Array; }; +type FileAttachment< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = Attachment & { + type: 'file'; + asset_url?: string; + file_size?: number; + mime_type?: string; + title?: string; +}; + export type AudioAttachment< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = Attachment & { - asset_url: string; type: 'audio'; + asset_url?: string; file_size?: number; mime_type?: string; title?: string; @@ -91,37 +101,98 @@ export type AudioAttachment< export type VideoAttachment< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = Attachment & { - asset_url: string; type: 'video'; + asset_url?: string; mime_type?: string; thumb_url?: string; title?: string; }; -export type AttachmentInternalMetadata = { +type ImageAttachment< + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics +> = Attachment & { + type: 'image'; + fallback?: string; + image_url?: string; + original_height?: number; + original_width?: number; +}; + +export type BaseLocalAttachmentMetadata = { id: string; +}; + +export type LocalAttachmentUploadMetadata = { file?: File; uploadState?: AttachmentLoadingState; }; -type LocalAttachmentCast = T & { $internal: AttachmentInternalMetadata }; +export type LocalImageAttachmentUploadMetadata = LocalAttachmentUploadMetadata & { + previewUri?: string; +}; + +export type LocalAttachmentCast> = A & { + localMetadata: L & BaseLocalAttachmentMetadata; +}; + +export type LocalAttachmentMetadata< + CustomLocalMetadata = Record +> = CustomLocalMetadata & BaseLocalAttachmentMetadata & LocalImageAttachmentUploadMetadata; export type LocalVoiceRecordingAttachment< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = LocalAttachmentCast>; + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + VoiceRecordingAttachment, + LocalAttachmentUploadMetadata & CustomLocalMetadata +>; export type LocalAudioAttachment< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = LocalAttachmentCast>; + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + AudioAttachment, + LocalAttachmentUploadMetadata & CustomLocalMetadata +>; export type LocalVideoAttachment< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = LocalAttachmentCast>; + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + VideoAttachment, + LocalAttachmentUploadMetadata & CustomLocalMetadata +>; + +export type LocalImageAttachment< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + ImageAttachment, + LocalImageAttachmentUploadMetadata & CustomLocalMetadata +>; + +export type LocalFileAttachment< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + FileAttachment, + LocalAttachmentUploadMetadata & CustomLocalMetadata +>; + +export type AnyLocalAttachment< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + CustomLocalMetadata = Record +> = LocalAttachmentCast< + Attachment, + LocalAttachmentMetadata +>; export type LocalAttachment< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = - | LocalAttachmentCast> + | AnyLocalAttachment + | LocalFileAttachment + | LocalImageAttachment | LocalAudioAttachment | LocalVideoAttachment | LocalVoiceRecordingAttachment; diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index bb4aeca65d..310bcf883c 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -321,8 +321,8 @@ describe('MessageList', () => { const messages = Array.from({ length: msgCount }, generateMessage); const reviewProcessedMessage = jest.fn(); - await act(() => { - renderComponent({ + await act(async () => { + await renderComponent({ channelProps: { channel }, chatClient, msgListProps: { messages, reviewProcessedMessage }, diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 07ab40e724..d87f35f6e3 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -41,6 +41,7 @@ import { CustomMessageActionsListProps, StartRecordingAudioButtonProps, } from '../components'; +import type { AttachmentPreviewListProps } from '../components/MessageInput'; import type { LinkPreviewListProps } from '../components/MessageInput/LinkPreviewList'; import type { ReactionOptions } from '../components/Reactions/reactionOptions'; import type { MessageBouncePromptProps } from '../components/MessageBounce'; @@ -59,7 +60,7 @@ export type ComponentContextValue< MessageSystem: React.ComponentType>; reactionOptions: ReactionOptions; UnreadMessagesSeparator: React.ComponentType; - AttachmentPreviewList?: React.ComponentType; + AttachmentPreviewList?: React.ComponentType; AudioRecorder?: React.ComponentType; AutocompleteSuggestionHeader?: React.ComponentType; AutocompleteSuggestionItem?: React.ComponentType>; diff --git a/src/i18n/de.json b/src/i18n/de.json index 78c990a68c..3072b4df06 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -84,6 +84,7 @@ "Unmute": "Stummschaltung aufheben", "Unpin": "Pin entfernen", "Unread messages": "Ungelesene Nachrichten", + "Unsupported attachment": "Nicht unterstützter Anhang", "Upload type: \"{{ type }}\" is not allowed": "Upload-Typ: \"{{ type }}\" ist nicht erlaubt", "User uploaded content": "Benutzer hochgeladenen Inhalts", "Voice message": "Sprachnachricht", diff --git a/src/i18n/en.json b/src/i18n/en.json index 67da302ec1..bbd4b0dc44 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -84,6 +84,7 @@ "Unmute": "Unmute", "Unpin": "Unpin", "Unread messages": "Unread messages", + "Unsupported attachment": "Unsupported attachment", "Upload type: \"{{ type }}\" is not allowed": "Upload type: \"{{ type }}\" is not allowed", "User uploaded content": "User uploaded content", "Voice message": "Voice message", diff --git a/src/i18n/es.json b/src/i18n/es.json index 421110d32a..481c67165c 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -84,6 +84,7 @@ "Unmute": "Activar sonido", "Unpin": "Desprender", "Unread messages": "Mensajes no leídos", + "Unsupported attachment": "Adjunto no compatible", "Upload type: \"{{ type }}\" is not allowed": "Tipo de carga: \"{{ type }}\" no está permitido", "User uploaded content": "Contenido subido por el usuario", "Voice message": "Mensaje de voz", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index a32c9756c5..d72c7be953 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -84,6 +84,7 @@ "Unmute": "Désactiver muet", "Unpin": "Détacher", "Unread messages": "Messages non lus", + "Unsupported attachment": "Pièce jointe non prise en charge", "Upload type: \"{{ type }}\" is not allowed": "Le type de téléchargement: \"{{ type }}\" n'est pas autorisé", "User uploaded content": "Contenu téléchargé par l'utilisateur", "Voice message": "Message vocal", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 9c8a3b8ee8..c06eab396c 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -85,6 +85,7 @@ "Unmute": "अनम्यूट", "Unpin": "अनपिन", "Unread messages": "अपठित संदेश", + "Unsupported attachment": "असमर्थित अटैचमेंट", "Upload type: \"{{ type }}\" is not allowed": "अपलोड प्रकार: \"{{ type }}\" की अनुमति नहीं है", "User uploaded content": "उपयोगकर्ता अपलोड की गई सामग्री", "Voice message": "आवाज संदेश", diff --git a/src/i18n/it.json b/src/i18n/it.json index 54c8ce0ca9..189fd8c3ba 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -84,6 +84,7 @@ "Unmute": "Riattiva le notifiche", "Unpin": "Sblocca", "Unread messages": "Messaggi non letti", + "Unsupported attachment": "Allegato non supportato", "Upload type: \"{{ type }}\" is not allowed": "Tipo di caricamento: \"{{ type }}\" non è consentito", "User uploaded content": "Contenuto caricato dall'utente", "Voice message": "Messaggio vocale", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index fef2977867..506798e0e0 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -84,6 +84,7 @@ "Unmute": "無音を解除する", "Unpin": "ピンを解除する", "Unread messages": "未読メッセージ", + "Unsupported attachment": "サポートされていない添付ファイル", "Upload type: \"{{ type }}\" is not allowed": "アップロードタイプ:\"{{ type }}\"は許可されていません", "User uploaded content": "ユーザーがアップロードしたコンテンツ", "Voice message": "ボイスメッセージ", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index bf25fa2cd5..aaee88cba6 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -84,6 +84,7 @@ "Unmute": "음소거 해제", "Unpin": "핀 해제", "Unread messages": "읽지 않은 메시지", + "Unsupported attachment": "지원되지 않는 첨부 파일", "Upload type: \"{{ type }}\" is not allowed": "업로드 유형: \"{{ type }}\"은(는) 허용되지 않습니다.", "User uploaded content": "사용자 업로드 콘텐츠", "Voice message": "음성 메시지", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 45483e46a1..352229de6f 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -84,6 +84,7 @@ "Unmute": "Unmute", "Unpin": "Losmaken", "Unread messages": "Ongelezen berichten", + "Unsupported attachment": "Niet-ondersteunde bijlage", "Upload type: \"{{ type }}\" is not allowed": "Uploadtype: \"{{ type }}\" is niet toegestaan", "User uploaded content": "Gebruikersgeüploade inhoud", "Voice message": "Voicemail", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index d66f86bf55..30300ad8aa 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -84,6 +84,7 @@ "Unmute": "Ativar som", "Unpin": "Liberar", "Unread messages": "Mensagens não lidas", + "Unsupported attachment": "Anexo não suportado", "Upload type: \"{{ type }}\" is not allowed": "Tipo de upload: \"{{ type }}\" não é permitido", "User uploaded content": "Conteúdo enviado pelo usuário", "Voice message": "Mensagem de voz", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index acc259fd7e..266824bf15 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -84,6 +84,7 @@ "Unmute": "Включить уведомления", "Unpin": "Открепить", "Unread messages": "Непрочитанные сообщения", + "Unsupported attachment": "Неподдерживаемое вложение", "Upload type: \"{{ type }}\" is not allowed": "Тип загрузки: \"{{ type }}\" не разрешен", "User uploaded content": "Пользователь загрузил контент", "Voice message": "Голосовое сообщение", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index d094dce0f8..6975352bc7 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -84,6 +84,7 @@ "Unmute": "Sesini aç", "Unpin": "Sabitlemeyi kaldır", "Unread messages": "Okunmamış mesajlar", + "Unsupported attachment": "Desteklenmeyen ek", "Upload type: \"{{ type }}\" is not allowed": "Yükleme türü: \"{{ type }}\" izin verilmez", "User uploaded content": "Kullanıcı tarafından yüklenen içerik", "Voice message": "Sesli mesaj", diff --git a/src/mock-builders/generator/attachment.js b/src/mock-builders/generator/attachment.js index 38aaf817cb..e9ceadc5fc 100644 --- a/src/mock-builders/generator/attachment.js +++ b/src/mock-builders/generator/attachment.js @@ -7,17 +7,58 @@ export const generateAttachmentAction = (a) => ({ ...a, }); +export const generateFile = (overrides) => ({ + lastModified: 12345, + lastModifiedDate: new Date(12345), + name: 'file.pdf', + size: 144, + type: 'application/pdf', + webkitRelativePath: '', + ...overrides, +}); + +export const generateImageFile = (overrides) => ({ + ...generateFile(), + name: 'image.png', + type: 'image/png', + ...overrides, +}); + +export const generateLocalAttachmentData = () => ({ + localMetadata: { + id: nanoid(), + }, +}); + +export const generateLocalFileUploadAttachmentData = (overrides) => ({ + localMetadata: { + ...generateLocalAttachmentData().localMetadata, + ...overrides, + file: generateFile(overrides?.file ?? {}), + }, +}); + +export const generateLocalImageUploadAttachmentData = (overrides) => ({ + localMetadata: { + ...generateLocalFileUploadAttachmentData().localMetadata, + previewUri: 'image-preview-uri', + ...overrides, + // eslint-disable-next-line sort-keys + file: generateImageFile(overrides?.file ?? {}), + }, +}); + export const generateFileAttachment = (a) => ({ asset_url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', file_size: 1337, mime_type: 'application/pdf', - title: nanoid(), + title: nanoid() + '.pdf', type: 'file', ...a, }); export const generateImageAttachment = (a) => ({ - fallback: nanoid(), + fallback: nanoid() + '.png', image_url: 'http://' + nanoid(), type: 'image', ...a, @@ -27,7 +68,8 @@ export const generateVideoAttachment = (a) => ({ asset_url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', file_size: 2930530, mime_type: 'video/mp4', - title: nanoid(), + thumb_url: 'thumb_url', + title: nanoid() + '.mp4', type: 'video', ...a, }); @@ -36,7 +78,7 @@ export const generateAudioAttachment = (a) => ({ asset_url: 'http://www.jackblack.com/tribute.mp3', file_size: 36132, mime_type: 'audio/mpeg', - title: nanoid(), + title: nanoid() + '.mpeg', type: 'audio', ...a, }); @@ -47,7 +89,7 @@ export const generateVoiceRecordingAttachment = (a) => ({ duration: 43.007999420166016, file_size: 67940, mime_type: 'audio/aac', - title: 'audio_recording_Mon Feb 05 16:21:34 PST 2024', + title: 'audio_recording_Mon Feb 05 16:21:34 PST 2024.aac', type: 'voiceRecording', waveform_data: [ 0.3139950633049011, diff --git a/src/mock-builders/generator/message.js b/src/mock-builders/generator/message.js index 52581336f1..bc13785ec8 100644 --- a/src/mock-builders/generator/message.js +++ b/src/mock-builders/generator/message.js @@ -6,6 +6,7 @@ export const generateMessage = (options) => ({ created_at: new Date(), html: '

regular

', id: nanoid(), + mentioned_users: [], pinned_at: null, status: 'received', text: nanoid(), diff --git a/src/mock-builders/generator/upload.js b/src/mock-builders/generator/upload.js index 22817bb95e..90367d70cc 100644 --- a/src/mock-builders/generator/upload.js +++ b/src/mock-builders/generator/upload.js @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid'; -export const generateFile = (data = {}) => { +const generateFile = (data = {}) => { const date = new Date(); return { lastModified: +date, diff --git a/yarn.lock b/yarn.lock index e59949712f..e766dca9aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2241,10 +2241,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-4.14.0.tgz#89073a4d22465dee34c26ba3c6ea90fe17a851a2" - integrity sha512-EHP2clD0BNtzNYM6PYOxBJENDwAnT2y/39DF+kkZ/KEN1Z6S+o/c99NDjffWcXERRCG03nrARgXJNuU7UWiF9w== +"@stream-io/stream-chat-css@^4.16.0": + version "4.16.0" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-4.16.0.tgz#7d0358ca4ef696f422eafeb01c281a643e9e3b26" + integrity sha512-LeJoHhxCjHGlgDISkKF7J3AwHbb9OT0q6N6ErIK82Yx6pwpnMLD10uVt6xHwZg5W5gdbQq9oQFwMtpga23fW/g== "@stream-io/transliterate@^1.5.5": version "1.5.5"