diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ef3f92b3..3bc1b0da11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [12.15.0](https://github.com/GetStream/stream-chat-react/compare/v12.14.0...v12.15.0) (2025-05-02) + +### Features + +* introduce WithDragAndDropUpload component ([#2688](https://github.com/GetStream/stream-chat-react/issues/2688)) ([6b03abd](https://github.com/GetStream/stream-chat-react/commit/6b03abd707165d08539af435b940dd13025481d2)) + +### Chores + +* **deps:** upgrade @stream-io/stream-chat-css to v5.8.1 ([#2689](https://github.com/GetStream/stream-chat-react/issues/2689)) ([d0c32e3](https://github.com/GetStream/stream-chat-react/commit/d0c32e33225c2e72bf4f14f368c34fa3a34c543c)) + +### Refactors + +* simplify WithDragAndDropUpload API ([#2691](https://github.com/GetStream/stream-chat-react/issues/2691)) ([46c9add](https://github.com/GetStream/stream-chat-react/commit/46c9add73d8c37ed65cd0c2808c148199820889a)) + ## [12.14.0](https://github.com/GetStream/stream-chat-react/compare/v12.13.1...v12.14.0) (2025-04-08) ### Features diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index ff5f5a392e..4aca0a2c43 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 7c27cf62ee..b2899ed70c 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 { useRegisterDropHandlers } from './WithDragAndDropUpload'; export type EmojiSearchIndexResult = { id: string; @@ -151,6 +152,9 @@ const MessageInputProvider = < emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex, }); + // @ts-expect-error generics to be removed + useRegisterDropHandlers(messageInputContextValue); + return ( value={messageInputContextValue}> {props.children} diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 86b52661b7..b0e7003651 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 0000000000..849cd86b64 --- /dev/null +++ b/src/components/MessageInput/WithDragAndDropUpload.tsx @@ -0,0 +1,146 @@ +import React, { + CSSProperties, + ElementType, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + MessageInputContextValue, + useChannelStateContext, + useMessageInputContext, + useTranslationContext, +} from '../../context'; +import { useDropzone } from 'react-dropzone'; +import clsx from 'clsx'; + +const DragAndDropUploadContext = React.createContext<{ + subscribeToDrop: ((fn: (files: File[]) => void) => () => void) | null; +}>({ + subscribeToDrop: null, +}); + +export const useDragAndDropUploadContext = () => useContext(DragAndDropUploadContext); + +/** + * @private This hook should be used only once directly in the `MessageInputProvider` to + * register `uploadNewFiles` functions of the rendered `MessageInputs`. Each `MessageInput` + * will then be notified when the drop event occurs from within the `WithDragAndDropUpload` + * component. + */ +export const useRegisterDropHandlers = ({ uploadNewFiles }: MessageInputContextValue) => { + const { subscribeToDrop } = useDragAndDropUploadContext(); + + useEffect(() => { + const unsubscribe = subscribeToDrop?.(uploadNewFiles); + + return unsubscribe; + }, [subscribeToDrop, 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 dropHandlersRef = useRef void>>(new Set()); + 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 subscribeToDrop = useCallback((fn: (files: File[]) => void) => { + dropHandlersRef.current.add(fn); + + return () => { + dropHandlersRef.current.delete(fn); + }; + }, []); + + const handleDrop = useCallback((files: File[]) => { + dropHandlersRef.current.forEach((fn) => fn(files)); + }, []); + + 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 : handleDrop, + }); + + // nested WithDragAndDropUpload components render wrappers without functionality + // (MessageInputFlat has a default WithDragAndDropUpload) + if (dragAndDropUploadContext.subscribeToDrop !== 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 46c2d6e13e..0f0bbaa0db 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/components/Poll/PollActions/PollResults/PollResults.tsx b/src/components/Poll/PollActions/PollResults/PollResults.tsx index 3797f1b8b4..91b81f399b 100644 --- a/src/components/Poll/PollActions/PollResults/PollResults.tsx +++ b/src/components/Poll/PollActions/PollResults/PollResults.tsx @@ -21,7 +21,7 @@ const pollStateSelector = < nextValue: PollState, ): PollStateSelectorReturnValue => ({ name: nextValue.name, - options: nextValue.options, + options: [...nextValue.options], vote_counts_by_option: nextValue.vote_counts_by_option, }); diff --git a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx index 9d8b09b6a7..3ba21c682c 100644 --- a/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx +++ b/src/components/Poll/PollCreationDialog/PollCreationDialog.tsx @@ -123,8 +123,9 @@ export const PollCreationDialog = ({ close }: PollCreationDialogProps) => { id='max_votes_allowed' onChange={(e) => { const isValidValue = - !e.target.value || - e.target.value.match(VALID_MAX_VOTES_VALUE_REGEX); + e.target.validity.valid && + (!e.target.value || + e.target.value.match(VALID_MAX_VOTES_VALUE_REGEX)); if (!isValidValue) { setMultipleAnswerCountError( t('Type a number from 2 to 10'), diff --git a/src/context/MessageInputContext.tsx b/src/context/MessageInputContext.tsx index 95228c9ecb..587e4e7beb 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; }