/* eslint-disable react-hooks/refs -- pre-existing pattern flagged by the React Compiler rules added in eslint-plugin-react-hooks v7; refactoring it is out of scope for the lint toolchain upgrade. */ import React, { useCallback, useEffect, useRef, useState } from 'react' import type { Channel } from 'stream-chat' import { useMessaging } from '../../hooks/useMessaging' import type { MessagingShellProps } from '../../types' import { ChannelView } from '../ChannelView' import { ErrorState } from './ErrorState' import { LoadingState } from './LoadingState' /** * Direct-conversation surface for one specific participant. * * Renders a single ChannelView for the channel between the connected user and * `initialParticipantFilter`. If no channel exists yet and * `initialParticipantData` is supplied, the configured StreamChatService * channel creator is invoked to create one. */ export const MessagingShell: React.FC = ({ capabilities = {}, renderMessageInputActions, renderConversationFooter, onChannelSelect, onExitConversation, initialParticipantFilter, initialParticipantData, CustomChannelEmptyState, onBlockParticipantClick, onReportParticipantClick, dmAgentEnabled, onMessageSent, viewerLanguage, renderChannelBanner, customChannelActions, renderChannelActions, onParticipantNameClick, onOutboundClick, resolveOutbound, renderMessage, onMessageLinkClick, showChannelInfo, composerInput, attachmentPreviewList, }) => { const { client, isConnected, isLoading, error, refreshConnection, service, debug, } = useMessaging() const [selectedChannel, setSelectedChannel] = useState(null) const [directConversationError, setDirectConversationError] = useState< string | null >(null) const [didExit, setDidExit] = useState(false) const { showDeleteConversation = true } = capabilities // Stash consumer props that are unstable when passed inline (object // literals, arrow-function callbacks) so the load effect's deps stay // identity-stable. Without this, the documented usage pattern in the // README — passing `initialParticipantData={{ ... }}` and // `onChannelSelect={(ch) => ...}` — re-fires the effect on every render // and triggers a queryChannels call each time. const initialParticipantDataRef = useRef(initialParticipantData) initialParticipantDataRef.current = initialParticipantData const onChannelSelectRef = useRef(onChannelSelect) onChannelSelectRef.current = onChannelSelect // Track the direct-conversation load to prevent repeated/concurrent loads. // Identity-stable deps (above) stop the common re-fire, but legitimate dep // changes (e.g. `service`/`client` settling during connect) can still re-run // this effect before the just-created channel is indexed, each firing another // startChannelWithParticipant call and creating duplicate welcome messages. const directConversationLoadRef = useRef(null) // Mirror the currently-selected channel into a ref so the async load can read // the latest value rather than the (possibly stale) effect-closure value. const selectedChannelRef = useRef(null) useEffect(() => { selectedChannelRef.current = selectedChannel }, [selectedChannel]) useEffect(() => { if (!client || !isConnected) return const userId = client.userID if (!userId) return // Only load once per viewer ↔ participant pair. Set synchronously (before // the async work) so re-runs triggered by changing dependency identities // bail out instead of issuing another channel-create request. const loadKey = `${userId}::${initialParticipantFilter}` if (directConversationLoadRef.current === loadKey) return directConversationLoadRef.current = loadKey // Release the guard only if it still belongs to this load. A newer load for // a different pair may have taken ownership while this one was in flight, so // clearing unconditionally could wipe the newer guard and let a duplicate // load/create slip through. const releaseLoadGuard = () => { if (directConversationLoadRef.current === loadKey) { directConversationLoadRef.current = null } } const loadInitialChannel = async () => { try { if (debug) { console.log( '[MessagingShell] Loading initial conversation with:', initialParticipantFilter ) } const channels = await client.queryChannels( { type: 'messaging', members: { $eq: [userId, initialParticipantFilter] }, }, {}, { limit: 1 } ) if (channels.length > 0) { setSelectedChannel(channels[0]) setDirectConversationError(null) onChannelSelectRef.current?.(channels[0]) if (debug) { console.log( '[MessagingShell] Initial conversation loaded:', channels[0].id ) } return } const participantData = initialParticipantDataRef.current if (!participantData || !service) { // Allow a retry once participant data / service become available. releaseLoadGuard() setDirectConversationError('No conversation found with this account') if (debug) { console.log( '[MessagingShell] No conversation found for:', initialParticipantFilter ) } return } try { const channel = await service.startChannelWithParticipant({ id: participantData.id, name: participantData.name, phone: participantData.phone, }) setSelectedChannel(channel) setDirectConversationError(null) onChannelSelectRef.current?.(channel) if (debug) { console.log( '[MessagingShell] Channel created and loaded:', channel.id ) } } catch (createErr) { console.error( '[MessagingShell] Failed to create conversation:', createErr ) // Allow a retry for this pair after a transient failure. releaseLoadGuard() setDirectConversationError('Failed to create conversation') } } catch (err) { console.error( '[MessagingShell] Failed to load initial conversation:', err ) // Allow a retry for this pair after a transient failure. releaseLoadGuard() // Don't replace an already-loaded conversation with an error screen. // Read the latest selected channel via ref to avoid acting on a stale // closure value when the channel was selected mid-flight. if (!selectedChannelRef.current) { setDirectConversationError('Failed to load conversation') } } } void loadInitialChannel() }, [initialParticipantFilter, client, isConnected, service, debug]) // Leave / block clears the selected channel and notifies the consumer. // The consumer owns what happens next — typically unmounting MessagingShell // or re-rendering with new participant data. When no callback is provided, // the shell renders an "ended" state rather than an indefinite spinner so // the surface remains honest about why no conversation is shown. const onExitConversationRef = useRef(onExitConversation) onExitConversationRef.current = onExitConversation const handleExitConversation = useCallback(() => { setSelectedChannel(null) setDidExit(true) onExitConversationRef.current?.() }, []) if (isLoading) { return } if (error) { return } if (!isConnected || !client) { return ( ) } if (directConversationError) { return } if (didExit && !selectedChannel) { return } if (!selectedChannel) { return } return (
) }