diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 2328c78a5..923393f62 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -142,6 +142,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: [MessageBounceOptions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageBounce/MessageBounceOptions.tsx) */ + MessageBounceOptions?: ComponentContextValue['MessageBounceOptions']; /** 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) */ @@ -1038,6 +1040,7 @@ const ChannelInner = < LinkPreviewList: props.LinkPreviewList, LoadingIndicator: props.LoadingIndicator, Message: props.Message || MessageSimple, + MessageBounceOptions: props.MessageBounceOptions, 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..342f1dcf2 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 { MessageBounceOptions as DefaultMessageBounceOptions } 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'; @@ -27,6 +33,7 @@ import { MessageContextValue, useMessageContext } from '../../context/MessageCon import type { MessageUIComponentProps } from './types'; import type { DefaultStreamChatGenerics } from '../../types/types'; +import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; type MessageSimpleWithContextProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -59,11 +66,14 @@ const MessageSimpleWithContext = < threadList, } = props; + const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); + const { Attachment, Avatar = DefaultAvatar, EditMessageInput = DefaultEditMessageForm, MessageDeleted = DefaultMessageDeleted, + MessageBounceOptions = DefaultMessageBounceOptions, 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..719724d9e 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -9,6 +9,7 @@ import { renderText as defaultRenderText } from './renderText'; import type { TranslationLanguages } from 'stream-chat'; import type { MessageContextValue, StreamMessage } from '../../context'; import type { DefaultStreamChatGenerics } from '../../types/types'; +import { MessageErrorText } from './MessageErrorText'; export type MessageTextProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -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/utils.tsx b/src/components/Message/utils.tsx index d2f6698a1..0295fe28b 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -413,3 +413,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..2e4186dde --- /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 { MessageBounceOptionsProps } from './MessageBounceOptions'; + +export type MessageBounceModalProps = PropsWithChildren< + ModalProps & { + MessageBounceOptions: ComponentType; + } +>; + +export function MessageBounceModal({ + MessageBounceOptions, + ...modalProps +}: MessageBounceModalProps) { + return ( + + + + + + ); +} diff --git a/src/components/MessageBounce/MessageBounceOptions.tsx b/src/components/MessageBounce/MessageBounceOptions.tsx new file mode 100644 index 000000000..3c616b4fc --- /dev/null +++ b/src/components/MessageBounce/MessageBounceOptions.tsx @@ -0,0 +1,56 @@ +import React, { MouseEventHandler, PropsWithChildren } from 'react'; +import { DefaultStreamChatGenerics } from '../../types/types'; +import { ModalProps } from '../Modal'; +import { useMessageBounceContext, useTranslationContext } from '../../context'; + +export type MessageBounceOptionsProps = PropsWithChildren>; + +export function MessageBounceOptions< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ children, onClose }: MessageBounceOptionsProps) { + const { handleDelete, handleEdit, handleRetry } = useMessageBounceContext( + 'MessageBounceOptions', + ); + const { t } = useTranslationContext('MessageBounceOptions'); + + 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..7014901c5 --- /dev/null +++ b/src/components/MessageBounce/index.ts @@ -0,0 +1,2 @@ +export * from './MessageBounceModal'; +export * from './MessageBounceOptions'; 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 a062c15e5..373970496 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -39,6 +39,7 @@ import type { } from '../components'; import type { LinkPreviewListProps } from '../components/MessageInput/LinkPreviewList'; import type { ReactionOptions } from '../components/Reactions/reactionOptions'; +import { MessageBounceOptionsProps } from '../components/MessageBounce'; export type ComponentContextValue< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, @@ -66,6 +67,7 @@ export type ComponentContextValue< Input?: React.ComponentType>; LinkPreviewList?: React.ComponentType; LoadingIndicator?: React.ComponentType; + MessageBounceOptions?: 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..3525104ee --- /dev/null +++ b/src/context/MessageBounceContext.tsx @@ -0,0 +1,70 @@ +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'; + +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 useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure this hook is called within the MessageInput's UI component. 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'); + 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/en.json b/src/i18n/en.json index 0d3c34550..43358bc5e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -50,6 +50,7 @@ "Search": "Search", "Searching...": "Searching...", "Send": "Send", + "Send Anyway": "Send Anyway", "Send message request failed": "Send message request failed", "Sending...": "Sending...", "Shuffle": "Shuffle", @@ -82,5 +83,6 @@ "{{ users }} and more are typing...": "{{ users }} and more are typing...", "{{ users }} and {{ user }} are typing...": "{{ users }} and {{ user }} are typing...", "{{ watcherCount }} online": "{{ watcherCount }} online", - "🏙 Attachment...": "🏙 Attachment..." + "🏙 Attachment...": "🏙 Attachment...", + "This message did not meet our content guidelines": "This message did not meet our content guidelines" }