Skip to content

Commit 6b03abd

Browse files
feat: introduce WithDragAndDropUpload component (#2688)
### 🎯 Goal Current `Channel.dragAndDropWindow` and `Channel.optionalMessageInputProps` architecture which used to allow drag&drop file upload by dropping files onto the message list (channel) component suffers from a few pain points: - duplicate `MessageInputContextProvider` initialization, see [here](https://github.com/GetStream/stream-chat-react/blob/5fa6b0fc239a7d48032ae4c3d34e29969c52bd0c/src/components/Channel/Channel.tsx#L1479-L1484), [here](https://github.com/GetStream/stream-chat-react/blob/5fa6b0fc239a7d48032ae4c3d34e29969c52bd0c/src/components/MessageInput/MessageInput.tsx#L137-L159) and [here](https://github.com/GetStream/stream-chat-react/blob/5fa6b0fc239a7d48032ae4c3d34e29969c52bd0c/src/components/MessageInput/DropzoneProvider.tsx#L47-L67) ([related issue](https://getstream.slack.com/archives/C02R5UCGN6N/p1740392263047589)) - broken styling ([v2 vendor folder](https://github.com/GetStream/stream-chat-css/tree/v5.8.0/src/v2/styles/vendor) is missing [react-file-utils styling](https://github.com/GetStream/stream-chat-css/blob/v5.8.0/src/vendor/react-file-utils.scss)) New solution allows dragging and uploading files both in "channel" and in thread individually - which was previously impossible. The new solution also reuses drag and drop styling which is used by default in `MessageInputFlat` component (some minor adjustments from integrators are needed - such as setting relative positioning on required parents). #### Old API: ```tsx <Channel dragAndDropWindow> <Window> <ChannelHeader /> <MessageList /> <AIStateIndicator /> <MessageInput focus /> </Window> <Thread virtualized /> </Channel> ``` #### New API: ```tsx <Channel> <WithDragAndDropUpload className='str-chat__main-panel'> <ChannelHeader/> <MessageList /> <AIStateIndicator /> <MessageInput focus /> </WithDragAndDropUpload> <WithDragAndDropUpload> <Thread virtualized /> </WithDragAndDropUpload> </Channel> ``` ### Deprecations #### Public - `Channel.dragAndDropWindow` prop (will remove in v13) - `Channel.optionalMessageInputProps` prop (will remove in v13) #### Private - `DropzoneProvider` - `DropzoneInner` - `ImageDropzone` Note: This is not a direct fix of the issue I mentioned above (Slack link), I believe the proper fix is to move away from this messy architecture (by removing deprecated components and options which we'll do in v13 once this PR is merged). THIS PR RELIES ON [CSS CHANGES](GetStream/stream-chat-css#329) ### 🎨 UI Changes Before: ![image](https://github.com/user-attachments/assets/0514c338-8d52-44d8-aecd-60bf86acdee2) After: ![image](https://github.com/user-attachments/assets/3afd1468-d552-4180-9be6-b9ffbe503526)
1 parent 5fa6b0f commit 6b03abd

File tree

6 files changed

+228
-109
lines changed

6 files changed

+228
-109
lines changed

src/components/Channel/Channel.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ export type ChannelProps<
212212
updatedMessage: UpdatedMessage<StreamChatGenerics>,
213213
options?: UpdateMessageOptions,
214214
) => ReturnType<StreamChat<StreamChatGenerics>['updateMessage']>;
215-
/** If true, chat users will be able to drag and drop file uploads to the entire channel window */
215+
/**
216+
* @deprecated Use `WithDragAndDropUpload` instead (wrap draggable-to elements with this component).
217+
* @description If true, chat users will be able to drag and drop file uploads to the entire channel window
218+
*/
216219
dragAndDropWindow?: boolean;
217220
/** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */
218221
EmptyPlaceholder?: React.ReactElement;
@@ -246,7 +249,10 @@ export type ChannelProps<
246249
onMentionsClick?: OnMentionAction<StreamChatGenerics>;
247250
/** Custom action handler function to run on hover of an @mention in a message */
248251
onMentionsHover?: OnMentionAction<StreamChatGenerics>;
249-
/** If `dragAndDropWindow` prop is true, the props to pass to the MessageInput component (overrides props placed directly on MessageInput) */
252+
/**
253+
* @deprecated Use `WithDragAndDropUpload` instead (wrap draggable-to elements with this component).
254+
* @description If `dragAndDropWindow` prop is `true`, the props to pass to the `MessageInput` component (overrides props placed directly on `MessageInput`)
255+
*/
250256
optionalMessageInputProps?: MessageInputProps<StreamChatGenerics, V>;
251257
/** You can turn on/off thumbnail generation for video attachments */
252258
shouldGenerateVideoThumbnail?: boolean;

