import { useState, useCallback, useRef, useEffect, useMemo, createContext, useContext, } from "react"; import { BotData, ChatState } from "~/types/bot"; import { Message, ConversationStep } from "~/types/chat"; import { RepliedProduct } from "~/types/product"; import type { ChatContextType, ChatProviderProps, StateSnapshot, } from "./types"; import { setSentryUserContext, setSentryWidgetMetadata, addBreadcrumb, } from "~/utils/sentry"; import { WidgetType } from "~/utils/session-storage"; import { useSessionStorage } from "../hooks/useSessionStorage"; import { getProductId } from "~/utils/product-context"; import { getPageContext } from "~/utils/page-context"; import { InstallationSource } from "~/utils/installation-source"; import { scope } from "~/utils/sentry"; import { generateDynamicWelcomeMessage } from "~/utils/api"; // Extracted modules import { useScrollManagement } from "./hooks/useScrollManagement"; import { useRequestManagement } from "./hooks/useRequestManagement"; import { createInitializeBot, createAddWelcomeMessage, } from "./actions/bot-actions"; import { createSendMessage } from "./actions/message-actions"; import { createResetThreadId, createStartNewConversation, } from "./actions/conversation-actions"; // Re-export WidgetType for use in other components export { WidgetType } from "~/utils/session-storage"; export const ChatContext = createContext( undefined ); export function useChatContext(): ChatContextType { const context = useContext(ChatContext); if (context === undefined) { throw new Error("useChatContext must be used within a ChatProvider"); } return context; } const snapshotReplacer = (_key: string, value: unknown) => { if (value instanceof Date) { return value.toISOString(); } if (typeof value === "function") { return undefined; } return value; }; const buildSnapshotString = (snapshot: StateSnapshot) => JSON.stringify(snapshot, snapshotReplacer); export function ChatProvider({ children, initialWidgetType = WidgetType.FLOATING, installationSource = InstallationSource.DIRECT_SCRIPT, }: ChatProviderProps) { const [botData, setBotData] = useState(null); const [isLoadingBot, setIsLoadingBot] = useState(false); const [botError, setBotError] = useState(null); const [chatState, setChatState] = useState({ repliedProduct: null, threadId: null, isRequestInProgress: false, }); const [messages, setMessages] = useState([]); const [currentStep, setCurrentStep] = useState("welcome"); const [isInputDisabled, setIsInputDisabled] = useState(false); const [inputState, setInputState] = useState< "starting" | "generating" | "minimized" >("starting"); const [widgetType, setWidgetTypeState] = useState(initialWidgetType); const [shouldTrackSession, setShouldTrackSession] = useState(false); const [dynamicWelcomeMessage, setDynamicWelcomeMessage] = useState< string | null >(null); const [isLoadingWelcomeMessage, setIsLoadingWelcomeMessage] = useState(true); const [shouldInjectContext, setShouldInjectContext] = useState(false); const syncInProgressRef = useRef(false); const providerIdRef = useRef( `chat-provider-${Math.random().toString(36).slice(2)}` ); const lastSerializedStateRef = useRef(null); // Use extracted hooks const { messagesEndRef, setAutoScrollEnabled } = useScrollManagement( messages, widgetType ); const { currentAbortController, stopCurrentRequest } = useRequestManagement( setChatState, setIsInputDisabled, setInputState, setCurrentStep, setMessages ); useEffect(() => { setSentryWidgetMetadata( widgetType, installationSource, typeof window !== "undefined" ? (window as any).chatbotData?.pluginVersion : undefined ); addBreadcrumb( `Widget initialized as ${widgetType}`, "widget.initialization", "info", { widget_type: widgetType, installation_source: installationSource } ); }, [widgetType, installationSource]); // Fetch dynamic welcome message when botData is available // Use a ref to track if we've already fetched to avoid duplicate requests in Strict Mode const welcomeMessageFetchedRef = useRef(false); useEffect(() => { // Only run this feature if widget_test is true if (!botData?.widget_test) { setIsLoadingWelcomeMessage(false); return; } // BotData loaded but no business_description - no welcome message possible if (!botData.business_description) { setIsLoadingWelcomeMessage(false); return; } // Skip if we've already successfully fetched if (welcomeMessageFetchedRef.current) { return; } const abortController = new AbortController(); let isMounted = true; const fetchWelcomeMessage = async () => { setIsLoadingWelcomeMessage(true); const pageContext = getPageContext(); const lang = botData.language?.toLowerCase().includes("persian") || botData.language?.toLowerCase().includes("farsi") || botData.isRTL ? "fa" : "en"; addBreadcrumb( "Fetching dynamic welcome message", "welcome.fetch", "info", { page_type: pageContext.page_type, lang, } ); const message = await generateDynamicWelcomeMessage( { business_description: botData.business_description!, // We already checked it exists above h1: pageContext.h1, lang, page_link: pageContext.page_link, page_type: pageContext.page_type, }, abortController ); if (isMounted && !abortController.signal.aborted) { welcomeMessageFetchedRef.current = true; setDynamicWelcomeMessage(message); setIsLoadingWelcomeMessage(false); if (message) { addBreadcrumb( "Dynamic welcome message loaded", "welcome.loaded", "info", { message_length: message.length } ); } } }; fetchWelcomeMessage(); return () => { isMounted = false; abortController.abort(); }; }, [botData?.widget_test, botData?.business_description, botData?.language, botData?.isRTL]); useSessionStorage({ chatState, messages, currentStep, setChatState, setMessages, setCurrentStep, widgetType, }); useEffect(() => { if (!chatState.threadId && widgetType !== WidgetType.EMBEDDED) { setShouldTrackSession(true); } }, [chatState.threadId, widgetType]); const setRepliedProduct = useCallback( (repliedProduct: RepliedProduct | null) => { setChatState((prev) => ({ ...prev, repliedProduct })); }, [setChatState] ); const setWidgetType = useCallback((newWidgetType: WidgetType) => { setWidgetTypeState(newWidgetType); }, []); // Use extracted action creators const initializeBot = useMemo( () => createInitializeBot( setBotError, setIsLoadingBot, setBotData, setCurrentStep, widgetType, setChatState ), [ setBotError, setIsLoadingBot, setBotData, setCurrentStep, widgetType, setChatState, ] ); const addWelcomeMessage = useMemo( () => createAddWelcomeMessage(botData, dynamicWelcomeMessage, setMessages), [botData, dynamicWelcomeMessage, setMessages] ); const sendMessage = useMemo( () => createSendMessage( botData, isInputDisabled, chatState, widgetType, shouldTrackSession, currentAbortController, messages, setMessages, setCurrentStep, setIsInputDisabled, setInputState, setChatState, setShouldTrackSession, shouldInjectContext, setShouldInjectContext ), [ botData, isInputDisabled, chatState, widgetType, shouldTrackSession, currentAbortController, messages, setMessages, setCurrentStep, setIsInputDisabled, setInputState, setChatState, setShouldTrackSession, shouldInjectContext, setShouldInjectContext, ] ); const resetThreadId = useCallback( createResetThreadId(setChatState, widgetType), [setChatState, widgetType] ); const startNewConversation = useCallback( createStartNewConversation( currentAbortController, widgetType, setMessages, setCurrentStep, setIsInputDisabled, setInputState, setChatState, setShouldTrackSession ), [ widgetType, setMessages, setCurrentStep, setIsInputDisabled, setInputState, setChatState, setShouldTrackSession, ] ); useEffect(() => { const snapshotString = buildSnapshotString({ botData, chatState, messages, currentStep, }); if (syncInProgressRef.current) { lastSerializedStateRef.current = snapshotString; return; } if (snapshotString === lastSerializedStateRef.current) { return; } lastSerializedStateRef.current = snapshotString; const event = new CustomEvent("mori:chat-updated", { detail: { originId: providerIdRef.current }, }); window.dispatchEvent(event); }, [botData, chatState, messages, currentStep]); useEffect(() => { scope.setContext("conversation_state", { message_count: messages.length, current_step: currentStep, thread_id: chatState.threadId, has_error: botError !== null, is_request_in_progress: chatState.isRequestInProgress, }); if (widgetType === WidgetType.EMBEDDED) { const productId = getProductId(); if (productId) { scope.setContext("embedded_widget_context", { product_id: productId, replied_product: chatState.repliedProduct?.product.id || null, }); } } if (botData) { if (chatState.threadId) { setSentryUserContext(botData.sharing_id, chatState.threadId); } } }, [messages, currentStep, chatState, botData, botError, widgetType]); const value: ChatContextType = { botData, isLoadingBot, botError, chatState, messages, currentStep, isInputDisabled, inputState, widgetType, installationSource, dynamicWelcomeMessage, isLoadingWelcomeMessage, shouldInjectContext, setShouldInjectContext, initializeBot, sendMessage, stopCurrentRequest, setCurrentStep, setInputState, messagesEndRef, addWelcomeMessage, setRepliedProduct, setWidgetType, resetThreadId, startNewConversation, setAutoScrollEnabled: setAutoScrollEnabled, }; return {children}; }