diff --git a/docusaurus/docs/React/components/contexts/message-input-context.mdx b/docusaurus/docs/React/components/contexts/message-input-context.mdx index cc9327c44..f62e45044 100644 --- a/docusaurus/docs/React/components/contexts/message-input-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-input-context.mdx @@ -246,6 +246,14 @@ Function that runs onSubmit to the underlying `textarea` component. | ---------------------------------------------------------------------- | | (event: React.BaseSyntheticEvent, customMessageData?: Message) => void | +### hideSendButton + +Allows to hide MessageInput's send button. Used by `MessageSimple` to hide the send button in `EditMessageForm`. Received from `MessageInputProps`. + +| Type | Default | +|---------|---------| +| boolean | false | + ### imageOrder The order in which image attachments have been added to the current message. diff --git a/docusaurus/docs/React/components/message-input-components/message-input.mdx b/docusaurus/docs/React/components/message-input-components/message-input.mdx index 517840f43..422f7177f 100644 --- a/docusaurus/docs/React/components/message-input-components/message-input.mdx +++ b/docusaurus/docs/React/components/message-input-components/message-input.mdx @@ -124,6 +124,14 @@ If true, expands the text input vertically for new lines. | ------- | ------- | | boolean | true | +### hideSendButton + +Allows to hide MessageInput's send button. Used by `MessageSimple` to hide the send button in `EditMessageForm`. + +| Type | Default | +|---------|---------| +| boolean | false | + ### Input Custom UI component handling how the message input is rendered. diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 3a21b37b4..74cc72461 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -124,6 +124,7 @@ const MessageSimpleWithContext = < string | string[]; /** If true, expands the text input vertically for new lines */ grow?: boolean; + /** Allows to hide MessageInput's send button. */ + hideSendButton?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ Input?: React.ComponentType>; /** Max number of rows the underlying `textarea` component is allowed to grow */ diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 2dceb86c8..82559d915 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -81,6 +81,7 @@ const MessageInputV1 = < cooldownRemaining, emojiPickerIsOpen, handleSubmit, + hideSendButton, isUploadEnabled, maxFilesLeft, numberOfUploads, @@ -164,7 +165,7 @@ const MessageInputV1 = < )} - {!cooldownRemaining && } + {!(cooldownRemaining || hideSendButton) && } @@ -188,6 +189,7 @@ const MessageInputV2 = < emojiPickerIsOpen, findAndEnqueueURLsToEnrich, handleSubmit, + hideSendButton, isUploadEnabled, linkPreviews, maxFilesLeft, @@ -304,8 +306,7 @@ const MessageInputV2 = < - {/* hide SendButton if this component is rendered in the edit message form */} - {!message && ( + {!hideSendButton && ( <> {cooldownRemaining ? ( - {!cooldownRemaining && } + {!(cooldownRemaining || hideSendButton) && } diff --git a/src/components/MessageInput/__tests__/LinkPreviewList.test.js b/src/components/MessageInput/__tests__/LinkPreviewList.test.js index 1173460c0..0ae3d342b 100644 --- a/src/components/MessageInput/__tests__/LinkPreviewList.test.js +++ b/src/components/MessageInput/__tests__/LinkPreviewList.test.js @@ -13,12 +13,7 @@ import { useMockedApis, } from '../../../mock-builders'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { - ChatProvider, - MessageProvider, - useChatContext, - useMessageInputContext, -} from '../../../context'; +import { ChatProvider, MessageProvider, useChatContext } from '../../../context'; import React, { useEffect } from 'react'; import { Chat } from '../../Chat'; import { Channel } from '../../Channel'; @@ -26,7 +21,6 @@ import { MessageActionsBox } from '../../MessageActions'; import { MessageInput } from '../MessageInput'; import '@testing-library/jest-dom'; -import { SendButton } from '../icons'; // Mock out lodash debounce implementation, so it calls the debounced method immediately jest.mock('lodash.debounce', () => @@ -107,17 +101,6 @@ const makeRenderFn = (InputComponent) => async ({ messageContextOverrides = {}, messageActionsBoxProps = {}, } = {}) => { - // circumvents not so good decision to render SendButton conditionally - const InputContainer = () => { - const { handleSubmit, message } = useMessageInputContext(); - return ( - <> - - {!!message && } - - ); - }; - let renderResult; await act(() => { renderResult = render( @@ -131,7 +114,7 @@ const makeRenderFn = (InputComponent) => async ({ getMessageActions={defaultMessageContextValue.getMessageActions} /> - + , diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index d98065277..0b90630eb 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -16,7 +16,7 @@ import { MessageActionsBox } from '../../MessageActions'; import { MessageProvider } from '../../../context/MessageContext'; import { useMessageInputContext } from '../../../context/MessageInputContext'; -import { useChatContext } from '../../../context/ChatContext'; +import { ChatProvider, useChatContext } from '../../../context/ChatContext'; import { dispatchMessageDeletedEvent, dispatchMessageUpdatedEvent, @@ -1117,12 +1117,52 @@ function axeNoViolations(container) { }); [ - { InputComponent: MessageInputSmall, name: 'MessageInputSmall' }, - { InputComponent: MessageInputFlat, name: 'MessageInputFlat' }, -].forEach(({ InputComponent, name: componentName }) => { + { InputComponent: MessageInputSmall, name: 'MessageInputSmall', themeVersion: '1' }, + { InputComponent: MessageInputSmall, name: 'MessageInputSmall', themeVersion: '2' }, + { InputComponent: MessageInputFlat, name: 'MessageInputFlat', themeVersion: '1' }, + { InputComponent: MessageInputFlat, name: 'MessageInputFlat', themeVersion: '2' }, +].forEach(({ InputComponent, name: componentName, themeVersion }) => { + const makeRenderFn = (InputComponent) => async ({ + channelProps = {}, + chatContextOverrides = {}, + messageInputProps = {}, + messageContextOverrides = {}, + messageActionsBoxProps = {}, + } = {}) => { + let renderResult; + await act(() => { + renderResult = render( + + {/**/} + + + + + + + , + ); + }); + return renderResult; + }; const renderComponent = makeRenderFn(InputComponent); - describe(`${componentName}`, () => { + describe(`${componentName}${themeVersion ? `(theme: ${themeVersion})` : ''}:`, () => { beforeEach(async () => { chatClient = await getTestClientWithUser({ id: user1.id }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannelData)]); @@ -1131,17 +1171,39 @@ function axeNoViolations(container) { afterEach(tearDown); - const render = async () => { + const render = async ({ + chatContextOverrides = {}, + messageContextOverrides = {}, + messageInputProps = {}, + } = {}) => { const message = componentName === 'MessageInputSmall' ? threadMessage : defaultMessageContextValue.message; await renderComponent({ - messageContextOverrides: { message }, + chatContextOverrides: { themeVersion, ...chatContextOverrides }, + messageContextOverrides: { message, ...messageContextOverrides }, + messageInputProps, }); return message; }; + const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => { + channel = chatClient.channel('messaging', mockedChannelData.channel.id); + channel.data.cooldown = 30; + channel.initialized = true; + const lastSentSecondsAhead = 5; + await render({ + chatContextOverrides: { + channel, + latestMessageDatesByChannels: { + [channel.cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + }, + }, + messageInputProps, + }); + }; + const initQuotedMessagePreview = async (message) => { await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument()); @@ -1154,7 +1216,9 @@ function axeNoViolations(container) { }; const quotedMessagePreviewIsDisplayedCorrectly = async (message) => { - await waitFor(() => expect(screen.queryByText(/reply to message/i)).toBeInTheDocument()); + await waitFor(() => + expect(screen.queryByTestId('quoted-message-preview')).toBeInTheDocument(), + ); await waitFor(() => expect(screen.getByText(message.text)).toBeInTheDocument()); }; @@ -1174,17 +1238,19 @@ function axeNoViolations(container) { const message = await render(); await initQuotedMessagePreview(message); message.text = nanoid(); - act(() => { + await act(() => { dispatchMessageUpdatedEvent(chatClient, message, channel); }); await quotedMessagePreviewIsDisplayedCorrectly(message); }); it('is closed on close button click', async () => { + // skip trying to cancel reply for theme version 2 as that is not supported + if (themeVersion === '2') return; const message = await render(); await initQuotedMessagePreview(message); const closeBtn = screen.getByRole('button', { name: /cancel reply/i }); - act(() => { + await act(() => { fireEvent.click(closeBtn); }); quotedMessagePreviewIsNotDisplayed(message); @@ -1193,11 +1259,53 @@ function axeNoViolations(container) { it('is closed on original message delete', async () => { const message = await render(); await initQuotedMessagePreview(message); - act(() => { + await act(() => { dispatchMessageDeletedEvent(chatClient, message, channel); }); quotedMessagePreviewIsNotDisplayed(message); }); }); + + describe('send button', () => { + const SEND_BTN_TEST_ID = 'send-button'; + + it('should be renderer for empty input', async () => { + await render(); + expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeInTheDocument(); + }); + + it('should be renderer when editing a message', async () => { + await render({ messageInputProps: { message: generateMessage() } }); + expect(screen.getByTestId(SEND_BTN_TEST_ID)).toBeInTheDocument(); + }); + + it('should not be renderer during active cooldown period', async () => { + await renderWithActiveCooldown(); + expect(screen.queryByTestId(SEND_BTN_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not be renderer if explicitly hidden', async () => { + await render({ messageInputProps: { hideSendButton: true } }); + expect(screen.queryByTestId(SEND_BTN_TEST_ID)).not.toBeInTheDocument(); + }); + }); + + describe('cooldown timer', () => { + const COOLDOWN_TIMER_TEST_ID = 'cooldown-timer'; + + it('should be renderer during active cool-down period', async () => { + await renderWithActiveCooldown(); + expect(screen.getByTestId(COOLDOWN_TIMER_TEST_ID)).toBeInTheDocument(); + }); + + it('should not be renderer if send button explicitly hidden only for MessageInputFlat theme 2', async () => { + await renderWithActiveCooldown({ messageInputProps: { hideSendButton: true } }); + if (componentName === 'MessageInputSmall' || themeVersion === '1') { + expect(screen.queryByTestId(COOLDOWN_TIMER_TEST_ID)).toBeInTheDocument(); + } else { + expect(screen.queryByTestId(COOLDOWN_TIMER_TEST_ID)).not.toBeInTheDocument(); + } + }); + }); }); }); diff --git a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js index d2abb7c79..85508be10 100644 --- a/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js +++ b/src/components/MessageInput/hooks/__tests__/useCooldownTimer.test.js @@ -32,7 +32,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAhead = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + [cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); @@ -44,7 +44,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAgo = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), + [cid]: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); @@ -63,7 +63,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAhead = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + [cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); @@ -75,7 +75,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAgo = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), + [cid]: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); @@ -84,7 +84,7 @@ describe('useCooldownTimer', () => { it('should set remaining cooldown time to 0 if skip-slow-mode is among own_capabilities', async () => { const channel = { cid, data: { cooldown, own_capabilities: ['skip-slow-mode'] } }; - const chatContext = { latestMessageDatesByChannels: { cid: new Date() } }; + const chatContext = { latestMessageDatesByChannels: { [cid]: new Date() } }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); expect(result.current.cooldownRemaining).toBe(0); }); @@ -98,7 +98,7 @@ describe('useCooldownTimer', () => { it('should set remaining cooldown time to 0 if previous messages sent earlier than channel.cooldown', async () => { const channel = { cid, data: { cooldown } }; - const chatContext = { latestMessageDatesByChannels: { cid: new Date('1970-1-1') } }; + const chatContext = { latestMessageDatesByChannels: { [cid]: new Date('1970-1-1') } }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); expect(result.current.cooldownRemaining).toBe(0); }); @@ -108,7 +108,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAgo = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), + [cid]: new Date(new Date().getTime() - lastSentSecondsAgo * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); @@ -120,7 +120,7 @@ describe('useCooldownTimer', () => { const lastSentSecondsAhead = 5; const chatContext = { latestMessageDatesByChannels: { - cid: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), + [cid]: new Date(new Date().getTime() + lastSentSecondsAhead * 1000), }, }; const { result } = await renderUseCooldownTimerHook({ channel, chatContext }); diff --git a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts index a4e672da8..253f960de 100644 --- a/src/components/MessageInput/hooks/useCreateMessageInputContext.ts +++ b/src/components/MessageInput/hooks/useCreateMessageInputContext.ts @@ -37,6 +37,7 @@ export const useCreateMessageInputContext = < handleChange, handleEmojiKeyDown, handleSubmit, + hideSendButton, imageOrder, imageUploads, insertText, @@ -116,6 +117,7 @@ export const useCreateMessageInputContext = < handleChange, handleEmojiKeyDown, handleSubmit, + hideSendButton, imageOrder, imageUploads, insertText, @@ -161,6 +163,7 @@ export const useCreateMessageInputContext = < emojiPickerIsOpen, fileUploadsValue, findAndEnqueueURLsToEnrich, + hideSendButton, imageUploadsValue, isUploadEnabled, linkPreviewsValue,