import classNames from 'classnames' import React, { useContext, useMemo, useState } from 'react' import { Attachment as DefaultAttachment, EditMessageModal as DefaultEditMessageModal, MessageBounceModal, MessageBouncePrompt as DefaultMessageBouncePrompt, MessageBlocked as DefaultMessageBlocked, MessageDeleted as DefaultMessageDeleted, MessageErrorIcon, MessageIsThreadReplyInChannelButtonIndicator as DefaultMessageIsThreadReplyInChannelButtonIndicator, MessageRepliesCountButton as DefaultMessageRepliesCountButton, MessageText, Poll, ReminderNotification as DefaultReminderNotification, StreamedMessageText as DefaultStreamedMessageText, areMessageUIPropsEqual, isDateSeparatorMessage, isMessageBlocked, isMessageBounced, messageHasAttachments, messageHasReactions, useChannelStateContext, useComponentContext, useChatContext, useMessageContext, useMessageReminder, type MessageContextValue, type MessageUIComponentProps, } from 'stream-chat-react' import type { OutboundClickHandler, ResolvedOutbound } from '../../types' import { getMessageDisplayText } from '../../utils/getMessageDisplayText' import { Avatar } from '../Avatar' import { isLinkAttachment } from '../MediaMessage' import type { BubbleGroupPosition } from '../MessageAttachment/types' import { useCustomMessage } from './context' import LockedAttachment from './LockedAttachment' import { MessageTag, isAttachmentMessage, isChatbotMessage, isMediaAttachmentMessage, isTextAttachmentMessage, } from './MessageTag' import { MessageTail } from './MessageTail' import { OutboundContext } from './OutboundContext' import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus' import StreamAttachmentMessage, { buildOrderedAttachmentSegments, } from './StreamAttachmentMessage' import { TipMessage, isTipMessage } from './TipMessage' type CustomMessageUIComponentProps = MessageUIComponentProps & { viewerLanguage?: string onOutboundClick?: OutboundClickHandler } type CustomMessageWithContextProps = MessageContextValue & { viewerLanguage?: string onOutboundClick?: OutboundClickHandler resolvedOutbound?: ResolvedOutbound } const CustomMessageWithContext = (props: CustomMessageWithContextProps) => { const { additionalMessageInputProps, editing, endOfGroup, firstOfGroup, groupStyles, groupedByUser, handleAction, handleOpenThread, handleRetry, highlighted, isMessageAIGenerated, isMyMessage, message, renderText, threadList, viewerLanguage, onOutboundClick, resolvedOutbound, } = props const { client } = useChatContext('CustomMessage') const { channel } = useChannelStateContext('CustomMessage') const { isUnlocking, onUnlockClick, onFetchSource, onDownloadClick } = useCustomMessage('LockedAttachment') const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false) const reminder = useMessageReminder(message.id) const { Attachment = DefaultAttachment, EditMessageModal = DefaultEditMessageModal, MessageActions, MessageBlocked = DefaultMessageBlocked, MessageBouncePrompt = DefaultMessageBouncePrompt, MessageDeleted = DefaultMessageDeleted, MessageIsThreadReplyInChannelButtonIndicator = DefaultMessageIsThreadReplyInChannelButtonIndicator, MessageRepliesCountButton = DefaultMessageRepliesCountButton, ReminderNotification = DefaultReminderNotification, StreamedMessageText = DefaultStreamedMessageText, PinIndicator, } = useComponentContext('CustomMessage') const hasAttachment = messageHasAttachments(message) const hasReactions = messageHasReactions(message) const isAIGenerated = useMemo( () => isMessageAIGenerated?.(message), [isMessageAIGenerated, message] ) const finalAttachments = useMemo(() => { const attachments = message.attachments ?? [] const raw = message.shared_location ? [message.shared_location, ...attachments] : attachments if (!isChatbotMessage(message)) return raw const filtered = raw.filter((a) => !('type' in a) || !isLinkAttachment(a)) return filtered.length === raw.length ? raw : filtered }, [message]) const attachmentSegments = useMemo( () => buildOrderedAttachmentSegments(finalAttachments), [finalAttachments] ) const displayMessage = useMemo(() => { const displayText = getMessageDisplayText({ message, viewerLanguage }) return displayText === message.text ? message : { ...message, text: displayText } }, [message, viewerLanguage]) // Route every generic Stream-attachment message (link OG previews and // image/video/audio/pdf/file media) through the toolkit renderer, so // messaging-react owns all shared attachment rendering rather than leaving // media on stream-chat-react's default Attachment. App-specific types // (locked/paid, tips, chatbot) are handled by the branches above. const hasAttachmentSegments = attachmentSegments.length > 0 // Only own the message when every attachment maps to a representable segment. // Otherwise a mixed message (e.g. an unsupported shared_location alongside a // photo) would silently drop the unrepresented attachment — those fall // through to the default `Attachment` renderer, which handles all of them. const allAttachmentsRepresented = attachmentSegments.reduce( (total, segment) => total + segment.attachments.length, 0 ) === (finalAttachments?.length ?? 0) // Stream's MessageList sets the group-style string that drives the // `.str-chat__li--{single,top,middle,bottom}` class; the `firstOfGroup` / // `endOfGroup` / `groupedByUser` booleans it forwards are unset in this render // path (see `renderMessages`), so read the style directly. const groupStyle = groupStyles?.[0] ?? '' const bubbleGroupPosition: BubbleGroupPosition = groupStyle === 'top' ? 'first' : groupStyle === 'middle' ? 'middle' : groupStyle === 'bottom' ? 'end' : 'single' // The trailing bubble of a same-author run carries the tail — matches the // `.str-chat__li--single` / `--bottom` the old `::after` keyed off. const showTail = groupStyle === 'single' || groupStyle === 'bottom' if (isDateSeparatorMessage(message)) { return null } if (message.deleted_at || message.type === 'deleted') { return } if (isMessageBlocked(message)) { return } const showReplyCountButton = !threadList && !!message.reply_count const showIsReplyInChannel = !threadList && message.show_in_channel && message.parent_id const allowRetry = message.status === 'failed' && message.error?.status !== 403 const isBounced = isMessageBounced(message) let handleClick: (() => void) | undefined = undefined if (allowRetry) { handleClick = () => handleRetry(message) } else if (isBounced) { handleClick = () => setIsBounceDialogOpen(true) } const isMine = isMyMessage() const rootClassName = classNames( 'str-chat__message str-chat__message-simple', `str-chat__message--${message.type}`, `str-chat__message--${message.status}`, isMine ? 'str-chat__message--me str-chat__message-simple--me' : 'str-chat__message--other', message.text ? 'str-chat__message--has-text' : 'has-no-text', { 'str-chat__message--has-attachment': hasAttachment, 'str-chat__message--highlighted': highlighted, 'str-chat__message--pinned pinned-message': message.pinned, 'str-chat__message--with-reactions': hasReactions, 'str-chat__message-send-can-be-retried': message?.status === 'failed' && message?.error?.status !== 403, 'str-chat__message-with-thread-link': showReplyCountButton || showIsReplyInChannel, 'str-chat__virtual-message__wrapper--end': endOfGroup, 'str-chat__virtual-message__wrapper--first': firstOfGroup, 'str-chat__virtual-message__wrapper--group': groupedByUser, } ) const poll = message.poll_id && client.polls.fromState(message.poll_id) const isTip = isTipMessage(message) const isChatbot = isChatbotMessage(message) const isAttachment = isAttachmentMessage(message) const hasRenderableAttachments = !!( finalAttachments?.length && !message.quoted_message ) const useAttachmentFooterChatbotTag = isChatbot && isMine && hasRenderableAttachments // Route generic Stream attachments through the toolkit renderer only when it // can faithfully own the whole message. Quoted replies (attachments are // suppressed there), polls, AI-streamed, and chatbot messages fall through to // the default branch so their Poll / StreamedMessageText / message-text and // chatbot attribution (MessageTag) handling is preserved — the accompanying // message text and attribution must never be dropped. const canRenderAttachmentsInToolkit = hasAttachmentSegments && allAttachmentsRepresented && !message.quoted_message && !poll && !isAIGenerated && !isChatbot const sharedLockedTextProps = { contentType: 'text' as const, amountText: message.metadata?.amount_text, paymentStatus: message.metadata?.payment_status, renderedText: message.text && ( ), } const sharedLockedMediaProps = { contentType: 'media' as const, title: message.metadata?.attachment_title, mimeType: message.metadata?.attachment_mime_type, thumbnailUrl: message.metadata?.attachment_thumbnail, amountText: message.metadata?.amount_text, detail: message.metadata?.attachment_detail, paymentStatus: message.metadata?.payment_status, onFetchSource: onFetchSource ? () => onFetchSource(message, channel) : undefined, } // Shared by the tip and locked-media-attachment branches below: an // accompanying text bubble stacked under the pill / card. const accompanyingTextBubble = (
{showTail && }
) return ( <> {editing && ( )} {isBounceDialogOpen && ( setIsBounceDialogOpen(false)} open={isBounceDialogOpen} /> )}
{PinIndicator && } {!!reminder && } {message.user && ( )}
{isTip ? (
{message.text?.trim() && accompanyingTextBubble}
) : isTextAttachmentMessage(message) ? (
{isMine && MessageActions && } {isMine ? ( ) : ( onUnlockClick?.(message, channel)} /> )} {!isMine && MessageActions && }
) : isMediaAttachmentMessage(message) ? (
{isMine && MessageActions && } {isMine ? ( onUnlockClick ? onUnlockClick(message, channel) : undefined } /> ) : ( onUnlockClick ? onUnlockClick(message, channel) : undefined } onDownloadClick={() => onDownloadClick ? onDownloadClick(message, channel) : undefined } /> )} {!isMine && MessageActions && }
{message.text && accompanyingTextBubble}
) : canRenderAttachmentsInToolkit ? ( ) : (
{isChatbot && !useAttachmentFooterChatbotTag && ( )} {poll && } {finalAttachments?.length && !message.quoted_message ? ( ) : null} {isAIGenerated ? ( ) : ( )} {showTail && }
)}
{!isAttachment && !isTip && (
{(!isChatbot || useAttachmentFooterChatbotTag) && ( )}
)} {showReplyCountButton && ( )} {showIsReplyInChannel && ( )}
) } const MemoizedCustomMessage = React.memo( CustomMessageWithContext, (prev, next) => { if (prev.viewerLanguage !== next.viewerLanguage) return false if (prev.onOutboundClick !== next.onOutboundClick) return false if (prev.resolvedOutbound?.id !== next.resolvedOutbound?.id) return false if (prev.resolvedOutbound?.name !== next.resolvedOutbound?.name) return false return areMessageUIPropsEqual(prev, next) } ) as typeof CustomMessageWithContext export const CustomMessage = (props: CustomMessageUIComponentProps) => { const messageContext = useMessageContext('CustomMessage') const outboundsByRecordId = useContext(OutboundContext) const outboundRecordId = messageContext.message.metadata?.outbound_id const resolvedOutbound = typeof outboundRecordId === 'string' ? outboundsByRecordId?.[outboundRecordId] : undefined return ( ) }