diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index ff5f5a392..4aca0a2c4 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -212,7 +212,10 @@ export type ChannelProps< updatedMessage: UpdatedMessage, options?: UpdateMessageOptions, ) => ReturnType['updateMessage']>; - /** If true, chat users will be able to drag and drop file uploads to the entire channel window */ + /** + * @deprecated Use `WithDragAndDropUpload` instead (wrap draggable-to elements with this component). + * @description If true, chat users will be able to drag and drop file uploads to the entire channel window + */ dragAndDropWindow?: boolean; /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ EmptyPlaceholder?: React.ReactElement; @@ -246,7 +249,10 @@ export type ChannelProps< onMentionsClick?: OnMentionAction; /** Custom action handler function to run on hover of an @mention in a message */ onMentionsHover?: OnMentionAction; - /** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */ + /** + * @deprecated Use `WithDragAndDropUpload` instead (wrap draggable-to elements with this component). + * @description If `dragAndDropWindow` prop is `true`, the props to pass to the `MessageInput` component (overrides props placed directly on `MessageInput`) + */ optionalMessageInputProps?: MessageInputProps; /** You can turn on/off thumbnail generation for video attachments */ shouldGenerateVideoThumbnail?: boolean; diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index 7c27cf62e..55b67502b 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -26,6 +26,7 @@ import type { } from '../../types/types'; import type { URLEnrichmentConfig } from './hooks/useLinkPreviews'; import type { CustomAudioRecordingConfig } from '../MediaRecorder'; +import { useHandleDragAndDropQueuedFiles } from './WithDragAndDropUpload'; export type EmojiSearchIndexResult = { id: string; @@ -151,6 +152,9 @@ const MessageInputProvider = < emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, }); + // @ts-expect-error generics to be removed + useHandleDragAndDropQueuedFiles(messageInputContextValue); + return ( value={messageInputContextValue}> {props.children} diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 86b52661b..b0e700365 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { Event } from 'stream-chat'; -import clsx from 'clsx'; -import { useDropzone } from 'react-dropzone'; import { AttachmentSelector as DefaultAttachmentSelector, SimpleAttachmentSelector, @@ -28,17 +26,16 @@ import { RecordingAttachmentType } from '../MediaRecorder/classes'; import { useChatContext } from '../../context/ChatContext'; import { useChannelActionContext } from '../../context/ChannelActionContext'; import { useChannelStateContext } from '../../context/ChannelStateContext'; -import { useTranslationContext } from '../../context/TranslationContext'; import { useMessageInputContext } from '../../context/MessageInputContext'; import { useComponentContext } from '../../context/ComponentContext'; import type { DefaultStreamChatGenerics } from '../../types/types'; import { AIStates, useAIState } from '../AIStateIndicator'; +import { WithDragAndDropUpload } from './WithDragAndDropUpload'; export const MessageInputFlat = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >() => { - const { t } = useTranslationContext('MessageInputFlat'); const { asyncMessagesMultiSendEnabled, attachments, @@ -48,14 +45,12 @@ export const MessageInputFlat = < hideSendButton, isUploadEnabled, linkPreviews, - maxFilesLeft, message, numberOfUploads, parent, recordingController, setCooldownRemaining, text, - uploadNewFiles, } = useMessageInputContext('MessageInputFlat'); const { @@ -71,11 +66,8 @@ export const MessageInputFlat = < StartRecordingAudioButton = DefaultStartRecordingAudioButton, StopAIGenerationButton: StopAIGenerationButtonOverride, } = useComponentContext('MessageInputFlat'); - const { - acceptedFiles = [], - multipleUploads, - quotedMessage, - } = useChannelStateContext('MessageInputFlat'); + const { quotedMessage } = + useChannelStateContext('MessageInputFlat'); const { setQuotedMessage } = useChannelActionContext('MessageInputFlat'); const { channel } = useChatContext('MessageInputFlat'); @@ -96,23 +88,6 @@ export const MessageInputFlat = < [attachments], ); - const accept = useMemo( - () => - acceptedFiles.reduce>>((mediaTypeMap, mediaType) => { - mediaTypeMap[mediaType] ??= []; - return mediaTypeMap; - }, {}), - [acceptedFiles], - ); - - const { getRootProps, isDragActive, isDragReject } = useDropzone({ - accept, - disabled: !isUploadEnabled || maxFilesLeft === 0, - multiple: multipleUploads, - noClick: true, - onDrop: uploadNewFiles, - }); - useEffect(() => { const handleQuotedMessageUpdate = (e: Event) => { if (e.message?.id !== quotedMessage?.id) return; @@ -156,90 +131,76 @@ export const MessageInputFlat = < !!StopAIGenerationButton; return ( - <> -
- {recordingEnabled && - recordingController.permissionState === 'denied' && - showRecordingPermissionDeniedNotification && ( - - )} - {findAndEnqueueURLsToEnrich && ( - - )} - {isDragActive && ( -
- {!isDragReject &&

{t('Drag your files here')}

} - {isDragReject &&

{t('Some of the files will not be accepted')}

} -
+ + {recordingEnabled && + recordingController.permissionState === 'denied' && + showRecordingPermissionDeniedNotification && ( + )} - {displayQuotedMessage && } - -
- -
- {displayQuotedMessage && ( - + {findAndEnqueueURLsToEnrich && ( + + )} + {displayQuotedMessage && } + +
+ +
+ {displayQuotedMessage && } + {isUploadEnabled && + !!(numberOfUploads + failedUploadsCount || attachments.length > 0) && ( + )} - {isUploadEnabled && - !!(numberOfUploads + failedUploadsCount || attachments.length > 0) && ( - - )} -
- +
+ - {EmojiPicker && } -
+ {EmojiPicker && }
- {shouldDisplayStopAIGeneration ? ( - - ) : ( - !hideSendButton && ( - <> - {cooldownRemaining ? ( - + {shouldDisplayStopAIGeneration ? ( + + ) : ( + !hideSendButton && ( + <> + {cooldownRemaining ? ( + + ) : ( + <> + - ) : ( - <> - a.type === RecordingAttachmentType.VOICE_RECORDING, + )) } - sendMessage={handleSubmit} + onClick={() => { + recordingController.recorder?.start(); + setShowRecordingPermissionDeniedNotification(true); + }} /> - {recordingEnabled && ( - a.type === RecordingAttachmentType.VOICE_RECORDING, - )) - } - onClick={() => { - recordingController.recorder?.start(); - setShowRecordingPermissionDeniedNotification(true); - }} - /> - )} - - )} - - ) - )} -
+ )} + + )} + + ) + )}
- + ); }; diff --git a/src/components/MessageInput/WithDragAndDropUpload.tsx b/src/components/MessageInput/WithDragAndDropUpload.tsx new file mode 100644 index 000000000..29a77f2fb --- /dev/null +++ b/src/components/MessageInput/WithDragAndDropUpload.tsx @@ -0,0 +1,150 @@ +import React, { + CSSProperties, + ElementType, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { + MessageInputContextValue, + useChannelStateContext, + useMessageInputContext, + useTranslationContext, +} from '../../context'; +import { useDropzone } from 'react-dropzone'; +import clsx from 'clsx'; + +const DragAndDropUploadContext = React.createContext<{ + fileQueue: File[]; + addFilesToQueue: ((files: File[]) => void) | null; + removeFilesFromQueue: ((files: File[]) => void) | null; +}>({ + addFilesToQueue: null, + fileQueue: [], + removeFilesFromQueue: null, +}); + +export const useDragAndDropUploadContext = () => useContext(DragAndDropUploadContext); + +/** + * @private To maintain top -> bottom data flow, the drag-and-drop functionality allows dragging any files to the queue - the closest + * `MessageInputProvider` will be notified through `DragAndDropUploadContext.fileQueue` and starts the upload with `uploadNewAttachments`, + * forwarded files are removed from the queue immediately after. + */ +export const useHandleDragAndDropQueuedFiles = ({ + uploadNewFiles, +}: MessageInputContextValue) => { + const { fileQueue, removeFilesFromQueue } = useDragAndDropUploadContext(); + + useEffect(() => { + if (!removeFilesFromQueue) return; + + uploadNewFiles(fileQueue); + + removeFilesFromQueue(fileQueue); + }, [fileQueue, removeFilesFromQueue, uploadNewFiles]); +}; + +/** + * Wrapper to replace now deprecated `Channel.dragAndDropWindow` option. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * + * + * ``` + */ +export const WithDragAndDropUpload = ({ + children, + className, + component: Component = 'div', + style, +}: PropsWithChildren<{ + /** + * @description An element to render as a wrapper onto which drag & drop functionality will be applied. + * @default 'div' + */ + component?: ElementType; + className?: string; + style?: CSSProperties; +}>) => { + const [files, setFiles] = useState([]); + const { acceptedFiles = [], multipleUploads } = useChannelStateContext(); + const { t } = useTranslationContext(); + + const messageInputContext = useMessageInputContext(); + const dragAndDropUploadContext = useDragAndDropUploadContext(); + + // if message input context is available, there's no need to use the queue + const isWithinMessageInputContext = + typeof messageInputContext.uploadNewFiles === 'function'; + + const accept = useMemo( + () => + acceptedFiles.reduce>>((mediaTypeMap, mediaType) => { + mediaTypeMap[mediaType] ??= []; + return mediaTypeMap; + }, {}), + [acceptedFiles], + ); + + const addFilesToQueue = useCallback((files: File[]) => { + setFiles((cv) => cv.concat(files)); + }, []); + + const removeFilesFromQueue = useCallback((files: File[]) => { + if (!files.length) return; + setFiles((cv) => cv.filter((f) => files.indexOf(f) === -1)); + }, []); + + const { getRootProps, isDragActive, isDragReject } = useDropzone({ + accept, + // apply `disabled` rules if available, otherwise allow anything and + // let the `uploadNewFiles` handle the limitations internally + disabled: isWithinMessageInputContext + ? !messageInputContext.isUploadEnabled || messageInputContext.maxFilesLeft === 0 + : false, + multiple: multipleUploads, + noClick: true, + onDrop: isWithinMessageInputContext + ? messageInputContext.uploadNewFiles + : addFilesToQueue, + }); + + // nested WithDragAndDropUpload components render wrappers without functionality + // (MessageInputFlat has a default WithDragAndDropUpload) + if (dragAndDropUploadContext.removeFilesFromQueue !== null) { + return {children}; + } + + return ( + + + {/* TODO: could be a replaceable component */} + {isDragActive && ( +
+ {!isDragReject &&

{t('Drag your files here')}

} + {isDragReject &&

{t('Some of the files will not be accepted')}

} +
+ )} + {children} +
+
+ ); +}; diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts index 46c2d6e13..0f0bbaa0d 100644 --- a/src/components/MessageInput/index.ts +++ b/src/components/MessageInput/index.ts @@ -18,4 +18,5 @@ export * from './MessageInput'; export * from './MessageInputFlat'; export * from './QuotedMessagePreview'; export * from './SendButton'; +export { WithDragAndDropUpload } from './WithDragAndDropUpload'; export * from './types'; diff --git a/src/context/MessageInputContext.tsx b/src/context/MessageInputContext.tsx index 95228c9ec..587e4e7be 100644 --- a/src/context/MessageInputContext.tsx +++ b/src/context/MessageInputContext.tsx @@ -44,15 +44,12 @@ export const useMessageInputContext = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, V extends CustomTrigger = CustomTrigger, >( + // eslint-disable-next-line @typescript-eslint/no-unused-vars componentName?: string, ) => { const contextValue = useContext(MessageInputContext); if (!contextValue) { - console.warn( - `The useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure this hook is called within the MessageInput's UI component. The errored call is located in the ${componentName} component.`, - ); - return {} as MessageInputContextValue; }