From 10251ec3d22b9ec9be8552aedbc9391f9423504c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 20 Feb 2025 15:43:07 +0100 Subject: [PATCH 01/99] feat: support message draft management for main message list --- src/components/Channel/Channel.tsx | 184 ++++--- src/components/Channel/channelState.ts | 2 + .../hooks/useCreateChannelStateContext.ts | 6 + src/components/Channel/utils.ts | 17 +- .../FileAttachmentPreview.tsx | 12 +- .../MessageInput/MessageDraftSynchronizer.tsx | 221 ++++++++ src/components/MessageInput/MessageInput.tsx | 11 +- .../__tests__/MessageInput.test.js | 488 +++++++++++++++++- .../hooks/useCreateMessageInputContext.ts | 7 + .../MessageInput/hooks/useLinkPreviews.ts | 3 +- .../hooks/useMessageInputState/index.ts | 2 + .../initMessageInputState.ts | 72 +++ .../useMessageInputState.ts | 159 +++--- .../MessageInput/hooks/useMessageInputText.ts | 10 +- .../MessageInput/hooks/useSubmitHandler.ts | 251 +++++---- src/context/ChannelStateContext.tsx | 17 +- src/context/ComponentContext.tsx | 4 +- src/context/MessageInputContext.tsx | 4 +- src/mock-builders/api/getOrCreateChannel.js | 3 + src/mock-builders/event/draftDeleted.ts | 16 + src/mock-builders/event/draftUpdated.ts | 16 + src/mock-builders/generator/messageDraft.ts | 17 + 22 files changed, 1205 insertions(+), 317 deletions(-) create mode 100644 src/components/MessageInput/MessageDraftSynchronizer.tsx create mode 100644 src/components/MessageInput/hooks/useMessageInputState/index.ts create mode 100644 src/components/MessageInput/hooks/useMessageInputState/initMessageInputState.ts rename src/components/MessageInput/hooks/{ => useMessageInputState}/useMessageInputState.ts (73%) create mode 100644 src/mock-builders/event/draftDeleted.ts create mode 100644 src/mock-builders/event/draftUpdated.ts create mode 100644 src/mock-builders/generator/messageDraft.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 5d43620601..21172edf7b 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -76,7 +76,12 @@ import { useChannelContainerClasses, useImageFlagEmojisOnWindowsClass, } from './hooks/useChannelContainerClasses'; -import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils'; +import { + findInMsgSetByDate, + findInMsgSetById, + generateMessageId, + makeAddNotifications, +} from './utils'; import { useThreadContext } from '../Threads'; import { getChannel } from '../../utils'; @@ -166,83 +171,89 @@ const isUserResponseArray = ( output: string[] | UserResponse[], ): output is UserResponse[] => (output as UserResponse[])[0]?.id != null; -export type ChannelProps = - ChannelPropsForwardedToComponentContext & { - /** List of accepted file types */ - acceptedFiles?: string[]; - /** Custom handler function that runs when the active channel has unread messages and the app is running on a separate browser tab */ - activeUnreadHandler?: (unread: number, documentTitle: string) => void; - /** The connected and active channel */ - channel?: StreamChannel; - /** - * Optional configuration parameters used for the initial channel query. - * Applied only if the value of channel.initialized is false. - * If the channel instance has already been initialized (channel has been queried), - * then the channel query will be skipped and channelQueryOptions will not be applied. - */ - channelQueryOptions?: ChannelQueryOptions; - /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ - doDeleteMessageRequest?: (message: StreamMessage) => Promise; - /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ - doMarkReadRequest?: ( - channel: StreamChannel, - setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, - ) => Promise | void; - /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ - doSendMessageRequest?: ( - channel: StreamChannel, - message: Message, - options?: SendMessageOptions, - ) => ReturnType | void; - /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ - doUpdateMessageRequest?: ( - cid: string, - updatedMessage: UpdatedMessage, - options?: UpdateMessageOptions, - ) => ReturnType; - /** 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; - /** - * A global flag to toggle the URL enrichment and link previews in `MessageInput` components. - * By default, the feature is disabled. Can be overridden on Thread, MessageList level through additionalMessageInputProps - * or directly on MessageInput level through urlEnrichmentConfig. - */ - enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; - /** Global configuration for link preview generation in all the MessageInput components */ - enrichURLForPreviewConfig?: Omit; - /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ - giphyVersion?: GiphyVersions; - /** A custom function to provide size configuration for image attachments */ - imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; - /** - * Allows to prevent triggering the channel.watch() call when mounting the component. - * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. - * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. - */ - initializeOnMount?: boolean; - /** Custom UI component to be shown if the channel query fails, defaults to and accepts same props as: [LoadingErrorIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingErrorIndicator.tsx) */ - LoadingErrorIndicator?: React.ComponentType; - /** Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is marked read on mount. */ - markReadOnMount?: boolean; - /** Maximum number of attachments allowed per message */ - maxNumberOfFiles?: number; - /** Whether to allow multiple attachment uploads */ - multipleUploads?: boolean; - /** Custom action handler function to run on click of an @mention in a message */ - 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) */ - optionalMessageInputProps?: MessageInputProps; - /** You can turn on/off thumbnail generation for video attachments */ - shouldGenerateVideoThumbnail?: boolean; - /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ - skipMessageDataMemoization?: boolean; - /** A custom function to provide size configuration for video attachments */ - videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; - }; +export type ChannelProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger, +> = ChannelPropsForwardedToComponentContext & { + /** List of accepted file types */ + acceptedFiles?: string[]; + /** Custom handler function that runs when the active channel has unread messages and the app is running on a separate browser tab */ + activeUnreadHandler?: (unread: number, documentTitle: string) => void; + /** The connected and active channel */ + channel?: StreamChannel; + /** + * Optional configuration parameters used for the initial channel query. + * Applied only if the value of channel.initialized is false. + * If the channel instance has already been initialized (channel has been queried), + * then the channel query will be skipped and channelQueryOptions will not be applied. + */ + channelQueryOptions?: ChannelQueryOptions; + /** Custom action handler to override the default `client.deleteMessage(message.id)` function */ + doDeleteMessageRequest?: ( + message: StreamMessage, + ) => Promise>; + /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ + doMarkReadRequest?: ( + channel: StreamChannel, + setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, + ) => Promise> | void; + /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ + doSendMessageRequest?: ( + channel: StreamChannel, + message: Message, + options?: SendMessageOptions, + ) => ReturnType['sendMessage']> | void; + /** Custom action handler to override the default `client.updateMessage` request function (advanced usage only) */ + doUpdateMessageRequest?: ( + cid: string, + updatedMessage: UpdatedMessage, + options?: UpdateMessageOptions, + ) => ReturnType['updateMessage']>; + /** 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; + /** + * A global flag to toggle the URL enrichment and link previews in `MessageInput` components. + * By default, the feature is disabled. Can be overridden on Thread, MessageList level through additionalMessageInputProps + * or directly on MessageInput level through urlEnrichmentConfig. + */ + enrichURLForPreview?: URLEnrichmentConfig['enrichURLForPreview']; + /** Global configuration for link preview generation in all the MessageInput components */ + enrichURLForPreviewConfig?: Omit; + /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ + giphyVersion?: GiphyVersions; + /** A custom function to provide size configuration for image attachments */ + imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; + /** + * Allows to prevent triggering the channel.watch() call when mounting the component. + * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. + * Preventing to initialize the channel on mount allows us to postpone the channel creation to a later point in time. + */ + initializeOnMount?: boolean; + /** Custom UI component to be shown if the channel query fails, defaults to and accepts same props as: [LoadingErrorIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Loading/LoadingErrorIndicator.tsx) */ + LoadingErrorIndicator?: React.ComponentType; + /** Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is marked read on mount. */ + markReadOnMount?: boolean; + /** Maximum number of attachments allowed per message */ + maxNumberOfFiles?: number; + /** Enables storing message drafts on the server. */ + messageDraftsEnabled?: boolean; + /** Whether to allow multiple attachment uploads */ + multipleUploads?: boolean; + /** Custom action handler function to run on click of an @mention in a message */ + 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) */ + optionalMessageInputProps?: MessageInputProps; + /** You can turn on/off thumbnail generation for video attachments */ + shouldGenerateVideoThumbnail?: boolean; + /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ + skipMessageDataMemoization?: boolean; + /** A custom function to provide size configuration for video attachments */ + videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; +}; const ChannelContainer = ({ children, @@ -323,6 +334,7 @@ const ChannelInner = ( LoadingIndicator = DefaultLoadingIndicator, markReadOnMount = true, maxNumberOfFiles, + messageDraftsEnabled, multipleUploads = true, onMentionsClick, onMentionsHover, @@ -348,14 +360,18 @@ const ChannelInner = ( const thread = useThreadContext(); const [channelConfig, setChannelConfig] = useState(channel.getConfig()); + // FIXME: Create a proper notification service in the LLC. const [notifications, setNotifications] = useState([]); - const [quotedMessage, setQuotedMessage] = useState(); + const notificationTimeouts = useRef>([]); + + // FIXME: Move to LLC message list controller. + const [quotedMessage, setQuotedMessage] = useState< + StreamMessage | undefined + >(messageDraftsEnabled ? channel.state.messageDraft?.quoted_message : undefined); const [channelUnreadUiState, _setChannelUnreadUiState] = useState(); - const notificationTimeouts = useRef>([]); - - const channelReducer = useMemo(() => makeChannelReducer(), []); + const channelReducer = useMemo(() => makeChannelReducer(), []); const [state, dispatch] = useReducer( channelReducer, @@ -365,6 +381,7 @@ const ChannelInner = ( ...initialState, hasMore: channel.state.messagePagination.hasPrev, loading: !channel.initialized, + messageDraft: channel.state.messageDraft, }, ); const jumpToMessageFromSearch = useSearchFocusedMessage(); @@ -1077,7 +1094,7 @@ const ChannelInner = ( attachments, created_at: new Date(), html: text, - id: customMessageData?.id ?? `${client.userID}-${nanoid()}`, + id: customMessageData?.id ?? generateMessageId({ client }), mentioned_users, parent_id: parent?.id, reactions: [], @@ -1216,6 +1233,7 @@ const ChannelInner = ( imageAttachmentSizeHandler: props.imageAttachmentSizeHandler || getImageAttachmentConfiguration, maxNumberOfFiles, + messageDraftsEnabled, multipleUploads, mutes, notifications, diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts index 1ced66fab5..ff8e196384 100644 --- a/src/components/Channel/channelState.ts +++ b/src/components/Channel/channelState.ts @@ -115,6 +115,7 @@ export const makeChannelReducer = return { ...state, members: { ...channel.state.members }, + messageDraft: channel.state.messageDraft, messages: [...channel.state.messages], pinnedMessages: [...channel.state.pinnedMessages], read: { ...channel.state.read }, @@ -130,6 +131,7 @@ export const makeChannelReducer = hasMore, loading: false, members: { ...channel.state.members }, + messageDraft: channel.state.messageDraft, messages: [...channel.state.messages], pinnedMessages: [...channel.state.pinnedMessages], read: { ...channel.state.read }, diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index 153447759d..1f7bf0f048 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -30,6 +30,8 @@ export const useCreateChannelStateContext = ( loadingMore, maxNumberOfFiles, members, + messageDraft, + messageDraftsEnabled, messages = [], multipleUploads, mutes, @@ -125,6 +127,8 @@ export const useCreateChannelStateContext = ( loadingMore, maxNumberOfFiles, members, + messageDraft, + messageDraftsEnabled, messages, multipleUploads, mutes, @@ -162,6 +166,8 @@ export const useCreateChannelStateContext = ( membersLength, memoizedMessageData, memoizedThreadMessageData, + messageDraft, + messageDraftsEnabled, notificationsLength, onLinkPreviewDismissed, quotedMessage, diff --git a/src/components/Channel/utils.ts b/src/components/Channel/utils.ts index f3a5055e32..a8e2344a94 100644 --- a/src/components/Channel/utils.ts +++ b/src/components/Channel/utils.ts @@ -1,7 +1,12 @@ import { nanoid } from 'nanoid'; import type { Dispatch, SetStateAction } from 'react'; -import type { ChannelState, MessageResponse } from 'stream-chat'; - +import type { + ChannelState, + DefaultGenerics, + ExtendableGenerics, + MessageResponse, + StreamChat, +} from 'stream-chat'; import type { ChannelNotifications } from '../../context/ChannelStateContext'; export const makeAddNotifications = @@ -99,3 +104,11 @@ export const findInMsgSetByDate = ( } return { index: -1 }; }; + +export const generateMessageId = < + StreamChatGenerics extends ExtendableGenerics = DefaultGenerics, +>({ + client, +}: { + client: StreamChat; +}) => `${client.userID}-${nanoid()}`; diff --git a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx index f32a3695cf..e91f4be817 100644 --- a/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/FileAttachmentPreview.tsx @@ -25,6 +25,7 @@ export const FileAttachmentPreview = ({ removeAttachments, }: FileAttachmentPreviewProps) => { const { t } = useTranslationContext('FilePreview'); + const uploadState = attachment.localMetadata?.uploadState; return (
attachment.localMetadata?.id && removeAttachments([attachment.localMetadata?.id]) @@ -47,7 +48,7 @@ export const FileAttachmentPreview = ({ - {attachment.localMetadata?.uploadState === 'failed' && !!handleRetry && ( + {uploadState === 'failed' && !!handleRetry && (
- {attachment.localMetadata?.uploadState === 'finished' && + {/* undefined if loaded from a draft */} + {(typeof uploadState === 'undefined' || uploadState === 'finished') && !!attachment.asset_url && ( )} - {attachment.localMetadata?.uploadState === 'uploading' && ( - - )} + {uploadState === 'uploading' && } ); diff --git a/src/components/MessageInput/MessageDraftSynchronizer.tsx b/src/components/MessageInput/MessageDraftSynchronizer.tsx new file mode 100644 index 0000000000..44f6c5c49c --- /dev/null +++ b/src/components/MessageInput/MessageDraftSynchronizer.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef } from 'react'; +import { generateMessageId } from '../Channel/utils'; +import { EnrichURLsController } from './hooks/useLinkPreviews'; +import { prepareMessage } from './hooks/useSubmitHandler'; +import { initState } from './hooks/useMessageInputState/initMessageInputState'; +import { + StreamMessage, + useChannelActionContext, + useChannelStateContext, + useChatContext, + useMessageInputContext, +} from '../../context'; +import type { + DraftMessagePayload, + DraftResponse, + Event, + UserResponse, +} from 'stream-chat'; +import type { LinkPreviewMap, LocalAttachment } from './types'; +import type { DefaultStreamChatGenerics, PropsWithChildrenOnly } from '../../types/types'; + +type MessageDraftRefs< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick & { + attachments: LocalAttachment[]; + lastChange: Date | undefined; + linkPreviews: LinkPreviewMap; + mentioned_users: UserResponse[]; + numberOfUploads: number; + text: string; + editedMessage?: StreamMessage; + messageDraft?: DraftResponse; + parent?: StreamMessage; + quotedMessage?: StreamMessage; +}; + +// FIXME: Move all the logic from this component to the LLC Channel reactive state +export const MessageDraftSynchronizer = < + SCG extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + children, +}: PropsWithChildrenOnly) => { + const { client } = useChatContext(); + const { setQuotedMessage } = useChannelActionContext(); + const { channel, messageDraft, messageDraftsEnabled, quotedMessage } = + useChannelStateContext(); + + const { + attachments, + cancelURLEnrichment, + findAndEnqueueURLsToEnrich, + lastChange, + linkPreviews, + message: editedMessage, + mentioned_users, + numberOfUploads, + parent, + setComposerState, + text, + } = useMessageInputContext(); + + const draftPrereqRefs = useRef>({ + attachments, + cancelURLEnrichment, + editedMessage, + findAndEnqueueURLsToEnrich, + lastChange, + linkPreviews, + mentioned_users, + messageDraft, + numberOfUploads, + parent, + quotedMessage, + text, + }); + + useEffect(() => { + if (!messageDraftsEnabled) return; + if (draftPrereqRefs.current.editedMessage || !channel) return; + + draftPrereqRefs.current = { + attachments, + cancelURLEnrichment, + editedMessage, + findAndEnqueueURLsToEnrich, + lastChange, + linkPreviews, + mentioned_users, + messageDraft, + numberOfUploads, + parent, + quotedMessage, + text, + }; + }, [ + attachments, + cancelURLEnrichment, + editedMessage, + findAndEnqueueURLsToEnrich, + lastChange, + linkPreviews, + messageDraft, + mentioned_users, + numberOfUploads, + parent, + quotedMessage, + text, + ]); + + useEffect(() => { + // delete the draft if composer is empty + if (!messageDraftsEnabled) return; + if (draftPrereqRefs.current.editedMessage || !channel) return; + + const quotedMessageRemovedLocally = + !quotedMessage && !!draftPrereqRefs.current.messageDraft?.message.quoted_message_id; + // use lastChange to prevent ignoring a draft that was received via draft.updated event (draft created in another window / device) + const localMessageChangeIsLast = + !!messageDraft && + (lastChange?.getTime() ?? 0) > new Date(messageDraft.created_at).getTime(); + const shouldClear = + messageDraft && + (localMessageChangeIsLast || quotedMessageRemovedLocally) && + !text && + !attachments?.length && + !quotedMessage; + if (!shouldClear) return; + + const deletePayload: { parent_id?: string } = {}; + if (messageDraft.parent_id) { + deletePayload.parent_id = messageDraft.parent_id; + } + channel.deleteMessageDraft(deletePayload); + }, [attachments, channel, lastChange, messageDraft, quotedMessage, text]); + + useEffect(() => { + if (!messageDraftsEnabled) return; + if (draftPrereqRefs.current.editedMessage || !channel) return; + + return channel.on('draft.updated', (event: Event) => { + const draft = event.draft as DraftResponse; + const { attachments, linkPreviews, mentioned_users, text } = initState({ + attachments: draft.message?.attachments ?? [], + mentioned_users: draft.message?.mentioned_users ?? [], + text: draft.message?.text ?? '', + } as Pick, 'attachments' | 'mentioned_users' | 'text'>); + setComposerState({ + attachments, + lastChange: new Date(), + linkPreviews, + mentioned_users, + text, + }); + setQuotedMessage(draft.quoted_message); + }).unsubscribe; + }, [channel, setComposerState]); + + useEffect(() => { + if (!messageDraftsEnabled) return; + if (draftPrereqRefs.current.editedMessage || !channel) return; + + return channel.on('draft.deleted', () => { + setComposerState({ + ...initState(), + lastChange: new Date(), + }); + setQuotedMessage(undefined); + }).unsubscribe; + }, [channel, setComposerState]); + + useEffect(() => { + // create draft when leaving the channel + if (!messageDraftsEnabled) return; + if (draftPrereqRefs.current.editedMessage || !client || !channel) return; + + return () => { + const { message, notification } = prepareMessage({ + attachments: draftPrereqRefs.current.attachments, + cancelURLEnrichment: draftPrereqRefs.current.cancelURLEnrichment, + findAndEnqueueURLsToEnrich: draftPrereqRefs.current.findAndEnqueueURLsToEnrich, + linkPreviews: draftPrereqRefs.current.linkPreviews, + mentioned_users: draftPrereqRefs.current.mentioned_users, + numberOfUploads: draftPrereqRefs.current.numberOfUploads, + text: draftPrereqRefs.current.text, + }); + + const hasData = !!(draftPrereqRefs.current.quotedMessage || message); + const quotedMessageAddedLocally = + !!draftPrereqRefs.current.quotedMessage?.id && + !draftPrereqRefs.current.messageDraft?.message.quoted_message_id; // todo: check if quoted_message is returned from channel query + const messageUpdatedLocally = + draftPrereqRefs.current.lastChange && + draftPrereqRefs.current.lastChange > + new Date(draftPrereqRefs.current.messageDraft?.created_at ?? 0); + + const shouldCreateDraft = + hasData && (messageUpdatedLocally || quotedMessageAddedLocally); + + if (!shouldCreateDraft || notification?.type === 'error') return; + + const draftMessagePayload: DraftMessagePayload = { + ...message, + id: + draftPrereqRefs.current.messageDraft?.message.id || + generateMessageId({ client }), + mentioned_users: message?.mentioned_users.map((u) => u.id), + text: message?.text ?? '', + }; + + if (draftPrereqRefs.current.parent?.id) { + draftMessagePayload.parent_id = draftPrereqRefs.current.parent.id; + } + if (draftPrereqRefs.current.quotedMessage) { + draftMessagePayload.quoted_message_id = draftPrereqRefs.current.quotedMessage.id; + } + channel.draftMessage(draftMessagePayload); + }; + }, [channel, client]); + + return children; +}; diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index 323837eb38..7dae9ba221 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import { DefaultTriggerProvider } from './DefaultTriggerProvider'; +import { MessageDraftSynchronizer as DefaultMessageDraftSynchronizer } from './MessageDraftSynchronizer'; import { MessageInputFlat } from './MessageInputFlat'; import { useCooldownTimer } from './hooks/useCooldownTimer'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; @@ -152,9 +153,12 @@ const UnMemoizedMessageInput = ( ) => { const { Input: PropInput } = props; - const { dragAndDropWindow } = useChannelStateContext(); - const { Input: ContextInput, TriggerProvider = DefaultTriggerProvider } = - useComponentContext('MessageInput'); + const { dragAndDropWindow } = useChannelStateContext(); + const { + Input: ContextInput, + MessageDraftSynchronizer = DefaultMessageDraftSynchronizer, + TriggerProvider = DefaultTriggerProvider, + } = useComponentContext('MessageInput'); const Input = PropInput || ContextInput || MessageInputFlat; const dialogManagerId = props.isThreadInput @@ -174,6 +178,7 @@ const UnMemoizedMessageInput = ( + diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index 74c4d181d7..d1bee6dce2 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -19,6 +19,7 @@ import { dispatchMessageDeletedEvent, dispatchMessageUpdatedEvent, generateChannel, + generateFileAttachment, generateMember, generateMessage, generateScrapedDataAttachment, @@ -27,6 +28,9 @@ import { } from '../../../mock-builders'; import { generatePoll } from '../../../mock-builders/generator/poll'; import { QuotedMessagePreview } from '../QuotedMessagePreview'; +import { generateMessageDraft } from '../../../mock-builders/generator/messageDraft'; +import { dispatchDraftUpdated } from '../../../mock-builders/event/draftUpdated'; +import { dispatchDraftDeleted } from '../../../mock-builders/event/draftDeleted'; expect.extend(toHaveNoViolations); @@ -92,6 +96,7 @@ const editMock = jest.fn(); const mockAddNotification = jest.fn(); jest.mock('../../Channel/utils', () => ({ + ...jest.requireActual('../../Channel/utils'), makeAddNotifications: () => mockAddNotification, })); @@ -1465,6 +1470,29 @@ describe('EditMessageForm only', () => { }); }); +const initQuotedMessagePreview = async (message) => { + await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument()); + + const quoteButton = await screen.findByText(/^reply$/i); + await waitFor(() => expect(quoteButton).toBeInTheDocument()); + + act(() => { + fireEvent.click(quoteButton); + }); +}; + +const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { + await waitFor(() => + expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), + ); + await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); +}; + +const quotedMessagePreviewIsNotDisplayed = (message) => { + expect(screen.queryByText(/reply to message/i)).not.toBeInTheDocument(); + expect(screen.queryByText(message.text)).not.toBeInTheDocument(); +}; + describe(`MessageInputFlat only`, () => { afterEach(tearDown); @@ -1521,31 +1549,6 @@ describe(`MessageInputFlat only`, () => { }); }; - const initQuotedMessagePreview = async () => { - await waitFor(() => - expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(), - ); - - const quoteButton = await screen.findByText(/^reply$/i); - await waitFor(() => expect(quoteButton).toBeInTheDocument()); - - act(() => { - fireEvent.click(quoteButton); - }); - }; - - const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { - await waitFor(() => - expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), - ); - await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); - }; - - const quotedMessagePreviewIsNotDisplayed = (message) => { - expect(screen.queryByText(/reply to message/i)).not.toBeInTheDocument(); - expect(screen.queryByText(message.text)).not.toBeInTheDocument(); - }; - describe('QuotedMessagePreview', () => { it('is displayed on quote action click', async () => { await renderComponent(); @@ -1744,3 +1747,438 @@ describe(`MessageInputFlat only`, () => { }); }); }); + +describe('Message drafts', () => { + afterEach(tearDown); + + const renderComponent = makeRenderFn(MessageInputFlat); + // todo: thread + + describe.each([ + [ + 'enabled', + { + channelProps: { messageDraftsEnabled: true }, + messageInputProps: { message: undefined }, + }, + ], + [ + 'disabled', + { + channelProps: { messageDraftsEnabled: undefined }, + messageInputProps: { message: undefined }, + }, + ], + [ + 'editing message', + { + channelProps: { messageDraftsEnabled: true }, + messageInputProps: { message: generateMessage() }, + }, + ], + ])('%s', (scenario, { channelProps, messageInputProps }) => { + const channelData = generateChannel(); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + }); + + it('initiates with draft', async () => { + const channelData = generateChannel({ + channel: { own_capabilities: ['upload-file'] }, + }); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ + attachments: [generateFileAttachment()], + quoted_message_id: mainListMessage.id, + mentioned_users: ['Y'], + }), + quoted_message: mainListMessage, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + const textarea = screen.getByPlaceholderText(inputPlaceholder); + const filePreview = screen.queryByTestId(FILE_PREVIEW_TEST_ID); + if (scenario === 'enabled') { + expect(textarea.value).toBe(channelData.draft.message.text); + await quotedMessagePreviewIsDisplayedCorrectly(mainListMessage); + expect( + screen.getByText(channelData.draft.message.attachments[0].title), + ).toBeInTheDocument(); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute( + 'href', + channelData.draft.message.attachments[0].asset_url, + ); + }); + // todo: check mentioned users + } else if (scenario === 'disabled') { + expect(textarea.value).toBe(''); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } else if (scenario === 'editing message') { + expect(textarea.value).toBe(messageInputProps.message.text); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } + }); + + it('stores draft on unmount if at least text is not empty', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + const textarea = screen.getByPlaceholderText(inputPlaceholder); + const updatedDraftText = 'X'; + + await act(async () => { + await fireEvent.change(textarea, { + target: { + value: updatedDraftText, + }, + }); + }); + + unmount(); + + if (scenario === 'enabled') { + expect(channel.draftMessage).toHaveBeenCalledWith( + expect.objectContaining({ text: updatedDraftText }), + ); + } else { + expect(channel.draftMessage).not.toHaveBeenCalled(); + } + }); + + it('stores draft on unmount if at least one attachment uploaded', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { own_capabilities: ['upload-file'] } }], + }); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps: { + ...messageInputProps, + doFileUploadRequest: mockUploadApi(), + }, + }); + const file = getFile(); + const input = screen.getByTestId(FILE_INPUT_TEST_ID); + + await act(() => { + fireEvent.change(input, { + target: { + files: [file], + }, + }); + }); + + unmount(); + + if (scenario === 'enabled') { + expect(channel.draftMessage).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: [ + { + asset_url: fileUploadUrl, + file_size: 7, + mime_type: 'text/plain', + title: 'some.txt', + type: 'file', + }, + ], + }), + ); + } else { + expect(channel.draftMessage).not.toHaveBeenCalled(); + } + }); + + it('stores draft on unmount if quoted message exists', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + await initQuotedMessagePreview(mainListMessage); + + unmount(); + + if (scenario === 'enabled') { + expect(channel.draftMessage).toHaveBeenCalledWith( + expect.objectContaining({ quoted_message_id: mainListMessage.id }), + ); + } else { + expect(channel.draftMessage).not.toHaveBeenCalled(); + } + }); + + it('does not store unchanged draft on unmount', async () => { + const channelData = generateChannel({ + channel: { own_capabilities: ['upload-file'] }, + }); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ + attachments: [generateFileAttachment()], + quoted_message_id: mainListMessage.id, + }), + quoted_message: mainListMessage, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + unmount(); + + expect(channel.draftMessage).not.toHaveBeenCalled(); + }); + + it('overrides the draft with WS event draft.updated payload', async () => { + const channelData = generateChannel({ + channel: { own_capabilities: ['upload-file'] }, + }); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ + attachments: [generateFileAttachment()], + quoted_message_id: mainListMessage.id, + }), + quoted_message: mainListMessage, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + const updatedDraft = generateMessageDraft({ + channel_cid: channel.cid, + message: generateMessage({ + attachments: [generateFileAttachment()], + mentioned_users: ['X'], + quoted_message_id: mainListMessage.id, + }), + quoted_message: mainListMessage, + }); + await act(() => { + dispatchDraftUpdated({ client, draft: updatedDraft }); + }); + const textarea = screen.getByPlaceholderText(inputPlaceholder); + const filePreview = screen.queryByTestId(FILE_PREVIEW_TEST_ID); + if (scenario === 'enabled') { + expect(textarea.value).toBe(updatedDraft.message.text); + await quotedMessagePreviewIsDisplayedCorrectly(mainListMessage); + expect( + screen.getByText(updatedDraft.message.attachments[0].title), + ).toBeInTheDocument(); + await waitFor(() => { + expect(filePreview.querySelector('a')).toHaveAttribute( + 'href', + updatedDraft.message.attachments[0].asset_url, + ); + }); + // todo: check mentioned users + } else if (scenario === 'disabled') { + expect(textarea.value).toBe(''); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } else if (scenario === 'editing message') { + expect(textarea.value).toBe(messageInputProps.message.text); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } + }); + + it('removes the draft with WS event draft.deleted', async () => { + const channelData = generateChannel({ + channel: { own_capabilities: ['upload-file'] }, + }); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ + attachments: [generateFileAttachment()], + quoted_message_id: mainListMessage.id, + }), + quoted_message: mainListMessage, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + await act(() => { + dispatchDraftDeleted({ client, draft: channelData.draft }); + }); + const textarea = screen.getByPlaceholderText(inputPlaceholder); + const filePreview = screen.queryByTestId(FILE_PREVIEW_TEST_ID); + if (scenario === 'editing message') { + expect(textarea.value).toBe(messageInputProps.message.text); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } else { + expect(textarea.value).toBe(''); + expect(filePreview).not.toBeInTheDocument(); + expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(); + } + }); + + it('removes draft on text clear if no attachments and no quoted message', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + jest.spyOn(channel, 'deleteMessageDraft').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + const textarea = screen.getByPlaceholderText(inputPlaceholder); + const updatedDraftText = ''; + + await act(async () => { + await fireEvent.change(textarea, { + target: { + value: updatedDraftText, + }, + }); + }); + + unmount(); + + if (scenario === 'enabled') { + expect(channel.draftMessage).not.toHaveBeenCalled(); + expect(channel.deleteMessageDraft).toHaveBeenCalledWith( + expect.objectContaining({}), + ); + } else { + expect(channel.draftMessage).not.toHaveBeenCalled(); + expect(channel.deleteMessageDraft).not.toHaveBeenCalled(); + } + }); + + it('removes the draft if quoted message removed', async () => { + if (scenario !== 'enabled') return; // when the initial draft is disregarded, there is no quoted message preview + + const channelData = generateChannel(); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ + attachments: [], + quoted_message_id: mainListMessage.id, + text: '', + }), + quoted_message: mainListMessage, + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + jest.spyOn(channel, 'deleteMessageDraft').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + if (scenario === 'enabled') { + await act(async () => { + await fireEvent.click(screen.getByLabelText('aria/Cancel Reply')); + }); + } + + unmount(); + + expect(channel.draftMessage).not.toHaveBeenCalled(); + expect(channel.deleteMessageDraft).toHaveBeenCalledWith( + expect.objectContaining({}), + ); + }); + + it('removes draft on last attachment removal if no text and no quoted message', async () => { + if (scenario !== 'enabled') return; // when the initial draft is disregarded, there is no attachment preview + + const channelData = generateChannel({ + channel: { own_capabilities: ['upload-file'] }, + }); + channelData.draft = generateMessageDraft({ + channel_cid: channelData.channel.cid, + message: generateMessage({ attachments: [generateFileAttachment()], text: '' }), + }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ channelsData: [channelData] }); + jest.spyOn(channel, 'draftMessage').mockImplementationOnce(); + jest.spyOn(channel, 'deleteMessageDraft').mockImplementationOnce(); + const { unmount } = await renderComponent({ + channelProps, + customChannel: channel, + customClient: client, + messageInputProps, + }); + + await act(async () => { + await fireEvent.click(screen.getByLabelText('aria/Remove attachment')); + }); + + unmount(); + + expect(channel.draftMessage).not.toHaveBeenCalled(); + expect(channel.deleteMessageDraft).toHaveBeenCalledWith( + expect.objectContaining({}), + ); + }); + }); +}); diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index 5562fc79ad..856840f1e9 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -34,6 +34,7 @@ export const useCreateMessageInputContext = = { + attachments: LocalAttachment[]; + linkPreviews: LinkPreviewMap; + mentioned_users: UserResponse[]; + text: string; + lastChange?: Date; +}; + +export const makeEmptyMessageInputState = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>(): MessageInputState => ({ + attachments: [], + lastChange: undefined, + linkPreviews: new Map(), + mentioned_users: [], + text: '', +}); + +/** + * Initializes the state. Empty if the message prop is falsy. + */ +export const initState = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + message?: Pick< + StreamMessage, + 'attachments' | 'mentioned_users' | 'text' + >, +): MessageInputState => { + if (!message) { + return makeEmptyMessageInputState(); + } + + const linkPreviews = + message.attachments?.reduce((acc, attachment) => { + if (!attachment.og_scrape_url) return acc; + acc.set(attachment.og_scrape_url, { + ...(attachment as OGAttachment), + state: LinkPreviewState.LOADED, + }); + return acc; + }, new Map()) ?? new Map(); + + const attachments = + message.attachments + ?.filter(({ og_scrape_url }) => !og_scrape_url) + .map( + (att) => + ({ + ...att, + localMetadata: { id: nanoid() }, + }) as LocalAttachment, + ) || []; + + const mentioned_users: StreamMessage['mentioned_users'] = message.mentioned_users || []; + + return { + attachments, + lastChange: undefined, + linkPreviews, + mentioned_users, + text: message.text || '', + }; +}; diff --git a/src/components/MessageInput/hooks/useMessageInputState.ts b/src/components/MessageInput/hooks/useMessageInputState/useMessageInputState.ts similarity index 73% rename from src/components/MessageInput/hooks/useMessageInputState.ts rename to src/components/MessageInput/hooks/useMessageInputState/useMessageInputState.ts index 3f54fcc59e..9c45bec7cb 100644 --- a/src/components/MessageInput/hooks/useMessageInputState.ts +++ b/src/components/MessageInput/hooks/useMessageInputState/useMessageInputState.ts @@ -1,35 +1,36 @@ -import type { Reducer } from 'react'; -import type React from 'react'; -import { useCallback, useReducer, useState } from 'react'; -import { nanoid } from 'nanoid'; - -import type { StreamMessage } from '../../../context/ChannelStateContext'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; - -import { useAttachments } from './useAttachments'; -import type { EnrichURLsController } from './useLinkPreviews'; -import { useLinkPreviews } from './useLinkPreviews'; -import { useMessageInputText } from './useMessageInputText'; -import { useSubmitHandler } from './useSubmitHandler'; -import { usePasteHandler } from './usePasteHandler'; -import type { RecordingController } from '../../MediaRecorder/hooks/useMediaRecorder'; -import { useMediaRecorder } from '../../MediaRecorder/hooks/useMediaRecorder'; -import type { LinkPreviewMap, LocalAttachment } from '../types'; -import { LinkPreviewState, SetLinkPreviewMode } from '../types'; -import type { Attachment, Message, OGAttachment, UserResponse } from 'stream-chat'; - -import type { MessageInputProps } from '../MessageInput'; - -import type { CustomTrigger, SendMessageOptions } from '../../../types/types'; -import { mergeDeep } from '../../../utils/mergeDeep'; - -export type MessageInputState = { - attachments: LocalAttachment[]; - linkPreviews: LinkPreviewMap; - mentioned_users: UserResponse[]; - setText: (text: string) => void; - text: string; -}; +import React, { Reducer, useCallback, useReducer, useState } from 'react'; + +import { + StreamMessage, + useChannelStateContext, +} from '../../../../context/ChannelStateContext'; + +import { useAttachments } from '../useAttachments'; +import { EnrichURLsController, useLinkPreviews } from '../useLinkPreviews'; +import { useMessageInputText } from '../useMessageInputText'; +import { useSubmitHandler } from '../useSubmitHandler'; +import { usePasteHandler } from '../usePasteHandler'; +import { + RecordingController, + useMediaRecorder, +} from '../../../MediaRecorder/hooks/useMediaRecorder'; +import type { LinkPreviewMap, LocalAttachment } from '../../types'; +import { LinkPreviewState, SetLinkPreviewMode } from '../../types'; +import type { Attachment, Message, UserResponse } from 'stream-chat'; + +import type { MessageInputProps } from '../../MessageInput'; + +import type { + CustomTrigger, + DefaultStreamChatGenerics, + SendMessageOptions, +} from '../../../../types/types'; +import { mergeDeep } from '../../../../utils/mergeDeep'; +import { + initState, + makeEmptyMessageInputState, + MessageInputState, +} from './initMessageInputState'; type UpsertAttachmentsAction = { attachments: LocalAttachment[]; @@ -46,6 +47,13 @@ type SetTextAction = { type: 'setText'; }; +type SetComposerStateAction< + SCG extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = { + state: MessageInputState; + type: 'setComposerState'; +}; + type ClearAction = { type: 'clear'; }; @@ -67,7 +75,8 @@ export type MessageInputReducerAction = | SetLinkPreviewsAction | AddMentionedUserAction | UpsertAttachmentsAction - | RemoveAttachmentsAction; + | RemoveAttachmentsAction + | SetComposerStateAction; export type MessageInputHookProps = EnrichURLsController & { handleChange: React.ChangeEventHandler; @@ -84,62 +93,14 @@ export type MessageInputHookProps = EnrichURLsController & { onSelectUser: (item: UserResponse) => void; recordingController: RecordingController; removeAttachments: (ids: string[]) => void; + setComposerState: (state: MessageInputState) => void; + setText: (text: string) => void; textareaRef: React.MutableRefObject; uploadAttachment: (attachment: LocalAttachment) => Promise; uploadNewFiles: (files: FileList | File[]) => void; upsertAttachments: (attachments: (Attachment | LocalAttachment)[]) => void; }; -const makeEmptyMessageInputState = (): MessageInputState => ({ - attachments: [], - linkPreviews: new Map(), - mentioned_users: [], - setText: () => null, - text: '', -}); - -/** - * Initializes the state. Empty if the message prop is falsy. - */ -const initState = ( - message?: Pick, -): MessageInputState => { - if (!message) { - return makeEmptyMessageInputState(); - } - - const linkPreviews = - message.attachments?.reduce((acc, attachment) => { - if (!attachment.og_scrape_url) return acc; - acc.set(attachment.og_scrape_url, { - ...(attachment as OGAttachment), - state: LinkPreviewState.LOADED, - }); - return acc; - }, new Map()) ?? new Map(); - - const attachments = - message.attachments - ?.filter(({ og_scrape_url }) => !og_scrape_url) - .map( - (att) => - ({ - ...att, - localMetadata: { id: nanoid() }, - }) as LocalAttachment, - ) || []; - - const mentioned_users: StreamMessage['mentioned_users'] = message.mentioned_users || []; - - return { - attachments, - linkPreviews, - mentioned_users, - setText: () => null, - text: message.text || '', - }; -}; - /** * MessageInput state reducer */ @@ -149,7 +110,7 @@ const messageInputReducer = ( ) => { switch (action.type) { case 'setText': - return { ...state, text: action.getNewText(state.text) }; + return { ...state, lastChange: new Date(), text: action.getNewText(state.text) }; case 'clear': return makeEmptyMessageInputState(); @@ -177,6 +138,7 @@ const messageInputReducer = ( return { ...state, attachments, + lastChange: new Date(), }; } @@ -186,6 +148,7 @@ const messageInputReducer = ( attachments: state.attachments.filter( (att) => !action.ids.includes(att.localMetadata?.id), ), + lastChange: new Date(), }; } @@ -221,6 +184,7 @@ const messageInputReducer = ( return { ...state, linkPreviews, + lastChange: new Date(), }; } @@ -228,6 +192,13 @@ const messageInputReducer = ( return { ...state, mentioned_users: state.mentioned_users.concat(action.user), + lastChange: new Date(), + }; + + case 'setComposerState': + return { + ...action.state, + lastChange: new Date(), }; default: @@ -266,18 +237,27 @@ export const useMessageInputState = ( const { channelCapabilities = {}, enrichURLForPreview: enrichURLForPreviewChannelContext, - } = useChannelStateContext('useMessageInputState'); + messageDraft, + messageDraftsEnabled, + } = useChannelStateContext('useMessageInputState'); const defaultValue = getDefaultValue?.() || additionalTextareaProps?.defaultValue; const initialStateValue = message || + (messageDraftsEnabled && messageDraft?.message) || ((Array.isArray(defaultValue) ? { text: defaultValue.join('') } : { text: defaultValue?.toString() }) as Partial); const [state, dispatch] = useReducer( - messageInputReducer as Reducer, - initialStateValue, + messageInputReducer as Reducer< + MessageInputState, + MessageInputReducerAction + >, + initialStateValue as Pick< + StreamMessage, + 'attachments' | 'mentioned_users' | 'text' + >, initState, ); @@ -360,6 +340,10 @@ export const useMessageInputState = ( dispatch({ getNewText: () => text, type: 'setText' }); }, []); + const setComposerState = useCallback((state: MessageInputState) => { + dispatch({ state, type: 'setComposerState' }); + }, []); + return { ...state, ...enrichURLsController, @@ -377,6 +361,7 @@ export const useMessageInputState = ( openMentionsList, recordingController, removeAttachments, + setComposerState, setText, showCommandsList, showMentionsList, diff --git a/src/components/MessageInput/hooks/useMessageInputText.ts b/src/components/MessageInput/hooks/useMessageInputText.ts index 07799ab172..ddff68cb6e 100644 --- a/src/components/MessageInput/hooks/useMessageInputText.ts +++ b/src/components/MessageInput/hooks/useMessageInputText.ts @@ -100,8 +100,14 @@ export const useMessageInputText = ( logChatPromiseExecution(channel.keystroke(parent?.id), 'start typing event'); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [channel, findAndEnqueueURLsToEnrich, parent, publishTypingEvent], + [ + channel, + dispatch, + findAndEnqueueURLsToEnrich, + logChatPromiseExecution, + parent, + publishTypingEvent, + ], ); return { diff --git a/src/components/MessageInput/hooks/useSubmitHandler.ts b/src/components/MessageInput/hooks/useSubmitHandler.ts index 2d5471fb51..72e0718c8f 100644 --- a/src/components/MessageInput/hooks/useSubmitHandler.ts +++ b/src/components/MessageInput/hooks/useSubmitHandler.ts @@ -2,9 +2,9 @@ import { useEffect, useRef } from 'react'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { useChannelStateContext } from '../../../context/ChannelStateContext'; import { useTranslationContext } from '../../../context/TranslationContext'; -import { LinkPreviewState } from '../types'; +import { type LinkPreviewMap, LinkPreviewState, type LocalAttachment } from '../types'; -import type { Attachment, Message, UpdatedMessage } from 'stream-chat'; +import type { Attachment, Message, UpdatedMessage, UserResponse } from 'stream-chat'; import type { MessageInputReducerAction, @@ -15,10 +15,138 @@ import type { MessageInputProps } from '../MessageInput'; import type { CustomTrigger, SendMessageOptions } from '../../../types/types'; import type { EnrichURLsController } from './useLinkPreviews'; -export const useSubmitHandler = ( - props: MessageInputProps, - state: MessageInputState, - dispatch: React.Dispatch, +export type PrepareMessageParams< + SCG extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Pick & { + attachments: LocalAttachment[]; + linkPreviews: LinkPreviewMap; + mentioned_users: UserResponse[]; + numberOfUploads: number; + text: string; + customMessageData?: Partial>; + options?: SendMessageOptions; +}; + +export const prepareMessage = < + SCG extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + attachments, + cancelURLEnrichment, + customMessageData, + findAndEnqueueURLsToEnrich, + linkPreviews, + mentioned_users, + numberOfUploads, + options, + text, +}: PrepareMessageParams) => { + const trimmedMessage = text.trim(); + const isEmptyMessage = + trimmedMessage === '' || + trimmedMessage === '>' || + trimmedMessage === '``````' || + trimmedMessage === '``' || + trimmedMessage === '**' || + trimmedMessage === '____' || + trimmedMessage === '__' || + trimmedMessage === '****'; + + if ( + isEmptyMessage && + numberOfUploads === 0 && + attachments.length === 0 && + !customMessageData?.poll_id + ) + return {}; + const someAttachmentsUploading = attachments.some( + (att) => att.localMetadata?.uploadState === 'uploading', + ); + + if (someAttachmentsUploading) { + return { + notification: { text: 'Wait until all attachments have uploaded', type: 'error' }, + }; + } + + const attachmentsFromUploads = attachments + .filter( + (att) => + att.localMetadata?.uploadState !== 'failed' || + (findAndEnqueueURLsToEnrich && !att.og_scrape_url), // filter out all the attachments scraped before the message was edited + ) + .map((localAttachment) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { localMetadata: _, ...attachment } = localAttachment; + return attachment as Attachment; + }); + + const sendOptions = { ...options }; + let attachmentsFromLinkPreviews: Attachment[] = []; + if (findAndEnqueueURLsToEnrich) { + // prevent showing link preview in MessageInput after the message has been sent + cancelURLEnrichment(); + const someLinkPreviewsLoading = Array.from(linkPreviews.values()).some( + (linkPreview) => + [LinkPreviewState.QUEUED, LinkPreviewState.LOADING].includes(linkPreview.state), + ); + const someLinkPreviewsDismissed = Array.from(linkPreviews.values()).some( + (linkPreview) => linkPreview.state === LinkPreviewState.DISMISSED, + ); + + attachmentsFromLinkPreviews = someLinkPreviewsLoading + ? [] + : Array.from(linkPreviews.values()) + .filter( + (linkPreview) => + linkPreview.state === LinkPreviewState.LOADED && + !attachmentsFromUploads.find( + (attFromUpload) => + attFromUpload.og_scrape_url === linkPreview.og_scrape_url, + ), + ) + + .map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ({ state: linkPreviewState, ...ogAttachment }) => + ogAttachment as Attachment, + ); + + // scraped attachments are added only if all enrich queries has completed. Otherwise, the scraping has to be done server-side. + sendOptions.skip_enrich_url = + (!someLinkPreviewsLoading && attachmentsFromLinkPreviews.length > 0) || + someLinkPreviewsDismissed; + } + + const newAttachments = [...attachmentsFromUploads, ...attachmentsFromLinkPreviews]; + + // Instead of checking if a user is still mentioned every time the text changes, + // just filter out non-mentioned users before submit, which is cheaper + // and allows users to easily undo any accidental deletion + const actualMentionedUsers = Array.from( + new Set( + mentioned_users.filter( + ({ id, name }) => text.includes(`@${id}`) || text.includes(`@${name}`), + ), + ), + ); + + return { + message: { + attachments: newAttachments, + mentioned_users: actualMentionedUsers, + text, + }, + sendOptions, + }; +}; + +export const useSubmitHandler = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger, +>( + props: MessageInputProps, + state: MessageInputState, + dispatch: React.Dispatch>, numberOfUploads: number, enrichURLsController: EnrichURLsController, ) => { @@ -55,99 +183,28 @@ export const useSubmitHandler = ( options?: SendMessageOptions, ) => { event?.preventDefault(); - const trimmedMessage = text.trim(); - const isEmptyMessage = - trimmedMessage === '' || - trimmedMessage === '>' || - trimmedMessage === '``````' || - trimmedMessage === '``' || - trimmedMessage === '**' || - trimmedMessage === '____' || - trimmedMessage === '__' || - trimmedMessage === '****'; - - if ( - isEmptyMessage && - numberOfUploads === 0 && - attachments.length === 0 && - !customMessageData?.poll_id - ) - return; - const someAttachmentsUploading = attachments.some( - (att) => att.localMetadata?.uploadState === 'uploading', - ); - - if (someAttachmentsUploading) { - return addNotification(t('Wait until all attachments have uploaded'), 'error'); - } + const { + message: updatedMessage, + notification, + sendOptions, + } = prepareMessage({ + attachments, + cancelURLEnrichment, + customMessageData, + findAndEnqueueURLsToEnrich, + linkPreviews, + mentioned_users, + numberOfUploads, + options, + text, + }); - const attachmentsFromUploads = attachments - .filter( - (att) => - att.localMetadata?.uploadState !== 'failed' || - (findAndEnqueueURLsToEnrich && !att.og_scrape_url), // filter out all the attachments scraped before the message was edited - ) - .map((localAttachment) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { localMetadata: _, ...attachment } = localAttachment; - return attachment as Attachment; - }); - - const sendOptions = { ...options }; - let attachmentsFromLinkPreviews: Attachment[] = []; - if (findAndEnqueueURLsToEnrich) { - // prevent showing link preview in MessageInput after the message has been sent - cancelURLEnrichment(); - const someLinkPreviewsLoading = Array.from(linkPreviews.values()).some( - (linkPreview) => - [LinkPreviewState.QUEUED, LinkPreviewState.LOADING].includes(linkPreview.state), - ); - const someLinkPreviewsDismissed = Array.from(linkPreviews.values()).some( - (linkPreview) => linkPreview.state === LinkPreviewState.DISMISSED, - ); - - attachmentsFromLinkPreviews = someLinkPreviewsLoading - ? [] - : Array.from(linkPreviews.values()) - .filter( - (linkPreview) => - linkPreview.state === LinkPreviewState.LOADED && - !attachmentsFromUploads.find( - (attFromUpload) => - attFromUpload.og_scrape_url === linkPreview.og_scrape_url, - ), - ) - - .map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - ({ state: linkPreviewState, ...ogAttachment }) => - ogAttachment as Attachment, - ); - - // scraped attachments are added only if all enrich queries has completed. Otherwise, the scraping has to be done server-side. - sendOptions.skip_enrich_url = - (!someLinkPreviewsLoading && attachmentsFromLinkPreviews.length > 0) || - someLinkPreviewsDismissed; + if (notification?.type === 'error') { + addNotification(t(notification.text), 'error'); + return; } - const newAttachments = [...attachmentsFromUploads, ...attachmentsFromLinkPreviews]; - - // Instead of checking if a user is still mentioned every time the text changes, - // just filter out non-mentioned users before submit, which is cheaper - // and allows users to easily undo any accidental deletion - const actualMentionedUsers = Array.from( - new Set( - mentioned_users.filter( - ({ id, name }) => text.includes(`@${id}`) || text.includes(`@${name}`), - ), - ), - ); - - const updatedMessage = { - attachments: newAttachments, - mentioned_users: actualMentionedUsers, - text, - }; + if (!updatedMessage) return; if (message && message.type !== 'error') { delete message.i18n; @@ -199,7 +256,7 @@ export const useSubmitHandler = ( type: 'setText', }); - actualMentionedUsers?.forEach((user) => { + updatedMessage.mentioned_users?.forEach((user) => { dispatch({ type: 'addMentionedUser', user }); }); diff --git a/src/context/ChannelStateContext.tsx b/src/context/ChannelStateContext.tsx index 5c4696a8bd..95aa0ff539 100644 --- a/src/context/ChannelStateContext.tsx +++ b/src/context/ChannelStateContext.tsx @@ -5,6 +5,7 @@ import type { Channel, ChannelConfigWithInfo, ErrorFromResponse, + DraftResponse, MessageResponse, Mute, ChannelState as StreamChannelState, @@ -44,12 +45,13 @@ export type ChannelState = { loading?: boolean; loadingMore?: boolean; loadingMoreNewer?: boolean; - members?: StreamChannelState['members']; - messages?: StreamMessage[]; - pinnedMessages?: StreamMessage[]; - quotedMessage?: StreamMessage; - read?: StreamChannelState['read']; - thread?: StreamMessage | null; + members?: StreamChannelState['members']; + messageDraft?: DraftResponse; + messages?: StreamMessage[]; + pinnedMessages?: StreamMessage[]; + quotedMessage?: StreamMessage; + read?: StreamChannelState['read']; + thread?: StreamMessage | null; threadHasMore?: boolean; threadLoadingMore?: boolean; threadMessages?: StreamMessage[]; @@ -76,7 +78,8 @@ export type ChannelStateContextValue = Omit & { findURLFn?: URLEnrichmentConfig['findURLFn']; giphyVersion?: GiphyVersions; maxNumberOfFiles?: number; - mutes?: Array; + messageDraftsEnabled?: boolean; + mutes?: Array>; onLinkPreviewDismissed?: URLEnrichmentConfig['onLinkPreviewDismissed']; watcher_count?: number; }; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 3c65faa832..608dd8fc9e 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -117,7 +117,9 @@ export type ComponentContextValue = { /** Custom UI component to display the contents of a bounced message modal. Usually it allows to retry, edit, or delete the message. Defaults to and accepts the same props as: [MessageBouncePrompt](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageBounce/MessageBouncePrompt.tsx) */ MessageBouncePrompt?: React.ComponentType; /** Custom UI component for a deleted message, defaults to and accepts same props as: [MessageDeleted](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageDeleted.tsx) */ - MessageDeleted?: React.ComponentType; + MessageDeleted?: React.ComponentType>; + /** Custom component that handles the message draft management for main or thread message composer */ + MessageDraftSynchronizer?: React.ComponentType; MessageListMainPanel?: React.ComponentType; /** Custom UI component that displays message and connection status notifications in the `MessageList`, defaults to and accepts same props as [DefaultMessageListNotifications](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageListNotifications.tsx) */ MessageListNotifications?: React.ComponentType; diff --git a/src/context/MessageInputContext.tsx b/src/context/MessageInputContext.tsx index e7bbab0b3d..0980720166 100644 --- a/src/context/MessageInputContext.tsx +++ b/src/context/MessageInputContext.tsx @@ -31,7 +31,7 @@ export const MessageInputContextProvider = ; }>) => ( - + {children} ); @@ -49,5 +49,5 @@ export const useMessageInputContext = ( return {} as MessageInputContextValue; } - return contextValue as MessageInputContextValue; + return contextValue as unknown as MessageInputContextValue; }; diff --git a/src/mock-builders/api/getOrCreateChannel.js b/src/mock-builders/api/getOrCreateChannel.js index 7293886f40..06e8fff087 100644 --- a/src/mock-builders/api/getOrCreateChannel.js +++ b/src/mock-builders/api/getOrCreateChannel.js @@ -24,6 +24,9 @@ export const getOrCreateChannelApi = ( pinnedMessages: channel.pinnedMessages, read: channel.read, }; + if (channel.draft) { + result.draft = channel.draft; + } return mockedApiResponse(result, 'post'); }; diff --git a/src/mock-builders/event/draftDeleted.ts b/src/mock-builders/event/draftDeleted.ts new file mode 100644 index 0000000000..e5b3df34fa --- /dev/null +++ b/src/mock-builders/event/draftDeleted.ts @@ -0,0 +1,16 @@ +import type { DraftResponse, StreamChat } from 'stream-chat'; + +export const dispatchDraftDeleted = ({ + client, + draft, +}: { + client: StreamChat; + draft: DraftResponse; +}) => { + client.dispatchEvent({ + type: 'draft.deleted', + created_at: new Date().toISOString(), + cid: draft.channel_cid, + draft, + }); +}; diff --git a/src/mock-builders/event/draftUpdated.ts b/src/mock-builders/event/draftUpdated.ts new file mode 100644 index 0000000000..883ef4b59e --- /dev/null +++ b/src/mock-builders/event/draftUpdated.ts @@ -0,0 +1,16 @@ +import type { DraftResponse, StreamChat } from 'stream-chat'; + +export const dispatchDraftUpdated = ({ + client, + draft, +}: { + client: StreamChat; + draft: DraftResponse; +}) => { + client.dispatchEvent({ + type: 'draft.updated', + created_at: new Date().toISOString(), + cid: draft.channel_cid, + draft, + }); +}; diff --git a/src/mock-builders/generator/messageDraft.ts b/src/mock-builders/generator/messageDraft.ts new file mode 100644 index 0000000000..7ce6f65946 --- /dev/null +++ b/src/mock-builders/generator/messageDraft.ts @@ -0,0 +1,17 @@ +import { generateMessage } from './message'; +import type { DraftResponse } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../types'; + +export const generateMessageDraft = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>({ + channel_cid, + ...customMsgDraft +}: Partial>) => { + return { + channel_cid, + created_at: new Date().toISOString(), + message: generateMessage(), + ...customMsgDraft, + } as DraftResponse; +}; From 6bb0e4c7052c38f0a3f5b0d1e2f7387bfc5f8023 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Feb 2025 18:15:51 +0100 Subject: [PATCH 02/99] feat: use Thread class instance to manage Thread component state when drafts enabled --- src/components/Channel/Channel.tsx | 50 ++++++++++++--- .../Channel/__tests__/Channel.test.js | 61 ++++++++++++++++--- src/components/Channel/channelState.ts | 8 ++- .../hooks/useCreateChannelStateContext.ts | 3 + src/components/Thread/Thread.tsx | 28 ++++++--- .../Thread/__tests__/Thread.test.js | 6 ++ src/components/Threads/ThreadContext.tsx | 7 ++- src/context/ChannelStateContext.tsx | 2 + 8 files changed, 139 insertions(+), 26 deletions(-) diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 21172edf7b..e06fbd8c9c 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -1145,14 +1145,42 @@ const ChannelInner = ( const openThread = (message: StreamMessage, event?: React.BaseSyntheticEvent) => { event?.preventDefault(); - setQuotedMessage((current) => { - if (current?.parent_id !== message?.parent_id) { - return undefined; - } else { - return current; + if (messageDraftsEnabled) { + let threadInstance = client.threads.threadsById[message.id]; + if (threadInstance) { + dispatch({ channel, message, threadInstance, type: 'openThread' }); + return; } - }); - dispatch({ channel, message, type: 'openThread' }); + + dispatch({ + channel, + message, + threadInstance: {} as Thread, + type: 'openThread', + }); + + client + .getThread(message.id, { reply_limit: DEFAULT_THREAD_PAGE_SIZE }) + .then((t: Thread) => { + t.registerSubscriptions(); + dispatch({ + channel, + message, + threadInstance: t, + type: 'openThread', + }); + client.threads.addThread(t); + }); + } else { + setQuotedMessage((current) => { + if (current?.parent_id !== message?.parent_id) { + return undefined; + } else { + return current; + } + }); + dispatch({ channel, message, type: 'openThread' }); + } }; const closeThread = (event?: React.BaseSyntheticEvent) => { @@ -1181,7 +1209,13 @@ const ChannelInner = ( const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => { // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length - if (state.threadLoadingMore || !state.thread || !state.threadHasMore) return; + if ( + state.threadInstance || + state.threadLoadingMore || + !state.thread || + !state.threadHasMore + ) + return; dispatch({ type: 'startLoadingThread' }); const parentId = state.thread.id; diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index b4dc0c853b..16b748b8f8 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -567,18 +567,65 @@ describe('Channel', () => { const { channel, chatClient } = await initClient(); const threadMessage = messages[0]; const hasThread = jest.fn(); + const hasThreadInstance = jest.fn(); + const mockThreadInstance = { + threadInstanceMock: true, + registerSubscriptions: jest.fn(), + }; + const getThreadSpy = jest + .spyOn(chatClient, 'getThread') + .mockResolvedValueOnce(mockThreadInstance); // this renders Channel, calls openThread from a child context consumer with a message, // and then calls hasThread with the thread id if it was set. - await renderComponent({ channel, chatClient }, ({ openThread, thread }) => { - if (!thread) { - openThread(threadMessage, { preventDefault: () => null }); - } else { - hasThread(thread.id); - } + await renderComponent( + { channel, chatClient }, + ({ openThread, thread, threadInstance }) => { + if (!thread) { + openThread(threadMessage, { preventDefault: () => null }); + } else { + hasThread(thread.id); + hasThreadInstance(threadInstance); + } + }, + ); + + await waitFor(() => { + expect(hasThread).toHaveBeenCalledWith(threadMessage.id); + expect(getThreadSpy).not.toHaveBeenCalled(); + expect(hasThreadInstance).toHaveBeenCalledWith(undefined); }); + getThreadSpy.mockRestore(); + }); + + it('uses Thread instance when messageDraftsEnabled is true', async () => { + const { channel, chatClient } = await initClient(); + const threadMessage = messages[0]; + const hasThreadInstance = jest.fn(); + const mockThreadInstance = { + threadInstanceMock: true, + registerSubscriptions: jest.fn(), + }; + const spy = jest + .spyOn(chatClient, 'getThread') + .mockResolvedValueOnce(mockThreadInstance); - await waitFor(() => expect(hasThread).toHaveBeenCalledWith(threadMessage.id)); + await renderComponent( + { channel, chatClient, messageDraftsEnabled: true }, + ({ openThread, thread, threadInstance }) => { + if (!thread) { + openThread(threadMessage, { preventDefault: () => null }); + } else { + hasThreadInstance(threadInstance); + } + }, + ); + + await waitFor(() => { + expect(hasThreadInstance).toHaveBeenCalledWith(mockThreadInstance); + expect(mockThreadInstance.registerSubscriptions).toHaveBeenCalledWith(); + }); + spy.mockRestore(); }); it('should be able to load more messages in a thread until reaching the end', async () => { diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts index ff8e196384..45ed417d8a 100644 --- a/src/components/Channel/channelState.ts +++ b/src/components/Channel/channelState.ts @@ -1,7 +1,8 @@ -import type { +import { Channel, MessageResponse, ChannelState as StreamChannelState, + Thread, } from 'stream-chat'; import type { ChannelState, StreamMessage } from '../../context/ChannelStateContext'; @@ -51,6 +52,7 @@ export type ChannelStateReducerAction = channel: Channel; message: StreamMessage; type: 'openThread'; + threadInstance?: Thread; } | { error: Error; @@ -91,6 +93,7 @@ export const makeChannelReducer = return { ...state, thread: null, + threadInstance: undefined, threadLoadingMore: false, threadMessages: [], }; @@ -198,11 +201,12 @@ export const makeChannelReducer = } case 'openThread': { - const { channel, message } = action; + const { channel, message, threadInstance } = action; return { ...state, thread: message, threadHasMore: true, + threadInstance, threadMessages: message.id ? { ...channel.state.threads }[message.id] || [] : [], diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts index 1f7bf0f048..0ed8e28a32 100644 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ b/src/components/Channel/hooks/useCreateChannelStateContext.ts @@ -45,6 +45,7 @@ export const useCreateChannelStateContext = ( suppressAutoscroll, thread, threadHasMore, + threadInstance, threadLoadingMore, threadMessages = [], videoAttachmentSizeHandler, @@ -141,6 +142,7 @@ export const useCreateChannelStateContext = ( suppressAutoscroll, thread, threadHasMore, + threadInstance, threadLoadingMore, threadMessages, videoAttachmentSizeHandler, @@ -178,6 +180,7 @@ export const useCreateChannelStateContext = ( suppressAutoscroll, thread, threadHasMore, + threadInstance, threadLoadingMore, threadMessagesLength, watcherCount, diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 569d0ffa2f..e56a914ffb 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -53,9 +53,15 @@ export type ThreadProps = { export const Thread = ( props: ThreadProps, ) => { - const { channel, channelConfig, thread } = useChannelStateContext('Thread'); - const threadInstance = useThreadContext(); + const { + channel, + channelConfig, + thread, + threadInstance: threadInstanceChannelCtx, + } = useChannelStateContext('Thread'); + const threadInstanceThreadCtx = useThreadContext(); + const threadInstance = threadInstanceThreadCtx ?? threadInstanceChannelCtx; if ((!thread && !threadInstance) || channelConfig?.replies === false) return null; // the wrapper ensures a key variable is set and the component recreates on thread switch @@ -68,7 +74,11 @@ export const Thread = ( ); }; -const selector = (nextValue: ThreadState) => ({ +const selector = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + nextValue: ThreadState, +) => ({ isLoadingNext: nextValue.pagination.isLoadingNext, isLoadingPrev: nextValue.pagination.isLoadingPrev, parentMessage: nextValue.parentMessage, @@ -91,13 +101,12 @@ const ThreadInner = ( virtualized, } = props; - const threadInstance = useThreadContext(); - const { isLoadingNext, isLoadingPrev, parentMessage, replies } = - useStateStore(threadInstance?.state, selector) ?? {}; + const threadInstanceThreadCtx = useThreadContext(); const { thread, threadHasMore, + threadInstance: threadInstanceChannelCtx, threadLoadingMore, threadMessages = [], threadSuppressAutoscroll, @@ -112,6 +121,11 @@ const ThreadInner = ( VirtualMessage, } = useComponentContext('Thread'); + const threadInstance = threadInstanceThreadCtx ?? threadInstanceChannelCtx; + + const { isLoadingNext, isLoadingPrev, parentMessage, replies } = + useStateStore(threadInstance?.state, selector) ?? {}; + const ThreadInput = PropInput ?? additionalMessageInputProps?.Input ?? ContextInput ?? MessageInputFlat; @@ -122,7 +136,7 @@ const ThreadInner = ( const ThreadMessageList = virtualized ? VirtualizedMessageList : MessageList; useEffect(() => { - if (thread?.id && thread?.reply_count) { + if (!threadInstance && thread?.id && thread?.reply_count) { // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options loadMoreThread(); } diff --git a/src/components/Thread/__tests__/Thread.test.js b/src/components/Thread/__tests__/Thread.test.js index 77811e5d7e..3932fdd69b 100644 --- a/src/components/Thread/__tests__/Thread.test.js +++ b/src/components/Thread/__tests__/Thread.test.js @@ -330,6 +330,12 @@ describe('Thread', () => { expect(channelActionContextMock.loadMoreThread).toHaveBeenCalledTimes(1); }); + it('should not call the loadMoreThread callback on mount if the thread start has a non-zero reply count but threadInstance is provided', () => { + renderComponent({ chatClient, channelStateOverrides: { threadInstance: {} } }); + + expect(channelActionContextMock.loadMoreThread).not.toHaveBeenCalled(); + }); + it('should render null if replies is disabled', async () => { const client = await getTestClientWithUser(); const ch = generateChannel({ getConfig: () => ({ replies: false }) }); diff --git a/src/components/Threads/ThreadContext.tsx b/src/components/Threads/ThreadContext.tsx index 39925eae96..28d5a83a52 100644 --- a/src/components/Threads/ThreadContext.tsx +++ b/src/components/Threads/ThreadContext.tsx @@ -4,15 +4,18 @@ import { Channel } from '../../components'; import type { PropsWithChildren } from 'react'; import type { Thread } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../types'; export type ThreadContextValue = Thread | undefined; export const ThreadContext = createContext(undefined); -export const useThreadContext = () => { +export const useThreadContext = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>() => { const thread = useContext(ThreadContext); - return thread ?? undefined; + return (thread as unknown as Thread) ?? undefined; }; export const ThreadProvider = ({ diff --git a/src/context/ChannelStateContext.tsx b/src/context/ChannelStateContext.tsx index 95aa0ff539..2b53fd434c 100644 --- a/src/context/ChannelStateContext.tsx +++ b/src/context/ChannelStateContext.tsx @@ -9,6 +9,7 @@ import type { MessageResponse, Mute, ChannelState as StreamChannelState, + Thread, } from 'stream-chat'; import type { @@ -53,6 +54,7 @@ export type ChannelState = { read?: StreamChannelState['read']; thread?: StreamMessage | null; threadHasMore?: boolean; + threadInstance?: Thread; threadLoadingMore?: boolean; threadMessages?: StreamMessage[]; threadSuppressAutoscroll?: boolean; From 7780290d68431d94532ee2bd12582caa41641701 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 13 Mar 2025 13:13:06 +0100 Subject: [PATCH 03/99] feat: add MessageComposer --- src/components/AutoCompleteTextarea/Item.jsx | 37 - src/components/AutoCompleteTextarea/Item.tsx | 48 + .../{List.jsx => List.tsx} | 83 +- .../AutoCompleteTextarea/TextareaX.tsx | 870 ++++++++++++++++++ src/components/AutoCompleteTextarea/utils.js | 4 +- src/components/Channel/Channel.tsx | 155 ++-- .../hooks/useCreateChannelStateContext.ts | 3 - .../ChatAutoComplete/ChatAutoComplete.tsx | 73 +- .../MessageActions/MessageActionsBox.tsx | 12 +- .../AttachmentPreviewList.tsx | 53 +- .../AttachmentPreviewList/types.ts | 2 +- .../MessageInput/AttachmentSelector.tsx | 4 + .../MessageInput/DefaultTriggerProvider.tsx | 14 +- .../MessageInput/DropzoneProvider.tsx | 41 +- .../MessageInput/LinkPreviewList.tsx | 22 +- .../MessageInput/MessageDraftSynchronizer.tsx | 28 +- src/components/MessageInput/MessageInput.tsx | 13 +- .../MessageInput/MessageInputFlat.tsx | 103 +-- .../MessageInput/QuotedMessagePreview.tsx | 64 +- .../messageComposer/useIsUploadEnabled.ts | 20 + .../messageComposer/useMessageComposer.ts | 67 ++ .../hooks/useCreateMessageInputContext.ts | 6 +- .../useMessageInputState.ts | 13 +- .../MessageInput/hooks/useMessageInputText.ts | 84 +- .../MessageInput/hooks/useUserTrigger.ts | 2 +- .../ReactFileUtilities/UploadButton.tsx | 27 +- .../TextAreaComposer/SuggestionList.tsx | 241 +++++ .../TextAreaComposer/SuggestionListItem.tsx | 65 ++ .../TextAreaComposer/TextAreaComposer.tsx | 279 ++++++ src/components/TextAreaComposer/index.ts | 3 + src/components/Thread/Thread.tsx | 58 +- src/components/Threads/ThreadContext.tsx | 14 +- src/context/ChannelActionContext.tsx | 3 +- src/context/ChannelStateContext.tsx | 3 +- src/context/ComponentContext.tsx | 8 +- src/context/MessageInputContext.tsx | 31 +- src/experimental/MessageActions/defaults.tsx | 7 +- .../middleware/textComposerEmojiMiddleware.ts | 169 ++++ 38 files changed, 2278 insertions(+), 451 deletions(-) delete mode 100644 src/components/AutoCompleteTextarea/Item.jsx create mode 100644 src/components/AutoCompleteTextarea/Item.tsx rename src/components/AutoCompleteTextarea/{List.jsx => List.tsx} (62%) create mode 100644 src/components/AutoCompleteTextarea/TextareaX.tsx create mode 100644 src/components/MessageInput/hooks/messageComposer/useIsUploadEnabled.ts create mode 100644 src/components/MessageInput/hooks/messageComposer/useMessageComposer.ts create mode 100644 src/components/TextAreaComposer/SuggestionList.tsx create mode 100644 src/components/TextAreaComposer/SuggestionListItem.tsx create mode 100644 src/components/TextAreaComposer/TextAreaComposer.tsx create mode 100644 src/components/TextAreaComposer/index.ts create mode 100644 src/plugins/Emojis/middleware/textComposerEmojiMiddleware.ts diff --git a/src/components/AutoCompleteTextarea/Item.jsx b/src/components/AutoCompleteTextarea/Item.jsx deleted file mode 100644 index 4945d682d5..0000000000 --- a/src/components/AutoCompleteTextarea/Item.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback } from 'react'; -import clsx from 'clsx'; - -export const Item = React.forwardRef(function Item(props, innerRef) { - const { - className, - component: Component, - item, - onClickHandler, - onSelectHandler, - selected, - style, - } = props; - - const handleSelect = useCallback(() => onSelectHandler(item), [item, onSelectHandler]); - const handleClick = useCallback( - (event) => onClickHandler(event, item), - [item, onClickHandler], - ); - - return ( -
  • - - - -
  • - ); -}); diff --git a/src/components/AutoCompleteTextarea/Item.tsx b/src/components/AutoCompleteTextarea/Item.tsx new file mode 100644 index 0000000000..24fb60fd6c --- /dev/null +++ b/src/components/AutoCompleteTextarea/Item.tsx @@ -0,0 +1,48 @@ +import React, { MouseEventHandler, Ref, useCallback } from 'react'; +import clsx from 'clsx'; +import { SuggestionItemProps } from '../ChatAutoComplete'; +import type { DefaultStreamChatGenerics } from '../../types'; +import type { UnknownType } from '../../types/types'; + +export const Item = React.forwardRef( + function Item< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + T extends UnknownType = UnknownType, + >(props: SuggestionItemProps, innerRef: Ref) { + const { + className, + component: Component, + item, + onClickHandler, + onSelectHandler, + selected, + style, + } = props; + + const handleSelect = useCallback( + () => onSelectHandler(item), + [item, onSelectHandler], + ); + const handleClick: MouseEventHandler = useCallback( + (event) => onClickHandler(event, item), + [item, onClickHandler], + ); + + return ( +
  • + + + +
  • + ); + }, +); diff --git a/src/components/AutoCompleteTextarea/List.jsx b/src/components/AutoCompleteTextarea/List.tsx similarity index 62% rename from src/components/AutoCompleteTextarea/List.jsx rename to src/components/AutoCompleteTextarea/List.tsx index 73c275ae1d..9b49f00863 100644 --- a/src/components/AutoCompleteTextarea/List.jsx +++ b/src/components/AutoCompleteTextarea/List.tsx @@ -1,12 +1,31 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + CSSProperties, + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import clsx from 'clsx'; import { useComponentContext } from '../../context/ComponentContext'; import { Item } from './Item'; import { escapeRegExp } from '../Message/renderText'; - -export const List = ({ +import type { DefaultStreamChatGenerics } from '../../types'; +import type { CustomTrigger, UnknownType } from '../../types/types'; +import { + SuggestionEmoji, + SuggestionItem, + SuggestionListProps, + SuggestionUser, +} from '../ChatAutoComplete'; + +export const List = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger, + EmojiData extends UnknownType = UnknownType, +>({ className, component, currentTrigger, @@ -21,39 +40,46 @@ export const List = ({ SuggestionItem: PropSuggestionItem, value: propValue, values, -}) => { - const { AutocompleteSuggestionItem } = useComponentContext('SuggestionList'); +}: SuggestionListProps) => { + const { AutocompleteSuggestionItem } = + useComponentContext('SuggestionList'); const SuggestionItem = PropSuggestionItem || AutocompleteSuggestionItem || Item; - const [selectedItemIndex, setSelectedItemIndex] = useState(undefined); + const [selectedItemIndex, setSelectedItemIndex] = useState( + undefined, + ); - const itemsRef = []; + const itemsRef: HTMLElement[] = []; - const isSelected = (item) => + const isSelected = (item: SuggestionItem) => selectedItemIndex === values.findIndex((value) => getId(value) === getId(item)); - const getId = (item) => { + const getId = (item: SuggestionItem) => { const textToReplace = getTextToReplace(item); if (textToReplace.key) { return textToReplace.key; } - if (typeof item === 'string' || !item.key) { + if (typeof item === 'string' || !(item as SuggestionEmoji).key) { return textToReplace.text; } - return item.key; + return (item as SuggestionEmoji).key; }; const findItemIndex = useCallback( - (item) => + (item: SuggestionItem) => values.findIndex((value) => - value.id ? value.id === item.id : value.name === item.name, + value.id + ? value.id === (item as SuggestionUser).id + : value.name === item.name, ), [values], ); - const modifyText = (value) => { + const modifyText = ( + value: SuggestionListProps['values'][number], + ) => { if (!value) return; onSelect(getTextToReplace(value)); @@ -61,19 +87,21 @@ export const List = ({ }; const handleClick = useCallback( - (e, item) => { + ( + e: React.MouseEvent, + item: SuggestionItem, + ) => { e?.preventDefault(); const index = findItemIndex(item); modifyText(values[index]); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [modifyText, findItemIndex], + [modifyText, findItemIndex, values], ); const selectItem = useCallback( - (item) => { + (item: SuggestionItem) => { const index = findItemIndex(item); setSelectedItemIndex(index); }, @@ -81,7 +109,7 @@ export const List = ({ ); const handleKeyDown = useCallback( - (event) => { + (event: KeyboardEvent) => { if (event.key === 'ArrowUp') { setSelectedItemIndex((prevSelected) => { if (prevSelected === undefined) return 0; @@ -104,7 +132,7 @@ export const List = ({ (event.key === 'Enter' || event.key === 'Tab') && selectedItemIndex !== undefined ) { - handleClick(event, values[selectedItemIndex]); + modifyText(values[selectedItemIndex]); } return null; @@ -120,13 +148,13 @@ export const List = ({ useEffect(() => { if (values?.length) selectItem(values[0]); - }, [values]); // eslint-disable-line + }, [selectItem, values]); const restructureItem = useCallback( - (item) => { - const matched = item.name || item.id; + (item: SuggestionItem) => { + const matched = item.name || (item as SuggestionUser).id; - const textBeforeCursor = propValue.slice(0, selectionEnd); + const textBeforeCursor = (propValue || '').slice(0, selectionEnd); const triggerIndex = textBeforeCursor.lastIndexOf(currentTrigger); const editedPropValue = escapeRegExp(textBeforeCursor.slice(triggerIndex + 1)); @@ -149,16 +177,17 @@ export const List = ({ {restructuredValues.map((item, i) => ( { + ref={(ref: HTMLAnchorElement) => { itemsRef[i] = ref; }} selected={isSelected(item)} - style={itemStyle} + style={itemStyle as CSSProperties} value={propValue} /> ))} diff --git a/src/components/AutoCompleteTextarea/TextareaX.tsx b/src/components/AutoCompleteTextarea/TextareaX.tsx new file mode 100644 index 0000000000..53e5943092 --- /dev/null +++ b/src/components/AutoCompleteTextarea/TextareaX.tsx @@ -0,0 +1,870 @@ +import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; +import Textarea from 'react-textarea-autosize'; +import getCaretCoordinates from 'textarea-caret'; +import clsx from 'clsx'; +import { List as DefaultSuggestionList } from './List'; +import { + DEFAULT_CARET_POSITION, + defaultScrollToItem, + errorMessage, + triggerPropsCheck, +} from './utils'; +import { isSafari } from '../../utils/browsers'; +import { CommandItem } from '../CommandItem'; +import { UserItem } from '../UserItem'; +import { SuggestionItemProps, SuggestionListProps } from '../ChatAutoComplete'; +import type { DefaultStreamChatGenerics } from '../../types'; +import type { CustomTrigger, UnknownType } from '../../types/types'; + +type TextareaState = { + actualToken: string; + component: React.ComponentType | null; + currentTrigger: string | null; + data: any[] | null; + dataLoading: boolean; + isComposing: boolean; + left: number | null; + selectionEnd: number; + selectionStart: number; + top: number | null; + value: string; + propsValue: string; +}; + +type TextareaProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger, + EmojiData extends UnknownType = UnknownType, +> = { + additionalTextareaProps?: React.TextareaHTMLAttributes; + className?: string; + closeCommandsList?: () => void; + closeMentionsList?: () => void; + closeOnClickOutside?: boolean; + containerClassName?: string; + containerStyle?: CSSProperties; + disableMentions?: boolean; + dropdownClassName?: string; + dropdownStyle?: CSSProperties; + grow?: boolean; + handleSubmit?: (event: React.KeyboardEvent) => void; + innerRef?: (ref: HTMLTextAreaElement | null) => void; + itemClassName?: string; + itemStyle?: CSSProperties; + listClassName?: string; + listStyle?: CSSProperties; + loadingComponent?: React.ComponentType; + maxRows?: number; + minChar?: number; + movePopupAsYouType?: boolean; + onBlur?: (event: React.FocusEvent) => void; + onCaretPositionChange?: (position: number) => void; + onChange?: (event: React.ChangeEvent) => void; + onFocus?: (event: React.FocusEvent) => void; + onSelect?: (event: React.SyntheticEvent) => void; + placeholder?: string; + replaceWord?: + | ((word: string) => string | undefined) + | ((word: string) => Promise); + scrollToItem?: boolean | ((container: HTMLDivElement, item: HTMLElement) => void); + shouldSubmit?: (event: React.KeyboardEvent) => boolean; + showCommandsList?: boolean; + showMentionsList?: boolean; + style?: Omit; + SuggestionItem?: React.ComponentType< + React.ComponentType> + >; + SuggestionList?: React.ComponentType< + SuggestionListProps + >; + trigger: Record< + string, + { + callback?: ( + item: any, + trigger?: string, + ) => { text: string; caretPosition: string | number }; + dataProvider: ( + token: string, + text: string, + callback: (data: any[], token: string) => void, + ) => void; + component: React.ComponentType; + output?: ( + item: any, + trigger?: string, + ) => { text: string; caretPosition: string | number }; + } + >; + value?: string; +}; + +const _isCommand = (value: string) => value.startsWith('/'); + +export const TextareaX = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + V extends CustomTrigger = CustomTrigger, + EmojiData extends UnknownType = UnknownType, +>( + props: TextareaProps, +) => { + const { + closeCommandsList, + closeMentionsList, + closeOnClickOutside = true, + disableMentions, + grow = true, + handleSubmit, + maxRows = 10, + minChar = 1, + movePopupAsYouType = false, + onBlur, + onCaretPositionChange, + onChange, + onFocus, + onSelect, + replaceWord, + scrollToItem, + shouldSubmit, + showCommandsList, + showMentionsList, + SuggestionItem, + // @ts-ignore + SuggestionList = DefaultSuggestionList, + trigger, + value: initialValue = '', + innerRef, + ...restProps + } = props; + + if (!props.loadingComponent) { + throw new Error('RTA: loadingComponent is not defined'); + } + + if (!trigger) { + throw new Error('RTA: trigger is not defined'); + } + + const [state, setState] = useState({ + actualToken: '', + component: null, + currentTrigger: null, + data: null, + dataLoading: false, + isComposing: false, + left: null, + selectionEnd: 0, + selectionStart: 0, + top: null, + value: initialValue, + propsValue: initialValue, + }); + + const textareaRef = useRef(null); + const dropdownRef = useRef(null); + + const getCaretPosition = useCallback(() => { + if (!textareaRef.current) return 0; + return textareaRef.current.selectionEnd; + }, []); + + const setCaretPosition = useCallback((position = 0) => { + if (!textareaRef.current) return; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(position, position); + }, []); + + const _closeAutocomplete = useCallback(() => { + setState((prev) => ({ + ...prev, + currentTrigger: null, + data: null, + dataLoading: false, + left: null, + top: null, + })); + }, []); + + const _getCurrentTriggerSettings = useCallback( + (paramTrigger?: string) => { + const { currentTrigger: stateTrigger } = state; + const currentTrigger = paramTrigger || stateTrigger; + if (!currentTrigger) return null; + + return trigger[currentTrigger]; + }, + [state.currentTrigger, trigger], + ); + + const _getValuesFromProvider = useCallback(() => { + const { actualToken, currentTrigger } = state; + const triggerSettings = _getCurrentTriggerSettings(); + + if (!currentTrigger || !triggerSettings) return; + + const { component, dataProvider } = triggerSettings; + + if (typeof dataProvider !== 'function') { + throw new Error('Trigger provider has to be a function!'); + } + + setState((prev) => ({ ...prev, dataLoading: true })); + + // Modified: send the full text to support / style commands + dataProvider(actualToken, state.value, (data: unknown, token: string) => { + // Make sure that the result is still relevant for current query + if (token !== state.actualToken) return; + + if (!Array.isArray(data)) { + throw new Error('Trigger provider has to provide an array!'); + } + + // throw away if we resolved old trigger + if (currentTrigger !== state.currentTrigger) return; + + // if we haven't resolved any data let's close the autocomplete + if (!data.length) { + _closeAutocomplete(); + return; + } + + setState((prev) => ({ + ...prev, + component, + data, + dataLoading: false, + })); + }); + }, [ + state.actualToken, + state.currentTrigger, + state.value, + _getCurrentTriggerSettings, + _closeAutocomplete, + ]); + + const _changeHandler = useCallback( + (e: React.ChangeEvent) => { + const textarea = e.target; + const { selectionEnd, selectionStart, value } = textarea; + + if (onChange) { + e.persist(); + onChange(e); + } + + if (onCaretPositionChange) onCaretPositionChange(getCaretPosition()); + + setState((prev) => ({ ...prev, value })); + + let currentTrigger; + let lastToken: string | null; + + if (_isCommand(value)) { + currentTrigger = '/'; + lastToken = value; + } else { + const triggerTokens = Object.keys(trigger).join().replace('/', ''); + const triggerNorWhitespace = `[^\\s${triggerTokens}]*`; + const regex = new RegExp( + `(?!^|\\W)?[${triggerTokens}]${triggerNorWhitespace}\\s?${triggerNorWhitespace}$`, + 'g', + ); + const tokenMatch = value.slice(0, selectionEnd).match(regex); + + lastToken = tokenMatch && tokenMatch[tokenMatch.length - 1].trim(); + + currentTrigger = + lastToken && Object.keys(trigger).find((a) => a === lastToken![0]); + } + + if (!lastToken || lastToken.length <= minChar) { + _closeAutocomplete(); + return; + } + + const actualToken = lastToken.slice(1); + + if (!currentTrigger) return; + + if ( + movePopupAsYouType || + (state.top === null && state.left === null) || + lastToken.length === 1 + ) { + const { left: newLeft, top: newTop } = getCaretCoordinates( + // todo: should not be handled in LLC + textarea, + selectionEnd, + ); + + setState((prev) => ({ + ...prev, + left: newLeft, + top: newTop - (textareaRef.current?.scrollTop || 0), + })); + } + + setState((prev) => ({ + ...prev, + actualToken, + currentTrigger, + selectionEnd, + selectionStart, + })); + + try { + _getValuesFromProvider(); + } catch (err) { + errorMessage((err as Error).message); + } + }, + [ + minChar, + movePopupAsYouType, + onChange, + onCaretPositionChange, + trigger, + _isCommand, + _closeAutocomplete, + _getValuesFromProvider, + ], + ); + + const _onClickAndBlurHandler = useCallback( + ( + e: React.FocusEvent | React.MouseEvent, + ) => { + const { closeOnClickOutside, onBlur } = props; + + // If this is a click: e.target is the textarea, and e.relatedTarget is the thing + // that was actually clicked. If we clicked inside the auto-select dropdown, then + // that's not a blur, from the auto-select point of view, so then do nothing. + const el = e.relatedTarget; + // If this is a blur event in Safari, then relatedTarget is never a dropdown item, but a common parent + // of textarea and dropdown container. That means that dropdownRef will not contain its parent and the + // autocomplete will be closed before onclick handler can be invoked selecting an item. + // It seems that Safari has different implementation determining the relatedTarget node than Chrome and Firefox. + // Therefore, if focused away in Safari, the dropdown will be kept rendered until pressing Esc or selecting and item from it. + const focusedAwayInSafari = isSafari() && e.type === 'blur'; + if ( + (dropdownRef.current && el instanceof Node && dropdownRef.current.contains(el)) || + focusedAwayInSafari + ) { + return; + } + + if (closeOnClickOutside) _closeAutocomplete(); + + if (onBlur && e.type === 'blur') { + e.persist(); + onBlur(e as React.FocusEvent); + } + }, + [props, _closeAutocomplete], + ); + + const _onSelect = useCallback( + (newToken: { text: string; caretPosition: string | number }) => { + const { + closeCommandsList, + closeMentionsList, + onChange, + showCommandsList, + showMentionsList, + } = props; + const { currentTrigger: stateTrigger, selectionEnd, value: textareaValue } = state; + + const currentTrigger = showCommandsList + ? '/' + : showMentionsList + ? '@' + : stateTrigger; + + if (!currentTrigger) return; + + const computeCaretPosition = ( + position: string | number, + token: string, + startToken: number, + ) => { + switch (position) { + case 'start': + return startToken; + case 'next': + case 'end': + return startToken + token.length; + default: + if (typeof position === 'number') { + return position; + } + throw new Error( + 'RTA: caretPosition should be "start", "next", "end" or number.', + ); + } + }; + + const textToModify = showCommandsList + ? '/' + : showMentionsList + ? '@' + : textareaValue.slice(0, selectionEnd); + + const startOfTokenPosition = textToModify.lastIndexOf(currentTrigger); + + // we add space after emoji is selected if a caret position is next + const newTokenString = + newToken.caretPosition === 'next' ? `${newToken.text} ` : newToken.text; + + const newCaretPosition = computeCaretPosition( + newToken.caretPosition, + newTokenString, + startOfTokenPosition, + ); + + const modifiedText = + textToModify.substring(0, startOfTokenPosition) + newTokenString; + const valueToReplace = textareaValue.replace(textToModify, modifiedText); + + setState((prev) => ({ + ...prev, + dataLoading: false, + value: valueToReplace, + })); + + const e = new CustomEvent('change', { bubbles: true }); + textareaRef.current?.dispatchEvent(e); + onChange?.(e as any); + setCaretPosition(newCaretPosition); + + _closeAutocomplete(); + if (showCommandsList) closeCommandsList?.(); + if (showMentionsList) closeMentionsList?.(); + }, + [props, state, onChange, setCaretPosition], + ); + + const _onScrollHandler = useCallback(() => _closeAutocomplete(), [_closeAutocomplete]); + + const setDropdownRef = useCallback((element: HTMLDivElement | null) => { + dropdownRef.current = element; + }, []); + + /** + * isComposing prevents double submissions in Korean and other languages. + * starting point for a read: + * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing + * In the long term, the fix should happen by handling keypress, but changing this has unknown implications. + * @param event React.KeyboardEvent + */ + const _defaultShouldSubmit = useCallback( + (event: React.KeyboardEvent) => + event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing, + [], + ); + + const _onEnter = useCallback( + async (event: React.KeyboardEvent) => { + if (!textareaRef.current) return; + + if (!state.currentTrigger || !state.data) { + event.persist(); + await replaceWord?.(state.value); + if (textareaRef.current) { + textareaRef.current.selectionEnd = 0; + } + handleSubmit?.(event); + _closeAutocomplete(); + } + }, + [state.currentTrigger, state.data, state.value, replaceWord, handleSubmit], + ); + + const _onSpace = useCallback(() => { + if (!replaceWord || !textareaRef.current) return; + + // don't change characters if the element doesn't have focus + const hasFocus = textareaRef.current.matches(':focus'); + if (!hasFocus) return; + + _replaceWord(); + }, [replaceWord]); + + const _replaceWord = useCallback(async () => { + const lastWordRegex = /([^\s]+)(\s*)$/; + const match = lastWordRegex.exec(state.value.slice(0, getCaretPosition())); + const lastWord = match && match[1]; + + if (!lastWord) return; + + const spaces = match[2]; + const newWord = await replaceWord?.(lastWord); + if (newWord == null) return; + + const textBeforeWord = state.value.slice(0, getCaretPosition() - match[0].length); + const textAfterCaret = state.value.slice(getCaretPosition(), -1); + const newText = textBeforeWord + newWord + spaces + textAfterCaret; + + setState((prev) => ({ ...prev, value: newText })); + + if (textareaRef.current) { + const e = new CustomEvent('change', { bubbles: true }); + textareaRef.current.dispatchEvent(e); + onChange?.(e as any); + } + }, [state.value, getCaretPosition, replaceWord, onChange]); + + const _handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && dropdownRef.current) { + event.preventDefault(); + } + + if ((shouldSubmit ?? _defaultShouldSubmit)(event)) return _onEnter(event); + if (event.key === ' ') return _onSpace(); + if (event.key === 'Escape') return _closeAutocomplete(); + }, + [shouldSubmit, _defaultShouldSubmit, _onEnter, _onSpace, _closeAutocomplete], + ); + + const _getItemOnSelect = useCallback( + (paramTrigger?: string) => { + const { currentTrigger: stateTrigger } = state; + const triggerSettings = _getCurrentTriggerSettings(paramTrigger); + + const currentTrigger = paramTrigger || stateTrigger; + + if (!currentTrigger || !triggerSettings) return null; + + const { callback } = triggerSettings; + + if (!callback) return null; + + return (item: any) => { + if (typeof callback !== 'function') { + throw new Error( + 'Output functor is not defined! You have to define "output" function.', + ); + } + if (callback) { + return callback(item, currentTrigger); + } + return null; + }; + }, + [state.currentTrigger, _getCurrentTriggerSettings], + ); + + const _getTextToReplace = useCallback( + (paramTrigger?: string) => { + const { actualToken, currentTrigger: stateTrigger } = state; + const triggerSettings = _getCurrentTriggerSettings(paramTrigger); + const currentTrigger = paramTrigger || stateTrigger; + + if (!currentTrigger || !triggerSettings) return null; + + const { output } = triggerSettings; + + return (item: any) => { + if (typeof item === 'object' && (!output || typeof output !== 'function')) { + throw new Error( + 'Output functor is not defined! If you are using items as object you have to define "output" function. https://github.com/webscopeio/react-textarea-autocomplete#trigger-type', + ); + } + + if (output) { + const textToReplace = output(item, currentTrigger); + + if (!textToReplace || typeof textToReplace === 'number') { + throw new Error( + `Output functor should return string or object in shape {text: string, caretPosition: string | number}.\nGot "${String( + textToReplace, + )}". Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n\nSee https://github.com/webscopeio/react-textarea-autocomplete#trigger-type for more informations.\n`, + ); + } + + if (typeof textToReplace === 'string') { + return { + caretPosition: DEFAULT_CARET_POSITION, + text: textToReplace, + }; + } + + if (!textToReplace.text && currentTrigger !== ':') { + throw new Error( + `Output "text" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`, + ); + } + + if (!textToReplace.caretPosition) { + throw new Error( + `Output "caretPosition" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`, + ); + } + + return textToReplace; + } + + if (typeof item !== 'string') { + throw new Error('Output item should be string\n'); + } + + return { + caretPosition: DEFAULT_CARET_POSITION, + text: `${currentTrigger}${item}${currentTrigger}`, + }; + }; + }, + [state.actualToken, state.currentTrigger, _getCurrentTriggerSettings], + ); + + const _getSuggestions = useCallback( + (paramTrigger?: string) => { + const { currentTrigger: stateTrigger, data } = state; + + const currentTrigger = paramTrigger || stateTrigger; + + if (!currentTrigger || !data || (data && !data.length)) return []; + + return data; + }, + [state.currentTrigger, state.data], + ); + + const getTriggerProps = useCallback(() => { + const { component, currentTrigger, selectionEnd, value } = state; + + const selectedItem = _getItemOnSelect(); + const suggestionData = _getSuggestions(); + const textToReplace = _getTextToReplace(); + + const triggerProps = { + component, + currentTrigger, + getSelectedItem: selectedItem, + getTextToReplace: textToReplace, + selectionEnd, + value, + values: suggestionData, + }; + + if ((showCommandsList && trigger['/']) || (showMentionsList && trigger['@'])) { + const { dataProvider } = trigger[showCommandsList ? '/' : '@']; + + dataProvider?.('', showCommandsList ? '/' : '@', (data) => { + triggerProps.values = data; + }); + + // @ts-ignore + triggerProps.component = showCommandsList ? CommandItem : UserItem; + triggerProps.currentTrigger = showCommandsList ? '/' : '@'; + triggerProps.getTextToReplace = _getTextToReplace(showCommandsList ? '/' : '@'); + triggerProps.getSelectedItem = _getItemOnSelect(showCommandsList ? '/' : '@'); + triggerProps.selectionEnd = 1; + triggerProps.value = showCommandsList ? '/' : '@'; + } + + return triggerProps; + }, [ + state, + _getItemOnSelect, + _getSuggestions, + _getTextToReplace, + showCommandsList, + showMentionsList, + trigger, + ]); + + const _dropdownScroll = useCallback( + (item: HTMLElement) => { + if (!scrollToItem || !dropdownRef.current) return; + + if (scrollToItem === true) { + defaultScrollToItem(dropdownRef.current, item); + return; + } + + if (typeof scrollToItem !== 'function' || scrollToItem.length !== 2) { + throw new Error( + '`scrollToItem` has to be boolean (true for default implementation) or function with two parameters: container, item.', + ); + } + + scrollToItem(dropdownRef.current, item); + }, + [scrollToItem], + ); + + useEffect(() => { + if (state.currentTrigger && state.actualToken) { + const triggerSettings = trigger[state.currentTrigger]; + + setState((prev) => ({ + ...prev, + dataLoading: true, + })); + + triggerSettings.dataProvider(state.actualToken, state.value, (data) => { + if (!Array.isArray(data)) { + throw new Error('Trigger provider has to provide an array!'); + } + + setState((prev) => ({ + ...prev, + data, + dataLoading: false, + component: triggerSettings.component, + })); + }); + } + }, [state.currentTrigger, state.actualToken, trigger, state.value]); + + useEffect(() => { + if (initialValue !== state.propsValue) { + setState((prev) => ({ + ...prev, + propsValue: initialValue, + value: initialValue ?? '', + })); + } + }, [initialValue]); + + const _cleanUpProps = useCallback(() => { + const notSafe = [ + 'additionalTextareaProps', + 'className', + 'closeCommandsList', + 'closeMentionsList', + 'closeOnClickOutside', + 'containerClassName', + 'containerStyle', + 'disableMentions', + 'dropdownClassName', + 'dropdownStyle', + 'grow', + 'handleSubmit', + 'innerRef', + 'itemClassName', + 'itemStyle', + 'listClassName', + 'listStyle', + 'loaderClassName', + 'loaderStyle', + 'loadingComponent', + 'minChar', + 'movePopupAsYouType', + 'onCaretPositionChange', + 'onChange', + 'ref', + 'replaceWord', + 'scrollToItem', + 'shouldSubmit', + 'showCommandsList', + 'showMentionsList', + 'SuggestionItem', + 'SuggestionList', + 'trigger', + 'value', + ]; + const copiedProps = { ...props }; + for (const prop in copiedProps) { + if (notSafe.includes(prop)) delete copiedProps[prop as keyof TextareaProps]; + } + + return props; + }, [props]); + + return ( +
    + {(() => { + const triggerProps = getTriggerProps(); + if ( + state.isComposing || + !triggerProps.values || + !triggerProps.currentTrigger || + (disableMentions && triggerProps.currentTrigger === '@') + ) { + return null; + } + + return ( +
    + {/*// @ts-ignore*/} + +
    + ); + })()} +