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} >