src/components/MessageInput/MessageInput.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
} from '../../types/types';
2727
import type { URLEnrichmentConfig } from './hooks/useLinkPreviews';
2828
import type { CustomAudioRecordingConfig } from '../MediaRecorder';
29+
import { useHandleDragAndDropQueuedFiles } from './WithDragAndDropUpload';
2930

3031
export type EmojiSearchIndexResult = {
3132
id: string;
@@ -151,6 +152,9 @@ const MessageInputProvider = <
151152
emojiSearchIndex: props.emojiSearchIndex ?? emojiSearchIndex,
152153
});
153154

155+
// @ts-expect-error generics to be removed
156+
useHandleDragAndDropQueuedFiles(messageInputContextValue);
157+
154158
return (
155159
<MessageInputContextProvider<StreamChatGenerics, V> value={messageInputContextValue}>
156160
{props.children}
+64-103
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import type { Event } from 'stream-chat';
3-
import clsx from 'clsx';
4-
import { useDropzone } from 'react-dropzone';
53
import {
64
AttachmentSelector as DefaultAttachmentSelector,
75
SimpleAttachmentSelector,
@@ -28,17 +26,16 @@ import { RecordingAttachmentType } from '../MediaRecorder/classes';
2826
import { useChatContext } from '../../context/ChatContext';
2927
import { useChannelActionContext } from '../../context/ChannelActionContext';
3028
import { useChannelStateContext } from '../../context/ChannelStateContext';
31-
import { useTranslationContext } from '../../context/TranslationContext';
3229
import { useMessageInputContext } from '../../context/MessageInputContext';
3330
import { useComponentContext } from '../../context/ComponentContext';
3431

3532
import type { DefaultStreamChatGenerics } from '../../types/types';
3633
import { AIStates, useAIState } from '../AIStateIndicator';
34+
import { WithDragAndDropUpload } from './WithDragAndDropUpload';
3735

3836
export const MessageInputFlat = <
3937
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
4038
>() => {
41-
const { t } = useTranslationContext('MessageInputFlat');
4239
const {
4340
asyncMessagesMultiSendEnabled,
4441
attachments,
@@ -48,14 +45,12 @@ export const MessageInputFlat = <
4845
hideSendButton,
4946
isUploadEnabled,
5047
linkPreviews,
51-
maxFilesLeft,
5248
message,
5349
numberOfUploads,
5450
parent,
5551
recordingController,
5652
setCooldownRemaining,
5753
text,
58-
uploadNewFiles,
5954
} = useMessageInputContext<StreamChatGenerics>('MessageInputFlat');
6055

6156
const {
@@ -71,11 +66,8 @@ export const MessageInputFlat = <
7166
StartRecordingAudioButton = DefaultStartRecordingAudioButton,
7267
StopAIGenerationButton: StopAIGenerationButtonOverride,
7368
} = useComponentContext<StreamChatGenerics>('MessageInputFlat');
74-
const {
75-
acceptedFiles = [],
76-
multipleUploads,
77-
quotedMessage,
78-
} = useChannelStateContext<StreamChatGenerics>('MessageInputFlat');
69+
const { quotedMessage } =
70+
useChannelStateContext<StreamChatGenerics>('MessageInputFlat');
7971
const { setQuotedMessage } = useChannelActionContext('MessageInputFlat');
8072
const { channel } = useChatContext<StreamChatGenerics>('MessageInputFlat');
8173

@@ -96,23 +88,6 @@ export const MessageInputFlat = <
9688
[attachments],
9789
);
9890

99-
const accept = useMemo(
100-
() =>
101-
acceptedFiles.reduce<Record<string, Array<string>>>((mediaTypeMap, mediaType) => {
102-
mediaTypeMap[mediaType] ??= [];
103-
return mediaTypeMap;
104-
}, {}),
105-
[acceptedFiles],
106-
);
107-
108-
const { getRootProps, isDragActive, isDragReject } = useDropzone({
109-
accept,
110-
disabled: !isUploadEnabled || maxFilesLeft === 0,
111-
multiple: multipleUploads,
112-
noClick: true,
113-
onDrop: uploadNewFiles,
114-
});
115-
11691
useEffect(() => {
11792
const handleQuotedMessageUpdate = (e: Event<StreamChatGenerics>) => {
11893
if (e.message?.id !== quotedMessage?.id) return;
@@ -156,90 +131,76 @@ export const MessageInputFlat = <
156131
!!StopAIGenerationButton;
157132

158133
return (
159-
<>
160-
<div {...getRootProps({ className: 'str-chat__message-input' })}>
161-
{recordingEnabled &&
162-
recordingController.permissionState === 'denied' &&
163-
showRecordingPermissionDeniedNotification && (
164-
<RecordingPermissionDeniedNotification
165-
onClose={closePermissionDeniedNotification}
166-
permissionName={RecordingPermission.MIC}
167-
/>
168-
)}
169-
{findAndEnqueueURLsToEnrich && (
170-
<LinkPreviewList linkPreviews={Array.from(linkPreviews.values())} />
171-
)}
172-
{isDragActive && (
173-
<div
174-
className={clsx('str-chat__dropzone-container', {
175-
'str-chat__dropzone-container--not-accepted': isDragReject,
176-
})}
177-
>
178-
{!isDragReject && <p>{t<string>('Drag your files here')}</p>}
179-
{isDragReject && <p>{t<string>('Some of the files will not be accepted')}</p>}
180-
</div>
134+
<WithDragAndDropUpload className='str-chat__message-input' component='div'>
135+
{recordingEnabled &&
136+
recordingController.permissionState === 'denied' &&
137+
showRecordingPermissionDeniedNotification && (
138+
<RecordingPermissionDeniedNotification
139+
onClose={closePermissionDeniedNotification}
140+
permissionName={RecordingPermission.MIC}
141+
/>
181142
)}
182-
{displayQuotedMessage && <QuotedMessagePreviewHeader />}
183-
184-
<div className='str-chat__message-input-inner'>
185-
<AttachmentSelector />
186-
<div className='str-chat__message-textarea-container'>
187-
{displayQuotedMessage && (
188-
<QuotedMessagePreview quotedMessage={quotedMessage} />
143+
{findAndEnqueueURLsToEnrich && (
144+
<LinkPreviewList linkPreviews={Array.from(linkPreviews.values())} />
145+
)}
146+
{displayQuotedMessage && <QuotedMessagePreviewHeader />}
147+
148+
<div className='str-chat__message-input-inner'>
149+
<AttachmentSelector />
150+
<div className='str-chat__message-textarea-container'>
151+
{displayQuotedMessage && <QuotedMessagePreview quotedMessage={quotedMessage} />}
152+
{isUploadEnabled &&
153+
!!(numberOfUploads + failedUploadsCount || attachments.length > 0) && (
154+
<AttachmentPreviewList />
189155
)}
190-
{isUploadEnabled &&
191-
!!(numberOfUploads + failedUploadsCount || attachments.length > 0) && (
192-
<AttachmentPreviewList />
193-
)}
194156

195-
<div className='str-chat__message-textarea-with-emoji-picker'>
196-
<ChatAutoComplete />
157+
<div className='str-chat__message-textarea-with-emoji-picker'>
158+
<ChatAutoComplete />
197159

198-
{EmojiPicker && <EmojiPicker />}
199-
</div>
160+
{EmojiPicker && <EmojiPicker />}
200161
</div>
201-
{shouldDisplayStopAIGeneration ? (
202-
<StopAIGenerationButton onClick={stopGenerating} />
203-
) : (
204-
!hideSendButton && (
205-
<>
206-
{cooldownRemaining ? (
207-
<CooldownTimer
208-
cooldownInterval={cooldownRemaining}
209-
setCooldownRemaining={setCooldownRemaining}
162+
</div>
163+
{shouldDisplayStopAIGeneration ? (
164+
<StopAIGenerationButton onClick={stopGenerating} />
165+
) : (
166+
!hideSendButton && (
167+
<>
168+
{cooldownRemaining ? (
169+
<CooldownTimer
170+
cooldownInterval={cooldownRemaining}
171+
setCooldownRemaining={setCooldownRemaining}
172+
/>
173+
) : (
174+
<>
175+
<SendButton
176+
disabled={
177+
!numberOfUploads &&
178+
!text.length &&
179+
attachments.length - failedUploadsCount === 0
180+
}
181+
sendMessage={handleSubmit}
210182
/>
211-
) : (
212-
<>
213-
<SendButton
183+
{recordingEnabled && (
184+
<StartRecordingAudioButton
214185
disabled={
215-
!numberOfUploads &&
216-
!text.length &&
217-
attachments.length - failedUploadsCount === 0
186+
isRecording ||
187+
(!asyncMessagesMultiSendEnabled &&
188+
attachments.some(
189+
(a) => a.type === RecordingAttachmentType.VOICE_RECORDING,
190+
))
218191
}
219-
sendMessage={handleSubmit}
192+
onClick={() => {
193+
recordingController.recorder?.start();
194+
setShowRecordingPermissionDeniedNotification(true);
195+
}}
220196
/>
221-
{recordingEnabled && (
222-
<StartRecordingAudioButton
223-
disabled={
224-
isRecording ||
225-
(!asyncMessagesMultiSendEnabled &&
226-
attachments.some(
227-
(a) => a.type === RecordingAttachmentType.VOICE_RECORDING,
228-
))
229-
}
230-
onClick={() => {
231-
recordingController.recorder?.start();
232-
setShowRecordingPermissionDeniedNotification(true);
233-
}}
234-
/>
235-
)}
236-
</>
237-
)}
238-
</>
239-
)
240-
)}
241-
</div>
197+
)}
198+
</>
199+
)}
200+
</>
201+
)
202+
)}
242203
</div>
243-
</>
204+
</WithDragAndDropUpload>
244205
);
245206
};

0 commit comments

Comments
 (0)