From 3878e2f3148f70dd78da623da3c4b3f2ee012cb7 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Fri, 9 Feb 2024 17:24:38 +0100 Subject: [PATCH] feat: implement message bounce flow (#2254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🎯 Goal 🚂 https://github.com/GetStream/stream-chat-js/pull/1213 🚂 https://github.com/GetStream/stream-chat-css/pull/264 In chats with moderation rules set up, message can bounce if its content is deemed potentially harmful. The author of a bounced message should then be presented with four alternatives: 1. Edit the message and try sending it again 2. Try sending it again as-is (this is helpful for "bounce then flag" flow) 3. Remove the message 4. Do nothing. Bounced messages are ephemeral, so it will soon disappear on its own ### 🛠 Implementation details This PR introduces a couple of new components, including the `MessageBounceModal` which is rendered by `MessageSimple` when a bounced message is clicked. The contents of the modal (`MessageBounceOption`) is an overridable component that should ideally render three buttons for the first three alternative options listed above. The callbacks for said buttons are provided via `MessageBounceContext`. ### 🎨 UI Changes The chat in the screenshot has a semantic filter set up which is triggered by the word "midnight". Here's what a bounced message with the word "midnight" looks like: ![image](https://github.com/GetStream/stream-chat-react/assets/975978/9476cde6-f310-41a8-bb6d-3e7ed0f58421) Clicking on the bounced messages opens `MessageBounceModal`: ![image](https://github.com/GetStream/stream-chat-react/assets/975978/9bbb7201-3342-4ce7-88f2-9a3131c96878) Clicking "Edit Message" opens the standard editing UI: ![image](https://github.com/GetStream/stream-chat-react/assets/975978/ef627801-9b3b-46e5-8d12-eb8b3fd08afd) ### To-Do and Next Steps [The design doc](https://www.figma.com/file/ekifwChR9tR7zRJg1QEzSM/Chat-UI-Kit-1.0-All-platforms?type=design&node-id=23638-313355&mode=design) for this feature also features a notification banner with a button, which is displayed when a message bounces. Clicking the button should bring the user to the bounced message. We don't have a way to have interactive elements within channel notifications at the moment, but this is going to be implemented in further PRs in two steps: 1. Allow passing arbitrary JSX to the notification, not just text 2. Implement a bounced message notification with a button to bring the user to the message - [x] Release `stream-chat-css` with udpated styles - [x] Release `stream-chat-js` with updates types - [x] Cover `MessageBounceModal` and `MessageBounceOptions` with tests - [x] Document new components and customization options - [x] Document the moderation flow --- .../components/contexts/component-context.mdx | 22 ++- .../contexts/message-bounce-context.mdx | 152 ++++++++++++++++++ .../components/core-components/channel.mdx | 19 ++- .../message-components/ui-components.mdx | 46 ++++++ package.json | 4 +- src/components/Channel/Channel.tsx | 3 + src/components/Message/MessageErrorText.tsx | 38 +++++ src/components/Message/MessageSimple.tsx | 36 ++++- src/components/Message/MessageText.tsx | 20 +-- .../Message/__tests__/MessageSimple.test.js | 103 +++++++++++- src/components/Message/utils.tsx | 8 + .../MessageBounce/MessageBounceModal.tsx | 23 +++ .../MessageBounce/MessageBouncePrompt.tsx | 58 +++++++ src/components/MessageBounce/index.ts | 2 + .../MessageInput/hooks/useSubmitHandler.ts | 2 +- src/context/ComponentContext.tsx | 4 +- src/context/MessageBounceContext.tsx | 78 +++++++++ src/context/index.ts | 1 + src/i18n/de.json | 2 + src/i18n/en.json | 2 + src/i18n/es.json | 2 + src/i18n/fr.json | 2 + src/i18n/hi.json | 2 + src/i18n/it.json | 2 + src/i18n/ja.json | 2 + src/i18n/ko.json | 2 + src/i18n/nl.json | 2 + src/i18n/pt.json | 2 + src/i18n/ru.json | 2 + src/i18n/tr.json | 2 + yarn.lock | 8 +- 31 files changed, 605 insertions(+), 46 deletions(-) create mode 100644 docusaurus/docs/React/components/contexts/message-bounce-context.mdx create mode 100644 src/components/Message/MessageErrorText.tsx create mode 100644 src/components/MessageBounce/MessageBounceModal.tsx create mode 100644 src/components/MessageBounce/MessageBouncePrompt.tsx create mode 100644 src/components/MessageBounce/index.ts create mode 100644 src/context/MessageBounceContext.tsx diff --git a/docusaurus/docs/React/components/contexts/component-context.mdx b/docusaurus/docs/React/components/contexts/component-context.mdx index dcb67cf42..963a48625 100644 --- a/docusaurus/docs/React/components/contexts/component-context.mdx +++ b/docusaurus/docs/React/components/contexts/component-context.mdx @@ -74,8 +74,10 @@ Custom UI component to display a user's avatar. Custom UI component to display image resp. a fallback in case of load error, in `` element. The default resp. custom (from `ComponentContext`) `BaseImage` component is rendered by: - - single image attachment in message list -- - group of image attachments in message list -- - image uploads preview in message input (composer) +- - group of image attachments in message + list +- - image + uploads preview in message input (composer) The `BaseImage` component accepts the same props as `` element. @@ -261,6 +263,14 @@ Custom UI component to display a timestamp on a message. | --------- | ------------------------------------------------------------------------------- | | component | | +### MessageBouncePrompt + +Custom UI component for the content of the modal dialog for messages that got bounced by the moderation rules. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------- | +| component | | + ### ModalGallery Custom UI component for viewing message's image attachments. @@ -369,16 +379,16 @@ Custom UI component for the typing indicator. Custom UI component that indicates a user is viewing unread messages. It disappears once the user scrolls to `UnreadMessagesSeparator`. -| Type | Default | -| --------- | ------------------------------------------------------------------------------------- | +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------------------- | | component | | ### UnreadMessagesSeparator Custom UI component inserted before the first message marked unread. -| Type | Default | -| --------- | ------------------------------------------------------------------------------------- | +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------------- | | component | | ### VirtualMessage diff --git a/docusaurus/docs/React/components/contexts/message-bounce-context.mdx b/docusaurus/docs/React/components/contexts/message-bounce-context.mdx new file mode 100644 index 000000000..32ced6dcc --- /dev/null +++ b/docusaurus/docs/React/components/contexts/message-bounce-context.mdx @@ -0,0 +1,152 @@ +--- +id: message_bounce_context +sidebar_position: 11 +title: MessageBounceContext +--- + +The `MessageBounceContext` is available inside the modal rendered by the default message component for messages that got bounced by the moderation rules. This context provides callbacks that can be used to deal with the bounced message. + +## Basic Usage + +In most cases when using the default Message UI component implementation you are not going to deal with the `MessageBounceContext` directly. However if you are customizing the Message UI component, or providing a custom `MessageBouncePrompt`, the callbacks provided by this context come in handy. + +Get values from context with our custom hook: + +```jsx +const { message, handleEdit, handleSend, handleDelete } = useMessageBounceContext(); +``` + +Use these callbacks to implement your custom `MessageBouncePrompt`. Normally this component displays three options: edit the message before sending it again, send the message again without changes (this can be useful if you are using the "Bounce then flag" moderation flow), and delete the message. + +```jsx +import { useMessageBounceContext } from 'stream-chat-react'; + +function MyCustomMessageBouncePrompt({ onClose }) { + const { message, handleEdit, handleSend, handleDelete } = useMessageBounceContext(); + return ( + <> +

Your message is in violation of our community rules.

+

Message id: "{message.id}"

+ + {/* ... */} + + ); +} +``` + +Then override the default `MessageBouncePrompt` component with your custom one: + +```jsx + + + + + + + + +``` + +## Usage in a Custom Message UI component + +When implementing your own Message component from scratch, you should consider implementing UI for bounced messages, especially if you are using one of the moderation flows with message bouncing ("Bounce", "Bounce then flag", or "Bounce then block"). + +To do that, first check if the message is bounced: + +```jsx +import { useMessageContext, isMessageBounced } from 'stream-chat-react'; + +function CustomMessage() { + const { message } = useMessageContext(); + const isBounced = isMessageBounced(message); + // ... +} +``` + +Then, display custom UI in case the message is bounced. Don't forget to wrap the UI with the `MessageBounceProvider`, so that it has access to the callbacks used to deal with the bounced message: + +```jsx +import { useMessageContext, isMessageBounced, MessageBounceProvider } from 'stream-chat-react'; + +function MyCustomMessage() { + const { message } = useMessageContext(); + const isBounced = isMessageBounced(message); + + return ( +
+ {/* ... */} + + + {isBounced && ( + + + + )} +
+ ); +} + +function MyCustomMessageBouncePrompt({ onClose }) { + const { message, handleEdit, handleSend, handleDelete } = useMessageBounceContext(); + return ( + <> + + {/* ... */} + + ); +} +``` + +It only makes sense to render `MessageBounceProvider` in the context of a bounced message, so you'll see a warning in the browser console if you try to render it for any other type of message. + +Implementing a custom Message UI component from scratch is a larger topic, covered by the [Message UI Customization](../../guides/theming/message-ui.mdx) guide. + +## Values + +### message + +The object representing the message that got bounced. + +| Type | +| ------------- | +| StreamMessage | + +### handleEdit + +Call this function to switch the bounced message into editing mode. + +| Type | +| ----------------- | +| ReactEventHandler | + +### handleSend + +Call this function to try sending the bounced message again without changes. + +| Type | +| ----------------- | +| ReactEventHandler | + +### handleDelete + +Call this function to remove the bounced message from the message list. + +| Type | +| ----------------- | +| ReactEventHandler | diff --git a/docusaurus/docs/React/components/core-components/channel.mdx b/docusaurus/docs/React/components/core-components/channel.mdx index e81dea59c..7ad965e09 100644 --- a/docusaurus/docs/React/components/core-components/channel.mdx +++ b/docusaurus/docs/React/components/core-components/channel.mdx @@ -474,7 +474,7 @@ export const MessageInput = (props: MessageInputProps) => { Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is not marked read on mount. | Type | Default | -|---------|---------| +| ------- | ------- | | boolean | false | ### Input @@ -589,6 +589,14 @@ Custom UI component to display a timestamp on a message. | --------- | ------------------------------------------------------------------------------- | | component | | +### MessageBouncePrompt + +Custom UI component for the content of the modal dialog for messages that got bounced by the moderation rules. + +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------- | +| component | | + ### ModalGallery Custom UI component for viewing message's image attachments. @@ -745,17 +753,16 @@ Custom UI component for the typing indicator. Custom UI component that indicates a user is viewing unread messages. It disappears once the user scrolls to `UnreadMessagesSeparator`. -| Type | Default | -| --------- | ------------------------------------------------------------------------------------- | +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------------------- | | component | | - ### UnreadMessagesSeparator Custom UI component inserted before the first message marked unread. -| Type | Default | -| --------- | ------------------------------------------------------------------------------------- | +| Type | Default | +| --------- | ------------------------------------------------------------------------------------------------- | | component | | ### videoAttachmentSizeHandler diff --git a/docusaurus/docs/React/components/message-components/ui-components.mdx b/docusaurus/docs/React/components/message-components/ui-components.mdx index fdff420d0..bc83d5071 100644 --- a/docusaurus/docs/React/components/message-components/ui-components.mdx +++ b/docusaurus/docs/React/components/message-components/ui-components.mdx @@ -37,6 +37,9 @@ The following UI components are available for use: - [`QuotedMessage`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/QuotedMessage.tsx) - shows a quoted message UI wrapper when the sent message quotes a previous message +- [`MessageBouncePrompt`](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageBounce/MessageBouncePrompt.tsx) - + presents options to deal with a message that got bounced by the moderation rules. + Besides the above there are also components that render reaction list and reaction selector. You can find more about them in [dedicated chapter](./reactions.mdx). ## MessageActions Props @@ -415,3 +418,46 @@ The side of the message list to render MML components. :::note `QuotedMessage` only consumes context and does not accept any optional props. ::: + +## MessageBouncePrompt + +This component is rendered in a modal dialog for messages that got bounced by the moderation rules. + +### MessageBouncePrompt children + +| Type | Default | +| --------- | ----------------------------------------------------------------------- | +| ReactNode | Localized string for "This message did not meet our content guidelines" | + +Use this prop to easily override the text displayed in the modal dialog for the bounced messages, without fully implementing a custom `MessageBouncePrompt` component: + +```jsx +import { MessageBouncePrompt } from 'stream-react-chat'; + +function MyCustomMessageBouncePrompt(props) { + return My custom text; +} +``` + +Then override the default `MessageBouncePrompt` component with your custom one: + +```jsx + + + + + + + + +``` + +If you need deeper customization, refer to the [`MessageBounceContext`](../contexts/message-bounce-context.mdx) documentation. + +### onClose + +The Message UI component will pass this callback to close the modal dialog `MessageBouncePrompt` are rendered in. + +| Type | +| ----------------- | +| ReactEventHandler | diff --git a/package.json b/package.json index b13a9ab57..a89ebb129 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "emoji-mart": "^5.4.0", "react": "^18.0.0 || ^17.0.0 || ^16.8.0", "react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0", - "stream-chat": "^8.0.0" + "stream-chat": "^8.15.0" }, "peerDependenciesMeta": { "emoji-mart": { @@ -144,7 +144,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^4.6.3", + "@stream-io/stream-chat-css": "^4.7.0", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 7e8cb60b2..58343b661 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -140,6 +140,8 @@ type ChannelPropsForwardedToComponentContext< LoadingIndicator?: ComponentContextValue['LoadingIndicator']; /** Custom UI component to display a message in the standard `MessageList`, defaults to and accepts the same props as: [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ Message?: ComponentContextValue['Message']; + /** 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?: ComponentContextValue['MessageBouncePrompt']; /** 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?: ComponentContextValue['MessageDeleted']; /** 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) */ @@ -1112,6 +1114,7 @@ const ChannelInner = < LinkPreviewList: props.LinkPreviewList, LoadingIndicator: props.LoadingIndicator, Message: props.Message || MessageSimple, + MessageBouncePrompt: props.MessageBouncePrompt, MessageDeleted: props.MessageDeleted, MessageListNotifications: props.MessageListNotifications, MessageNotification: props.MessageNotification, diff --git a/src/components/Message/MessageErrorText.tsx b/src/components/Message/MessageErrorText.tsx new file mode 100644 index 000000000..fd4f7a137 --- /dev/null +++ b/src/components/Message/MessageErrorText.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { StreamMessage, useTranslationContext } from '../../context'; +import { DefaultStreamChatGenerics } from '../../types/types'; +import { isMessageBounced } from './utils'; + +export interface MessageErrorTextProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> { + message: StreamMessage; + theme: string; +} + +export function MessageErrorText< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ message, theme }: MessageErrorTextProps) { + const { t } = useTranslationContext('MessageText'); + + if (message.type === 'error' && !isMessageBounced(message)) { + return ( +
+ {t('Error · Unsent')} +
+ ); + } + + if (message.status === 'failed') { + return ( +
+ {message.errorStatusCode !== 403 + ? t('Message Failed · Click to try again') + : t('Message Failed · Unauthorized')} +
+ ); + } + + return null; +} diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 74cc72461..bf5bafc6b 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -1,14 +1,20 @@ -import React from 'react'; +import React, { useState } from 'react'; import clsx from 'clsx'; import { MessageErrorIcon } from './icons'; +import { MessageBouncePrompt as DefaultMessageBouncePrompt } from '../MessageBounce'; import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted'; import { MessageOptions as DefaultMessageOptions } from './MessageOptions'; import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from './MessageRepliesCountButton'; import { MessageStatus as DefaultMessageStatus } from './MessageStatus'; import { MessageText } from './MessageText'; import { MessageTimestamp as DefaultMessageTimestamp } from './MessageTimestamp'; -import { areMessageUIPropsEqual, messageHasAttachments, messageHasReactions } from './utils'; +import { + areMessageUIPropsEqual, + isMessageBounced, + messageHasAttachments, + messageHasReactions, +} from './utils'; import { Avatar as DefaultAvatar } from '../Avatar'; import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; @@ -19,6 +25,7 @@ import { ReactionsList as DefaultReactionList, ReactionSelector as DefaultReactionSelector, } from '../Reactions'; +import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; @@ -59,11 +66,14 @@ const MessageSimpleWithContext = < threadList, } = props; + const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); + const { Attachment, Avatar = DefaultAvatar, EditMessageInput = DefaultEditMessageForm, MessageDeleted = DefaultMessageDeleted, + MessageBouncePrompt = DefaultMessageBouncePrompt, MessageOptions = DefaultMessageOptions, MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, @@ -95,6 +105,15 @@ const MessageSimpleWithContext = < const showMetadata = !groupedByUser || endOfGroup; const showReplyCountButton = !threadList && !!message.reply_count; const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403; + const isBounced = isMessageBounced(message); + + let handleClick: (() => void) | undefined = undefined; + + if (allowRetry) { + handleClick = () => handleRetry(message); + } else if (isBounced) { + handleClick = () => setIsBounceDialogOpen(true); + } const rootClassName = clsx( 'str-chat__message str-chat__message-simple', @@ -131,6 +150,13 @@ const MessageSimpleWithContext = < /> )} + {isBounceDialogOpen && ( + setIsBounceDialogOpen(false)} + open={isBounceDialogOpen} + /> + )} {
{themeVersion === '1' && } @@ -145,11 +171,11 @@ const MessageSimpleWithContext = < )}
handleRetry(message) : undefined} - onKeyUp={allowRetry ? () => handleRetry(message) : undefined} + onClick={handleClick} + onKeyUp={handleClick} >
diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 270ffd61f..28e130034 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -5,6 +5,7 @@ import { isOnlyEmojis, messageHasAttachments } from './utils'; import { useComponentContext, useMessageContext, useTranslationContext } from '../../context'; import { renderText as defaultRenderText } from './renderText'; +import { MessageErrorText } from './MessageErrorText'; import type { TranslationLanguages } from 'stream-chat'; import type { MessageContextValue, StreamMessage } from '../../context'; @@ -50,7 +51,7 @@ const UnMemoizedMessageTextComponent = < const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText; - const { t, userLanguage } = useTranslationContext('MessageText'); + const { userLanguage } = useTranslationContext('MessageText'); const message = propMessage || contextMessage; const hasAttachment = messageHasAttachments(message); @@ -86,22 +87,7 @@ const UnMemoizedMessageTextComponent = < onMouseOver={onMentionsHoverMessage} > {message.quoted_message && } - {message.type === 'error' && ( -
- {t('Error · Unsent')} -
- )} - {message.status === 'failed' && ( -
- {message.errorStatusCode !== 403 - ? t('Message Failed · Click to try again') - : t('Message Failed · Unauthorized')} -
- )} + {unsafeHTML && message.html ? (
) : ( diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index ce201b14a..36d638988 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -36,6 +36,7 @@ import { generateUser, getTestClientWithUser, } from '../../../mock-builders'; +import { MessageBouncePrompt } from '../../MessageBounce'; Dayjs.extend(calendar); @@ -44,8 +45,8 @@ jest.mock('../MessageText', () => ({ MessageText: jest.fn(() =>
) })); jest.mock('../../MML', () => ({ MML: jest.fn(() =>
) })); jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() =>
) })); jest.mock('../../MessageInput', () => ({ - EditMessageForm: jest.fn(() =>
), - MessageInput: jest.fn(() =>
), + EditMessageForm: jest.fn(() =>
), + MessageInput: jest.fn(() =>
), })); jest.mock('../../Modal', () => ({ Modal: jest.fn((props) =>
{props.children}
) })); @@ -55,6 +56,7 @@ const carol = generateUser(); const openThreadMock = jest.fn(); const tDateTimeParserMock = jest.fn((date) => Dayjs(date)); const retrySendMessageMock = jest.fn(); +const removeMessageMock = jest.fn(); async function renderMessageSimple({ message, @@ -63,6 +65,7 @@ async function renderMessageSimple({ channelCapabilities = { 'send-reaction': true }, components = {}, renderer = render, + themeVersion = '1', }) { const channel = generateChannel({ getConfig: () => channelConfigOverrides, @@ -73,10 +76,14 @@ async function renderMessageSimple({ const client = await getTestClientWithUser(alice); return renderer( - + key, tDateTimeParser: tDateTimeParserMock }}> ', () => { const results = await axe(container); expect(results).toHaveNoViolations(); }); + + describe('bounced message', () => { + const bouncedMessageOptions = { + moderation_details: { + action: 'MESSAGE_RESPONSE_ACTION_BOUNCE', + }, + type: 'error', + }; + + it('should render error badge for bounced messages', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const { queryByTestId } = await renderMessageSimple({ message, themeVersion: '2' }); + expect(queryByTestId('error')).toBeInTheDocument(); + }); + + it('should render open bounce modal on click', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const { getByTestId, queryByTestId } = await renderMessageSimple({ message }); + fireEvent.click(getByTestId('message-inner')); + expect(queryByTestId('message-bounce-prompt')).toBeInTheDocument(); + }); + + it('should switch to message editing', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const { getByTestId, queryByTestId } = await renderMessageSimple({ + message, + }); + fireEvent.click(getByTestId('message-inner')); + fireEvent.click(getByTestId('message-bounce-edit')); + expect(queryByTestId('message-input')).toBeInTheDocument(); + }); + + it('should retry sending message', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const { getByTestId } = await renderMessageSimple({ + message, + }); + fireEvent.click(getByTestId('message-inner')); + fireEvent.click(getByTestId('message-bounce-send')); + expect(retrySendMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: message.id, + }), + ); + }); + + it('should remove message', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const { getByTestId } = await renderMessageSimple({ + message, + }); + fireEvent.click(getByTestId('message-inner')); + fireEvent.click(getByTestId('message-bounce-delete')); + expect(removeMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: message.id, + }), + ); + }); + + it('should use overriden modal content component', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const CustomMessageBouncePrompt = () => ( +
Overriden
+ ); + const { getByTestId, queryByTestId } = await renderMessageSimple({ + components: { + MessageBouncePrompt: CustomMessageBouncePrompt, + }, + message, + }); + fireEvent.click(getByTestId('message-inner')); + expect(queryByTestId('custom-message-bounce-prompt')).toBeInTheDocument(); + }); + + it('should use overriden modal content text', async () => { + const message = generateAliceMessage(bouncedMessageOptions); + const CustomMessageBouncePrompt = () => Overriden; + const { getByTestId, queryByText } = await renderMessageSimple({ + components: { + MessageBouncePrompt: CustomMessageBouncePrompt, + }, + message, + }); + fireEvent.click(getByTestId('message-inner')); + expect(queryByText('Overriden')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index ec3d263b2..e74d5292c 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -434,3 +434,11 @@ export const isOnlyEmojis = (text?: string) => { return !noSpace; }; + +export const isMessageBounced = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + message: Pick, 'type' | 'moderation_details'>, +) => + message.type === 'error' && + message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE'; diff --git a/src/components/MessageBounce/MessageBounceModal.tsx b/src/components/MessageBounce/MessageBounceModal.tsx new file mode 100644 index 000000000..102afca21 --- /dev/null +++ b/src/components/MessageBounce/MessageBounceModal.tsx @@ -0,0 +1,23 @@ +import React, { ComponentType, PropsWithChildren } from 'react'; +import { Modal, ModalProps } from '../Modal'; +import { MessageBounceProvider } from '../../context'; +import { MessageBouncePromptProps } from './MessageBouncePrompt'; + +export type MessageBounceModalProps = PropsWithChildren< + ModalProps & { + MessageBouncePrompt: ComponentType; + } +>; + +export function MessageBounceModal({ + MessageBouncePrompt, + ...modalProps +}: MessageBounceModalProps) { + return ( + + + + + + ); +} diff --git a/src/components/MessageBounce/MessageBouncePrompt.tsx b/src/components/MessageBounce/MessageBouncePrompt.tsx new file mode 100644 index 000000000..805dcb156 --- /dev/null +++ b/src/components/MessageBounce/MessageBouncePrompt.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useMessageBounceContext, useTranslationContext } from '../../context'; + +import type { MouseEventHandler, PropsWithChildren } from 'react'; +import type { DefaultStreamChatGenerics } from '../../types/types'; +import type { ModalProps } from '../Modal'; + +export type MessageBouncePromptProps = PropsWithChildren>; + +export function MessageBouncePrompt< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ children, onClose }: MessageBouncePromptProps) { + const { handleDelete, handleEdit, handleRetry } = useMessageBounceContext( + 'MessageBouncePrompt', + ); + const { t } = useTranslationContext('MessageBouncePrompt'); + + function createHandler( + handle: MouseEventHandler, + ): MouseEventHandler { + return (e) => { + handle(e); + onClose?.(e); + }; + } + + return ( +
+
+ {children ?? t('This message did not meet our content guidelines')} +
+
+ + + +
+
+ ); +} diff --git a/src/components/MessageBounce/index.ts b/src/components/MessageBounce/index.ts new file mode 100644 index 000000000..96acd90a4 --- /dev/null +++ b/src/components/MessageBounce/index.ts @@ -0,0 +1,2 @@ +export * from './MessageBounceModal'; +export * from './MessageBouncePrompt'; diff --git a/src/components/MessageInput/hooks/useSubmitHandler.ts b/src/components/MessageInput/hooks/useSubmitHandler.ts index 8ac25c438..adaf742b4 100644 --- a/src/components/MessageInput/hooks/useSubmitHandler.ts +++ b/src/components/MessageInput/hooks/useSubmitHandler.ts @@ -189,7 +189,7 @@ export const useSubmitHandler = < ((!someLinkPreviewsLoading && attachmentsFromLinkPreviews.length > 0) || someLinkPreviewsDismissed); const sendOptions = linkPreviewsEnabled ? { skip_enrich_url } : undefined; - if (message) { + if (message && message.type !== 'error') { delete message.i18n; try { diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 0568c0e4c..eda334154 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -40,7 +40,8 @@ import type { } from '../components'; import type { LinkPreviewListProps } from '../components/MessageInput/LinkPreviewList'; import type { ReactionOptions } from '../components/Reactions/reactionOptions'; -import { UnreadMessagesNotificationProps } from '../components/MessageList/UnreadMessagesNotification'; +import type { MessageBouncePromptProps } from '../components/MessageBounce'; +import type { UnreadMessagesNotificationProps } from '../components/MessageList/UnreadMessagesNotification'; export type ComponentContextValue< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, @@ -70,6 +71,7 @@ export type ComponentContextValue< Input?: React.ComponentType>; LinkPreviewList?: React.ComponentType; LoadingIndicator?: React.ComponentType; + MessageBouncePrompt?: React.ComponentType; MessageDeleted?: React.ComponentType>; MessageListNotifications?: React.ComponentType; MessageNotification?: React.ComponentType; diff --git a/src/context/MessageBounceContext.tsx b/src/context/MessageBounceContext.tsx new file mode 100644 index 000000000..7bd50e32e --- /dev/null +++ b/src/context/MessageBounceContext.tsx @@ -0,0 +1,78 @@ +import React, { createContext, ReactEventHandler, useCallback, useContext, useMemo } from 'react'; +import { useMessageContext } from './MessageContext'; +import { DefaultStreamChatGenerics, PropsWithChildrenOnly } from '../types/types'; +import { StreamMessage } from './ChannelStateContext'; +import { useChannelActionContext } from './ChannelActionContext'; +import { isMessageBounced } from '../components'; + +export interface MessageBounceContextValue< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> { + handleDelete: ReactEventHandler; + handleEdit: ReactEventHandler; + handleRetry: ReactEventHandler; + message: StreamMessage; +} + +const MessageBounceContext = createContext(undefined); + +export function useMessageBounceContext< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>(componentName?: string) { + const contextValue = useContext(MessageBounceContext); + + if (!contextValue) { + console.warn( + `The useMessageBounceContext hook was called outside of the MessageBounceContext provider. The errored call is located in the ${componentName} component.`, + ); + + return {} as MessageBounceContextValue; + } + + return contextValue; +} + +export function MessageBounceProvider< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ children }: PropsWithChildrenOnly) { + const { + handleRetry: doHandleRetry, + message, + setEditingState, + } = useMessageContext('MessageBounceProvider'); + + if (!isMessageBounced(message)) { + console.warn( + `The MessageBounceProvider was rendered for a message that is not bounced. Have you missed the "isMessageBounced" check?`, + ); + } + + const { removeMessage } = useChannelActionContext('MessageBounceProvider'); + + const handleDelete: ReactEventHandler = useCallback(() => { + removeMessage(message); + }, [message, removeMessage]); + + const handleEdit: ReactEventHandler = useCallback( + (e) => { + setEditingState(e); + }, + [setEditingState], + ); + + const handleRetry = useCallback(() => { + doHandleRetry(message); + }, [doHandleRetry, message]); + + const value = useMemo( + () => ({ + handleDelete, + handleEdit, + handleRetry, + message, + }), + [handleDelete, handleEdit, handleRetry, message], + ); + + return {children}; +} diff --git a/src/context/index.ts b/src/context/index.ts index 8f5a4a11f..21c075feb 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -4,6 +4,7 @@ export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; export * from './MessageContext'; +export * from './MessageBounceContext'; export * from './MessageInputContext'; export * from './MessageListContext'; export * from './TranslationContext'; diff --git a/src/i18n/de.json b/src/i18n/de.json index 6639cf379..fcd2bc9ca 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -54,11 +54,13 @@ "Search": "Suche", "Searching...": "Suchen...", "Send": "Senden", + "Send Anyway": "Trotzdem senden", "Send message request failed": "Senden der Nachrichtenanfrage fehlgeschlagen", "Sending...": "Senden...", "Shuffle": "Mischen", "Slow Mode ON": "Slow-Mode EIN", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", + "This message did not meet our content guidelines": "Diese Nachricht entsprach nicht unseren Inhaltsrichtlinien", "This message was deleted...": "Diese Nachricht wurde gelöscht...", "Thread": "Thread", "Type your message": "Nachricht eingeben", diff --git a/src/i18n/en.json b/src/i18n/en.json index d65c08389..c05911d13 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -54,11 +54,13 @@ "Search": "Search", "Searching...": "Searching...", "Send": "Send", + "Send Anyway": "Send Anyway", "Send message request failed": "Send message request failed", "Sending...": "Sending...", "Shuffle": "Shuffle", "Slow Mode ON": "Slow Mode ON", "Some of the files will not be accepted": "Some of the files will not be accepted", + "This message did not meet our content guidelines": "This message did not meet our content guidelines", "This message was deleted...": "This message was deleted...", "Thread": "Thread", "Type your message": "Type your message", diff --git a/src/i18n/es.json b/src/i18n/es.json index 205063473..6e19b281b 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -54,11 +54,13 @@ "Search": "Buscar", "Searching...": "Buscando...", "Send": "Enviar", + "Send Anyway": "Enviar de todos modos", "Send message request failed": "Error al enviar la solicitud de mensaje", "Sending...": "Enviando...", "Shuffle": "Mezclar", "Slow Mode ON": "Modo lento activado", "Some of the files will not be accepted": "Algunos de los archivos no serán aceptados", + "This message did not meet our content guidelines": "Este mensaje no cumple nuestras directrices de contenido", "This message was deleted...": "Este mensaje fue eliminado ...", "Thread": "Hilo", "Type your message": "Escribe tu mensaje", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 6946a40f4..a8e1cebd6 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -54,11 +54,13 @@ "Search": "Rechercher", "Searching...": "Recherche...", "Send": "Envoyer", + "Send Anyway": "Envoyer quand même", "Send message request failed": "Échec de la demande d'envoi de message", "Sending...": "Envoi en cours...", "Shuffle": "Mélanger", "Slow Mode ON": "Mode lent activé", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptés", + "This message did not meet our content guidelines": "Ce message n'est pas conforme à nos lignes directrices en matière de contenu", "This message was deleted...": "Ce message a été supprimé...", "Thread": "Fil de discussion", "Type your message": "Saisissez votre message", diff --git a/src/i18n/hi.json b/src/i18n/hi.json index 8c05b07b1..960335c28 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -55,11 +55,13 @@ "Search": "खोज", "Searching...": "खोज कर...", "Send": "भेजे", + "Send Anyway": "वैसे भी भेजें", "Send message request failed": "संदेश भेजने का अनुरोध विफल रहा", "Sending...": "भेजा जा रहा है", "Shuffle": "मिश्रित करें", "Slow Mode ON": "स्लो मोड ऑन", "Some of the files will not be accepted": "कुछ फ़ाइलें स्वीकार नहीं की जाएंगी", + "This message did not meet our content guidelines": "यह संदेश हमारे सामग्री दिशानिर्देशों के अनुरूप नहीं था", "This message was deleted...": "मैसेज हटा दिया गया", "Thread": "रिप्लाई थ्रेड", "Type your message": "अपना मैसेज लिखे", diff --git a/src/i18n/it.json b/src/i18n/it.json index faa1740ee..7f4606fa2 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -54,11 +54,13 @@ "Search": "Ricerca", "Searching...": "Ricerca in corso ...", "Send": "Invia", + "Send Anyway": "Invia comunque", "Send message request failed": "Invia messaggio di richiesta non riuscito", "Sending...": "Invio in corso...", "Shuffle": "Mescolare", "Slow Mode ON": "Modalità lenta attivata", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", + "This message did not meet our content guidelines": "Questo messaggio non soddisfa le nostre linee guida sui contenuti", "This message was deleted...": "Questo messaggio é stato cancellato", "Thread": "Thread", "Type your message": "Scrivi il tuo messaggio", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index aa8df9fe0..6f0b93f45 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -54,11 +54,13 @@ "Search": "探す", "Searching...": "検索中...", "Send": "送信", + "Send Anyway": "とにかく送信する", "Send message request failed": "メッセージ送信リクエストが失敗しました", "Sending...": "送信中...", "Shuffle": "シャッフル", "Slow Mode ON": "スローモードオン", "Some of the files will not be accepted": "一部のファイルは受け付けられません", + "This message did not meet our content guidelines": "このメッセージはコンテンツガイドラインに適合していません", "This message was deleted...": "このメッセージは削除されました...", "Thread": "スレッド", "Type your message": "メッセージを入力してください", diff --git a/src/i18n/ko.json b/src/i18n/ko.json index bc1ed5b3d..31fefb92d 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -54,11 +54,13 @@ "Search": "찾다", "Searching...": "수색...", "Send": "보내다", + "Send Anyway": "어쨌든 보내기", "Send message request failed": "메시지 보내기 요청 실패", "Sending...": "배상중...", "Shuffle": "셔플", "Slow Mode ON": "슬로우 모드 켜짐", "Some of the files will not be accepted": "일부 파일은 허용되지 않을 수 있습니다", + "This message did not meet our content guidelines": "이 메시지는 콘텐츠 가이드라인을 충족하지 않습니다.", "This message was deleted...": "이 메시지는 삭제되었습니다...", "Thread": "스레드", "Type your message": "메시지 입력", diff --git a/src/i18n/nl.json b/src/i18n/nl.json index 472fffbc6..7abfad72b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -54,11 +54,13 @@ "Search": "Zoeken", "Searching...": "Zoeken...", "Send": "Verstuur", + "Send Anyway": "Toch versturen", "Send message request failed": "Verzoek om bericht te verzenden mislukt", "Sending...": "Aan het verzenden...", "Shuffle": "Schudden", "Slow Mode ON": "Langzame modus aan", "Some of the files will not be accepted": "Sommige bestanden zullen niet worden geaccepteerd", + "This message did not meet our content guidelines": "Dit bericht voldeed niet aan onze inhoudsrichtlijnen", "This message was deleted...": "Dit bericht was verwijderd", "Thread": "Draadje", "Type your message": "Type je bericht", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 71999e51a..20b320325 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -54,11 +54,13 @@ "Search": "Procurar", "Searching...": "Procurando...", "Send": "Enviar", + "Send Anyway": "Enviar de qualquer forma", "Send message request failed": "O pedido de envio de mensagem falhou", "Sending...": "Enviando...", "Shuffle": "Embaralhar", "Slow Mode ON": "Modo lento LIGADO", "Some of the files will not be accepted": "Alguns dos arquivos não serão aceitos", + "This message did not meet our content guidelines": "Esta mensagem não corresponde às nossas directrizes de conteúdo", "This message was deleted...": "Esta mensagem foi excluída...", "Thread": "Fio", "Type your message": "Digite sua mensagem", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index bde796b5e..580a88cce 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -54,11 +54,13 @@ "Search": "Поиск", "Searching...": "Ищем...", "Send": "Отправить", + "Send Anyway": "Мне всё равно, отправить", "Send message request failed": "Не удалось отправить запрос на отправку сообщения", "Sending...": "Отправка...", "Shuffle": "Перемешать", "Slow Mode ON": "Медленный режим включен", "Some of the files will not be accepted": "Некоторые файлы не будут приняты", + "This message did not meet our content guidelines": "Сообщение не соответствует правилам", "This message was deleted...": "Сообщение было удалено...", "Thread": "Ветка", "Type your message": "Ваше сообщение", diff --git a/src/i18n/tr.json b/src/i18n/tr.json index db354a88d..eadcbeef0 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -54,11 +54,13 @@ "Search": "Arama", "Searching...": "Aranıyor...", "Send": "Gönder", + "Send Anyway": "Neyse Gönder", "Send message request failed": "Mesaj gönderme isteği başarısız oldu", "Sending...": "Gönderiliyor...", "Shuffle": "Karıştır", "Slow Mode ON": "Yavaş Mod Açık", "Some of the files will not be accepted": "Bazı dosyalar kabul edilmeyecektir", + "This message did not meet our content guidelines": "Bu mesaj içerik yönergelerimize uygun değil", "This message was deleted...": "Bu mesaj silindi", "Thread": "Konu", "Type your message": "Mesajınızı yazın", diff --git a/yarn.lock b/yarn.lock index 4191cdb40..1ae4632e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2186,10 +2186,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^4.6.3": - version "4.6.3" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-4.6.3.tgz#aabe1a5de9ced9e09c17cc43275ddc0b1546b9b0" - integrity sha512-f1CwE593KYeTMw5p96UD+rKW1L4PTaTJDmn+S237SjwTzaS/9qgPoz6IxZ5odpuni7yX09cCq1IRIdbR45eKvA== +"@stream-io/stream-chat-css@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-4.7.0.tgz#9fb46d19268b9401a1f3040e11a6a6e1c2b85f7a" + integrity sha512-23QmqoZJ3jdz4SQ+HQvHj2D7It3PgQ9xv1rbnjjWnnYVUWxojFhvqw0+f7j+Gh2hjHpJrBZel4lZDQHiK3uQ9g== "@stream-io/transliterate@^1.5.5": version "1.5.5"