import { isOfficialChannel } from '@linktr.ee/messaging-taxonomy' import classNames from 'classnames' import React, { useCallback, useEffect, useRef } from 'react' import { Channel as ChannelType } from 'stream-chat' import { Channel, Window, MessageList, useMessageContext, useChannelStateContext, WithComponents, MessageUIComponentProps, } from 'stream-chat-react' import type { ChannelViewProps } from '../types' import ChannelHeader from './ChannelHeader' import { CustomDateSeparator } from './CustomDateSeparator' import { CustomMessage } from './CustomMessage' import { CustomMessageActions } from './CustomMessage/CustomMessageActions' import { OutboundContext, useOutboundsByRecordId, } from './CustomMessage/OutboundContext' import { CustomMessageInput } from './CustomMessageInput' import { CustomSystemMessage } from './CustomSystemMessage' import CustomTypingIndicator from './CustomTypingIndicator' import { DmAgentEnabledContext } from './CustomTypingIndicator/DmAgentContext' import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState' import { LoadingState } from './MessagingShell/LoadingState' /** * Inner component that has access to channel context */ const ChannelViewInner: React.FC<{ onBack?: () => void showBackButton: boolean renderMessageInputActions?: (channel: ChannelType) => React.ReactNode renderMessageInputFooter?: (channel: ChannelType) => React.ReactNode renderConversationFooter?: (channel: ChannelType) => React.ReactNode onLeaveConversation?: (channel: ChannelType) => void onBlockParticipant?: (participantId?: string) => void CustomChannelEmptyState?: React.ComponentType showDeleteConversation?: boolean onDeleteConversationClick?: () => void onBlockParticipantClick?: () => void onReportParticipantClick?: () => void showBlockParticipant?: boolean showReportParticipant?: boolean composerDisabled?: boolean composerDisabledReason?: string showStarButton?: boolean renderChannelBanner?: () => React.ReactNode customChannelActions?: React.ReactNode renderChannelActions?: React.ReactNode renderMessage?: ( messageNode: React.ReactElement, message: NonNullable ) => React.ReactNode viewerLanguage?: string showChannelInfo?: boolean onParticipantNameClick?: () => void onOutboundClick?: ChannelViewProps['onOutboundClick'] resolveOutbound?: ChannelViewProps['resolveOutbound'] composerInput?: ChannelViewProps['composerInput'] }> = ({ onBack, showBackButton, renderMessageInputActions, renderMessageInputFooter, renderConversationFooter, onLeaveConversation, onBlockParticipant, showDeleteConversation = true, onDeleteConversationClick, onBlockParticipantClick, onReportParticipantClick, showBlockParticipant = true, showReportParticipant = true, composerDisabled = false, composerDisabledReason, showStarButton = false, renderChannelBanner, customChannelActions, renderChannelActions, renderMessage, viewerLanguage, showChannelInfo = true, onParticipantNameClick, onOutboundClick, resolveOutbound, composerInput, }) => { const { channel, messages } = useChannelStateContext() const onOutboundClickRef = useRef(onOutboundClick) useEffect(() => { onOutboundClickRef.current = onOutboundClick }, [onOutboundClick]) const stableOnOutboundClick = useCallback< NonNullable >((outbound) => { onOutboundClickRef.current?.(outbound) }, []) const messageOutboundClick = onOutboundClick ? stableOnOutboundClick : undefined // Resolve outside memoized Stream rows so current automation details publish // without remounting the rows. const outboundsByRecordId = useOutboundsByRecordId(messages, resolveOutbound) const hasVisibleMessages = channel.state.messages.some( (message) => message.type !== 'deleted' ) const channelBanner = renderChannelBanner ? ( {renderChannelBanner()} ) : null const channelHeader = ( ) // Prevents all message instances from unmounting when ChannelViewInner re-renders const MessageOverride = useCallback( (props: MessageUIComponentProps) => { // eslint-disable-next-line react-hooks/rules-of-hooks const { message } = useMessageContext('ChannelView') const messageNode = ( ) if (!renderMessage || !message) { return messageNode } return renderMessage(messageNode, message) }, [messageOutboundClick, renderMessage, viewerLanguage] ) return ( null, UnreadMessagesSeparator: () => null, }} > {composerInput ? (
{/* Message List */}
{/* Stream only renders MessageList.head when there are messages, so empty channels need the header and banner in normal flow. */} {!hasVisibleMessages && (
{channelHeader} {channelBanner}
)} {channelHeader} {channelBanner}
) : undefined } hideDeletedMessages hideNewMessageSeparator={false} />
{/* Message Input — conversation footer is rendered inside the floating chrome (via renderHeader) so it stays above the absolute composer and remains interactive. */} renderMessageInputActions?.(channel), })} {...(renderConversationFooter && { renderHeader: () => renderConversationFooter(channel), })} renderFooter={() => renderMessageInputFooter?.(channel)} disabled={composerDisabled} disabledReason={composerDisabledReason} composerInput={composerInput} /> ) : ( <> {/* Message List */}
{/* Stream only renders MessageList.head when there are messages, so empty channels need the header and banner in normal flow. */} {!hasVisibleMessages && (
{channelHeader} {channelBanner}
)} {channelHeader} {channelBanner}
) : undefined } hideDeletedMessages hideNewMessageSeparator={false} /> {renderConversationFooter ? ( {renderConversationFooter(channel)} ) : null} {/* Message Input */} renderMessageInputActions?.(channel), })} renderFooter={() => renderMessageInputFooter?.(channel)} disabled={composerDisabled} disabledReason={composerDisabledReason} composerInput={composerInput} /> )}
) } /** * Channel view component with message list and input */ export const ChannelView = React.memo( ({ channel, onBack, showBackButton = false, renderMessageInputActions, renderMessageInputFooter, renderConversationFooter, onLeaveConversation, onBlockParticipant, className, CustomChannelEmptyState = ChannelEmptyState, showDeleteConversation = true, onDeleteConversationClick, onBlockParticipantClick, onReportParticipantClick, showBlockParticipant = true, showReportParticipant = true, composerDisabled = false, composerDisabledReason, dmAgentEnabled, messageMetadata, onMessageSent, showStarButton = false, renderChannelBanner, customChannelActions, renderChannelActions, renderMessage, onMessageLinkClick, sendButton, attachmentPreviewList, viewerLanguage, showChannelInfo = true, onParticipantNameClick, onOutboundClick, resolveOutbound, composerInput, }) => { // Custom send message handler that: // 1. Applies messageMetadata if provided // 2. Adds skip_push and silent when DM agent is active // 3. Calls onMessageSent callback with full response // Read chatbot_paused inside callback to get current value at send time (not stale closure) const doSendMessageRequest = useCallback( async ( _channel: ChannelType, message: Parameters[0], options?: Parameters[1] ) => { const agentPaused = channel.data?.chatbot_paused === true const shouldSuppressNotifications = dmAgentEnabled && !agentPaused // Build final message with nested metadata const finalMessage = { ...message, ...(shouldSuppressNotifications && { silent: true }), ...(messageMetadata && { metadata: { ...(message.metadata ?? {}), ...messageMetadata, }, }), } // Build final options const finalOptions = { ...options, ...(shouldSuppressNotifications && { skip_push: true }), } const response = await channel.sendMessage(finalMessage, finalOptions) // Fire callback with full response (includes message.id) onMessageSent?.(response) return response }, [channel, dmAgentEnabled, messageMetadata, onMessageSent] ) const containerRef = useRef(null) useEffect(() => { if (!onMessageLinkClick) return const container = containerRef.current if (!container) return const handler = (event: MouseEvent) => { const target = event.target as HTMLElement | null const anchor = target?.closest('a[href]') const href = anchor?.getAttribute('href') if (!href) return const messageWrapper = anchor?.closest('[data-message-id]') const messageId = messageWrapper?.getAttribute('data-message-id') const message = messageId ? channel.state.messages.find((m) => m.id === messageId) : undefined onMessageLinkClick(href, message) } container.addEventListener('click', handler) return () => container.removeEventListener('click', handler) }, [onMessageLinkClick, channel]) // The official channel is read-only (no composer), so the design gives its content a // wider column. Publish the width as a CSS var on the channel root; every card/bubble // inside reads `w-[var(--lt-msg-card-width,…)]` and widens. Other channels leave the var // unset and keep their default widths. const messageCardWidthStyle = isOfficialChannel(channel.type) ? ({ '--lt-msg-card-width': '368px' } as React.CSSProperties) : undefined return (
) } ) ChannelView.displayName = 'ChannelView'