diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index 59287c595..639ddaa0a 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -142,6 +142,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'ReactionsListModal'
| 'SendButton'
| 'StartRecordingAudioButton'
+ | 'TextareaComposer'
| 'ThreadHead'
| 'ThreadHeader'
| 'ThreadStart'
@@ -1229,6 +1230,7 @@ const ChannelInner = (
StartRecordingAudioButton: props.StartRecordingAudioButton,
StopAIGenerationButton: props.StopAIGenerationButton,
StreamedMessageText: props.StreamedMessageText,
+ TextareaComposer: props.TextareaComposer,
ThreadHead: props.ThreadHead,
ThreadHeader: props.ThreadHeader,
ThreadStart: props.ThreadStart,
@@ -1291,6 +1293,7 @@ const ChannelInner = (
props.StartRecordingAudioButton,
props.StopAIGenerationButton,
props.StreamedMessageText,
+ props.TextareaComposer,
props.ThreadHead,
props.ThreadHeader,
props.ThreadStart,
diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js
index fff894a09..341f51827 100644
--- a/src/components/Channel/__tests__/Channel.test.js
+++ b/src/components/Channel/__tests__/Channel.test.js
@@ -32,6 +32,7 @@ import { Thread } from '../../Thread';
import { MessageProvider } from '../../../context';
import { MessageActionsBox } from '../../MessageActions';
import { DEFAULT_THREAD_PAGE_SIZE } from '../../../constants/limits';
+import { generateMessageDraft } from '../../../mock-builders/generator/messageDraft';
jest.mock('../../Loading', () => ({
LoadingErrorIndicator: jest.fn(() =>
),
@@ -173,6 +174,9 @@ describe('Channel', () => {
pinnedMessages,
user,
}));
+ jest.spyOn(channel, 'getDraft').mockResolvedValue({
+ draft: generateMessageDraft({ channel, channel_cid: channel.cid }),
+ });
});
afterEach(() => {
diff --git a/src/components/MessageInput/EditMessageForm.tsx b/src/components/MessageInput/EditMessageForm.tsx
index 072bf224a..6e5bda7f2 100644
--- a/src/components/MessageInput/EditMessageForm.tsx
+++ b/src/components/MessageInput/EditMessageForm.tsx
@@ -86,7 +86,6 @@ export const EditMessageModal = ({
>
,
- 'defaultValue'
+ 'defaultValue' | 'style' | 'disabled' | 'value'
>;
/**
* When enabled, recorded messages won’t be sent immediately.
@@ -55,8 +55,6 @@ export type MessageInputProps = {
emojiSearchIndex?: ComponentContextValue['emojiSearchIndex'];
/** If true, focuses the text input on component mount */
focus?: boolean;
- /** If true, expands the text input vertically for new lines */
- grow?: boolean;
/** Allows to hide MessageInput's send button. */
hideSendButton?: boolean;
/** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */
diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx
index 93eca9928..21f9bf154 100644
--- a/src/components/MessageInput/MessageInputFlat.tsx
+++ b/src/components/MessageInput/MessageInputFlat.tsx
@@ -19,7 +19,7 @@ import {
QuotedMessagePreviewHeader,
} from './QuotedMessagePreview';
import { LinkPreviewList as DefaultLinkPreviewList } from './LinkPreviewList';
-import { TextareaComposer } from '../TextareaComposer';
+import { TextareaComposer as DefaultTextareaComposer } from '../TextareaComposer';
import { AIStates, useAIState } from '../AIStateIndicator';
import { RecordingAttachmentType } from '../MediaRecorder/classes';
@@ -53,7 +53,8 @@ export const MessageInputFlat = () => {
SendButton = DefaultSendButton,
StartRecordingAudioButton = DefaultStartRecordingAudioButton,
StopAIGenerationButton: StopAIGenerationButtonOverride,
- } = useComponentContext('MessageInputFlat');
+ TextareaComposer = DefaultTextareaComposer,
+ } = useComponentContext();
const { channel } = useChatContext('MessageInputFlat');
const { aiState } = useAIState(channel);
diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts
index c31522353..e5f4e2513 100644
--- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts
+++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts
@@ -12,10 +12,8 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) =>
cooldownRemaining,
emojiSearchIndex,
focus,
- grow,
handleSubmit,
hideSendButton,
- insertText,
isThreadInput,
maxRows,
minRows,
@@ -39,10 +37,8 @@ export const useCreateMessageInputContext = (value: MessageInputContextValue) =>
cooldownRemaining,
emojiSearchIndex,
focus,
- grow,
handleSubmit,
hideSendButton,
- insertText,
isThreadInput,
maxRows,
minRows,
diff --git a/src/components/MessageInput/hooks/useMessageInputControls.ts b/src/components/MessageInput/hooks/useMessageInputControls.ts
index 212af9fa7..8f1382d99 100644
--- a/src/components/MessageInput/hooks/useMessageInputControls.ts
+++ b/src/components/MessageInput/hooks/useMessageInputControls.ts
@@ -1,5 +1,5 @@
import type React from 'react';
-import { useMessageInputText } from './useMessageInputText';
+import { useTextareaRef } from './useTextareaRef';
import { useSubmitHandler } from './useSubmitHandler';
import { usePasteHandler } from './usePasteHandler';
import { useMediaRecorder } from '../../MediaRecorder/hooks/useMediaRecorder';
@@ -12,7 +12,6 @@ export type MessageInputHookProps = {
event?: React.BaseSyntheticEvent,
customMessageData?: Omit,
) => void;
- insertText: (textToInsert: string) => void;
onPaste: (event: React.ClipboardEvent) => void;
recordingController: RecordingController;
textareaRef: React.MutableRefObject;
@@ -24,7 +23,7 @@ export const useMessageInputControls = (
const { asyncMessagesMultiSendEnabled, audioRecordingConfig, audioRecordingEnabled } =
props;
- const { insertText, textareaRef } = useMessageInputText(props);
+ const { textareaRef } = useTextareaRef(props);
const { handleSubmit } = useSubmitHandler(props);
@@ -35,11 +34,10 @@ export const useMessageInputControls = (
recordingConfig: audioRecordingConfig,
});
- const { onPaste } = usePasteHandler(insertText);
+ const { onPaste } = usePasteHandler();
return {
handleSubmit,
- insertText,
onPaste,
recordingController,
textareaRef,
diff --git a/src/components/MessageInput/hooks/useMessageInputText.ts b/src/components/MessageInput/hooks/useMessageInputText.ts
deleted file mode 100644
index 1d184d05e..000000000
--- a/src/components/MessageInput/hooks/useMessageInputText.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useCallback, useEffect, useRef } from 'react';
-import { type TextComposerState } from 'stream-chat';
-import type { MessageInputProps } from '../MessageInput';
-
-import { useMessageComposer } from './useMessageComposer';
-import { useStateStore } from '../../../store';
-
-const messageComposerStateSelector = (state: TextComposerState) => ({
- text: state.text,
-});
-
-export const useMessageInputText = (props: MessageInputProps) => {
- const { focus } = props;
- const messageComposer = useMessageComposer();
- const textareaRef = useRef(undefined);
- const { text } = useStateStore(
- messageComposer.textComposer.state,
- messageComposerStateSelector,
- );
- // Focus
- useEffect(() => {
- if (focus && textareaRef.current) {
- textareaRef.current.focus();
- }
- }, [focus]);
-
- // Text + cursor position
- const newCursorPosition = useRef(undefined);
-
- const insertText = useCallback(
- (textToInsert: string) => {
- const selection = textareaRef?.current && {
- end: textareaRef.current.selectionEnd,
- start: textareaRef.current.selectionStart,
- };
- messageComposer.textComposer.insertText({
- selection,
- text: textToInsert,
- });
- if (selection) newCursorPosition.current = selection.start + textToInsert.length;
- },
- [messageComposer, newCursorPosition, textareaRef],
- );
-
- useEffect(() => {
- const textareaElement = textareaRef.current;
- if (textareaElement && newCursorPosition.current !== undefined) {
- textareaElement.selectionStart = newCursorPosition.current;
- textareaElement.selectionEnd = newCursorPosition.current;
- newCursorPosition.current = undefined;
- }
- }, [text, newCursorPosition]);
-
- return {
- insertText,
- textareaRef,
- };
-};
diff --git a/src/components/MessageInput/hooks/usePasteHandler.ts b/src/components/MessageInput/hooks/usePasteHandler.ts
index 7ab87bb38..b35dbd48f 100644
--- a/src/components/MessageInput/hooks/usePasteHandler.ts
+++ b/src/components/MessageInput/hooks/usePasteHandler.ts
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
import { useMessageComposer } from './useMessageComposer';
import { dataTransferItemsToFiles } from '../../ReactFileUtilities';
-export const usePasteHandler = (insertText: (textToInsert: string) => void) => {
- const { attachmentManager } = useMessageComposer();
+export const usePasteHandler = () => {
+ const { attachmentManager, textComposer } = useMessageComposer();
const onPaste = useCallback(
(clipboardEvent: React.ClipboardEvent) => {
(async (event) => {
@@ -29,13 +29,13 @@ export const usePasteHandler = (insertText: (textToInsert: string) => void) => {
if (plainTextPromise) {
const pastedText = await plainTextPromise;
- insertText(pastedText);
+ textComposer.insertText({ text: pastedText });
} else {
attachmentManager.uploadFiles(fileLikes);
}
})(clipboardEvent);
},
- [attachmentManager, insertText],
+ [attachmentManager, textComposer],
);
return { onPaste };
diff --git a/src/components/MessageInput/hooks/useTextareaRef.ts b/src/components/MessageInput/hooks/useTextareaRef.ts
new file mode 100644
index 000000000..dd70d7659
--- /dev/null
+++ b/src/components/MessageInput/hooks/useTextareaRef.ts
@@ -0,0 +1,17 @@
+import { useEffect, useRef } from 'react';
+import type { MessageInputProps } from '../MessageInput';
+
+export const useTextareaRef = (props: MessageInputProps) => {
+ const { focus } = props;
+ const textareaRef = useRef(undefined);
+ // Focus
+ useEffect(() => {
+ if (focus && textareaRef.current) {
+ textareaRef.current.focus();
+ }
+ }, [focus]);
+
+ return {
+ textareaRef,
+ };
+};
diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx
index 8543e10a2..1d465f5a5 100644
--- a/src/components/TextareaComposer/TextareaComposer.tsx
+++ b/src/components/TextareaComposer/TextareaComposer.tsx
@@ -1,5 +1,12 @@
+import debounce from 'lodash.debounce';
import clsx from 'clsx';
-import type { ChangeEventHandler, TextareaHTMLAttributes, UIEventHandler } from 'react';
+import type {
+ ChangeEventHandler,
+ SyntheticEvent,
+ TextareaHTMLAttributes,
+ UIEventHandler,
+} from 'react';
+import { useMemo } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Textarea from 'react-textarea-autosize';
import { useMessageComposer } from '../MessageInput';
@@ -40,17 +47,15 @@ const configStateSelector = (state: MessageComposerConfig) => ({
const defaultShouldSubmit = (event: React.KeyboardEvent) =>
event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing;
-export type TextComposerProps = Omit<
+export type TextareaComposerProps = Omit<
TextareaHTMLAttributes,
- 'style' | 'defaultValue' | 'disabled'
+ 'style' | 'defaultValue' | 'disabled' | 'value'
> & {
closeSuggestionsOnClickOutside?: boolean;
containerClassName?: string;
- dropdownClassName?: string;
- grow?: boolean;
- itemClassName?: string;
listClassName?: string;
maxRows?: number;
+ minRows?: number;
shouldSubmit?: (event: React.KeyboardEvent) => boolean;
};
@@ -58,34 +63,33 @@ export const TextareaComposer = ({
className,
closeSuggestionsOnClickOutside,
containerClassName,
- // dropdownClassName, // todo: X find a different way to prevent prop drilling
- grow: growProp,
- // itemClassName, // todo: X find a different way to prevent prop drilling
listClassName,
maxRows: maxRowsProp = 1,
+ minRows: minRowsProp,
onBlur,
onChange,
onKeyDown,
onScroll,
+ onSelect,
placeholder: placeholderProp,
shouldSubmit: shouldSubmitProp,
- ...restProps
-}: TextComposerProps) => {
+ ...restTextareaProps
+}: TextareaComposerProps) => {
const { t } = useTranslationContext();
const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext();
const {
additionalTextareaProps,
cooldownRemaining,
- grow: growContext,
handleSubmit,
maxRows: maxRowsContext,
+ minRows: minRowsContext,
onPaste,
shouldSubmit: shouldSubmitContext,
textareaRef,
} = useMessageInputContext();
- const grow = growProp ?? growContext;
const maxRows = maxRowsProp ?? maxRowsContext;
+ const minRows = minRowsProp ?? minRowsContext;
const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder;
const shouldSubmit = shouldSubmitProp ?? shouldSubmitContext ?? defaultShouldSubmit;
@@ -205,6 +209,22 @@ export const TextareaComposer = ({
[onScroll, textComposer],
);
+ const setSelectionDebounced = useMemo(
+ () =>
+ debounce(
+ (e: SyntheticEvent) => {
+ onSelect?.(e);
+ textComposer.setSelection({
+ end: (e.target as HTMLTextAreaElement).selectionEnd,
+ start: (e.target as HTMLTextAreaElement).selectionStart,
+ });
+ },
+ 100,
+ { leading: false, trailing: true },
+ ),
+ [onSelect, textComposer],
+ );
+
useEffect(() => {
// FIXME: find the real reason for cursor being set to the end on each change
// This is a workaround to prevent the cursor from jumping
@@ -235,7 +255,7 @@ export const TextareaComposer = ({
ref={containerRef}
>