UNPKG

react-native-ajora

Version:

The most complete AI agent UI for React Native

352 lines 14.3 kB
import React, { createRef, useEffect, useMemo, useRef, useState, useCallback, } from "react"; import { ActionSheetProvider, } from "@expo/react-native-action-sheet"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { Platform, View } from "react-native"; import { Actions } from "../Actions"; import Bubble from "../Bubble"; import { Composer } from "../Composer"; import { MAX_COMPOSER_HEIGHT, MIN_COMPOSER_HEIGHT, TEST_ID } from "../Constant"; import { AjoraContext, useChatContext } from "../AjoraContext"; import { Header } from "../Header"; import { LoadEarlier } from "../LoadEarlier"; import Message from "../Message"; import MessageContainer from "../MessageContainer"; import { MessageImage } from "../MessageImage"; import { MessageText } from "../MessageText"; import { Thread } from "../Thread"; import { Send } from "../Send"; import * as utils from "../utils"; import Animated, { useAnimatedStyle, useAnimatedReaction, useSharedValue, withTiming, runOnJS, } from "react-native-reanimated"; import { KeyboardProvider, useReanimatedKeyboardAnimation, } from "react-native-keyboard-controller"; import stylesCommon from "../styles"; import styles from "./styles"; import { InputToolbar } from "../InputToolbar"; dayjs.extend(localizedFormat); function Ajora(props) { const { initialText = "", onSend, locale = "en", renderLoading, actionSheet = null, textInputProps, renderChatFooter = null, renderInputToolbar = null, bottomOffset = 0, focusOnInputWhenOpeningKeyboard = true, keyboardShouldPersistTaps = Platform.select({ ios: "never", android: "always", default: "never", }), onInputTextChanged = null, maxInputLength = null, minComposerHeight = MIN_COMPOSER_HEIGHT, maxComposerHeight = MAX_COMPOSER_HEIGHT, isKeyboardInternallyHandled = true, // Header and Thread props showHeader = true, headerProps = {}, showThreads = true, threadProps = {}, onThreadSelect, onNewThread, onHeaderLeftPress, onHeaderRightPress, onPressActionButton, } = props; const { ajora } = useChatContext(); const { activeThreadId, threads = [], addNewThread, switchThread, submitQuery, clearAttachement, } = ajora; const currentThread = threads.find((thread) => thread.id === activeThreadId); const actionSheetRef = useRef(null); const messageContainerRef = useMemo(() => props.messageContainerRef || createRef(), [props.messageContainerRef]); const textInputRef = useMemo(() => props.textInputRef || createRef(), [props.textInputRef]); const isTextInputWasFocused = useRef(false); const [isInitialized, setIsInitialized] = useState(false); const [composerHeight, setComposerHeight] = useState(minComposerHeight); const [text, setText] = useState(() => props.text || ""); const [isThinkingDisabled, setIsThinkingDisabled] = useState(false); const [isThreadDrawerOpen, setIsThreadDrawerOpen] = useState(false); const keyboard = useReanimatedKeyboardAnimation(); const trackingKeyboardMovement = useSharedValue(false); const debounceEnableThinkingTimeoutId = useRef(undefined); const keyboardOffsetBottom = useSharedValue(0); const contentStyleAnim = useAnimatedStyle(() => ({ transform: [ { translateY: keyboard.height.value - keyboardOffsetBottom.value }, ], }), [keyboard, keyboardOffsetBottom]); const getTextFromProp = useCallback((fallback) => { if (props.text === undefined) return fallback; return props.text; }, [props.text]); /** * Store text input focus status when keyboard hide to retrieve * it afterwards if needed. * `onKeyboardWillHide` may be called twice in sequence so we * make a guard condition (eg. showing image picker) */ const handleTextInputFocusWhenKeyboardHide = useCallback(() => { if (!isTextInputWasFocused.current) isTextInputWasFocused.current = textInputRef.current?.isFocused?.() || false; }, [textInputRef]); /** * Refocus the text input only if it was focused before showing keyboard. * This is needed in some cases (eg. showing image picker). */ const handleTextInputFocusWhenKeyboardShow = useCallback(() => { if (textInputRef.current && isTextInputWasFocused.current && !textInputRef.current.isFocused()) textInputRef.current.focus(); // Reset the indicator since the keyboard is shown isTextInputWasFocused.current = false; }, [textInputRef]); const disableThinking = useCallback(() => { clearTimeout(debounceEnableThinkingTimeoutId.current); setIsThinkingDisabled(true); }, []); const enableThinking = useCallback(() => { clearTimeout(debounceEnableThinkingTimeoutId.current); setIsThinkingDisabled(false); }, []); const debounceEnableThinking = useCallback(() => { clearTimeout(debounceEnableThinkingTimeoutId.current); debounceEnableThinkingTimeoutId.current = setTimeout(() => { enableThinking(); }, 50); }, [enableThinking]); const scrollToBottom = useCallback((isAnimated = true) => { if (!messageContainerRef?.current) return; messageContainerRef.current.scrollToOffset({ offset: 0, animated: isAnimated, }); return; }, [messageContainerRef]); // Header and Thread handlers const handleHeaderLeftPress = useCallback(() => { if (showThreads) { setIsThreadDrawerOpen(true); } if (onHeaderLeftPress) { onHeaderLeftPress(); } }, [showThreads, onHeaderLeftPress]); const handleHeaderRightPress = useCallback(() => { if (onHeaderRightPress) { onHeaderRightPress(); } else { addNewThread(); } }, [onHeaderRightPress, addNewThread]); const handleThreadSelect = useCallback((thread) => { setIsThreadDrawerOpen(false); if (onThreadSelect) { onThreadSelect(thread); } else { switchThread(thread.id); } }, [onThreadSelect, switchThread]); const handleNewThread = useCallback(() => { setIsThreadDrawerOpen(false); if (onNewThread) { onNewThread(); } else { addNewThread(); } }, [onNewThread, addNewThread]); const notifyInputTextReset = useCallback(() => { onInputTextChanged?.(""); }, [onInputTextChanged]); const resetInputToolbar = useCallback(() => { textInputRef.current?.clear(); notifyInputTextReset(); clearAttachement(); setComposerHeight(minComposerHeight); setText(getTextFromProp("")); enableThinking(); }, [ minComposerHeight, getTextFromProp, textInputRef, notifyInputTextReset, enableThinking, clearAttachement, ]); const _onSend = useCallback((messages = [], shouldResetInputToolbar = false) => { if (shouldResetInputToolbar === true) { disableThinking(); resetInputToolbar(); } // Normalize messages to array const messagesArray = Array.isArray(messages) ? messages : [messages]; // Send the message to the server if (messagesArray.length > 0) { submitQuery({ type: "text", message: messagesArray[0], }); } onSend?.(messagesArray); setTimeout(() => scrollToBottom(), 10); }, [ onSend, resetInputToolbar, disableThinking, scrollToBottom, submitQuery, activeThreadId, ]); const renderMessages = useMemo(() => { if (!isInitialized) return null; const { messagesContainerStyle, ...messagesContainerProps } = props; return (<View style={[stylesCommon.fill, messagesContainerStyle]}> <MessageContainer {...messagesContainerProps} invertibleScrollViewProps={{ keyboardShouldPersistTaps, }} forwardRef={messageContainerRef} onSend={_onSend}/> {renderChatFooter?.()} </View>); }, [ isInitialized, ajora.isThinking, props, keyboardShouldPersistTaps, messageContainerRef, renderChatFooter, showHeader, currentThread, handleHeaderLeftPress, handleHeaderRightPress, headerProps, _onSend, ]); const onInputSizeChanged = useCallback((size) => { const newComposerHeight = Math.max(minComposerHeight, Math.min(maxComposerHeight, size.height)); setComposerHeight(newComposerHeight); }, [maxComposerHeight, minComposerHeight]); const _onInputTextChanged = useCallback((_text) => { if (isThinkingDisabled) return; onInputTextChanged?.(_text); // Only set state if it's not being overridden by a prop. if (props.text === undefined) setText(_text); }, [onInputTextChanged, isThinkingDisabled, props.text]); const onInitialLayoutViewLayout = useCallback((e) => { if (isInitialized) return; const { layout } = e.nativeEvent; if (layout.height <= 0) return; notifyInputTextReset(); setIsInitialized(true); setComposerHeight(minComposerHeight); setText(getTextFromProp(initialText)); }, [ isInitialized, initialText, minComposerHeight, notifyInputTextReset, getTextFromProp, ]); const inputToolbarFragment = useMemo(() => { if (!isInitialized) return null; const inputToolbarProps = { ...props, text: getTextFromProp(text), composerHeight: Math.max(minComposerHeight, composerHeight), onSend: _onSend, onInputSizeChanged, onTextChanged: _onInputTextChanged, textInputProps: { ...textInputProps, ref: textInputRef, maxLength: isThinkingDisabled ? 0 : maxInputLength, }, }; if (renderInputToolbar) return renderInputToolbar(inputToolbarProps); return <InputToolbar {...inputToolbarProps}/>; }, [ isInitialized, _onSend, getTextFromProp, maxInputLength, minComposerHeight, onInputSizeChanged, props, text, renderInputToolbar, composerHeight, isThinkingDisabled, textInputRef, textInputProps, _onInputTextChanged, ]); const contextValues = useMemo(() => ({ actionSheet: actionSheet || (() => ({ showActionSheetWithOptions: actionSheetRef.current.showActionSheetWithOptions, })), getLocale: () => locale, ajora, }), [actionSheet, locale, ajora]); useEffect(() => { if (props.text != null) setText(props.text); }, [props.text]); useAnimatedReaction(() => -keyboard.height.value, (value, prevValue) => { if (prevValue !== null && value !== prevValue) { const isKeyboardMovingUp = value > prevValue; if (isKeyboardMovingUp !== trackingKeyboardMovement.value) { trackingKeyboardMovement.value = isKeyboardMovingUp; keyboardOffsetBottom.value = withTiming(isKeyboardMovingUp ? bottomOffset : 0, { // If `bottomOffset` exists, we change the duration to a smaller value to fix the delay in the keyboard animation speed duration: bottomOffset ? 150 : 400, }); if (focusOnInputWhenOpeningKeyboard) if (isKeyboardMovingUp) runOnJS(handleTextInputFocusWhenKeyboardShow)(); else runOnJS(handleTextInputFocusWhenKeyboardHide)(); if (value === 0) { runOnJS(enableThinking)(); } else { runOnJS(disableThinking)(); runOnJS(debounceEnableThinking)(); } } } }, [ keyboard, trackingKeyboardMovement, focusOnInputWhenOpeningKeyboard, handleTextInputFocusWhenKeyboardHide, handleTextInputFocusWhenKeyboardShow, enableThinking, disableThinking, debounceEnableThinking, bottomOffset, ]); return (<AjoraContext.Provider value={contextValues}> <ActionSheetProvider ref={actionSheetRef}> <View style={stylesCommon.fill}> {showHeader && (<Header onLeftPress={handleHeaderLeftPress} onRightPress={handleHeaderRightPress} {...headerProps}/>)} <View testID={TEST_ID.WRAPPER} style={[stylesCommon.fill, styles.contentContainer]} onLayout={onInitialLayoutViewLayout}> {isInitialized ? (<Animated.View style={[ stylesCommon.fill, isKeyboardInternallyHandled && contentStyleAnim, ]}> {renderMessages} {inputToolbarFragment} </Animated.View>) : (renderLoading?.())} </View> {showThreads && (<Thread isOpen={isThreadDrawerOpen} onClose={() => setIsThreadDrawerOpen(false)} onThreadSelect={handleThreadSelect} onNewThread={handleNewThread} {...threadProps}/>)} </View> </ActionSheetProvider> </AjoraContext.Provider>); } function AjoraWrapper(props) { return (<KeyboardProvider> <Ajora {...props}/> </KeyboardProvider>); } AjoraWrapper.append = (currentMessages = [], messages) => { if (!Array.isArray(messages)) messages = [messages]; return messages.concat(currentMessages); }; AjoraWrapper.prepend = (currentMessages = [], messages) => { if (!Array.isArray(messages)) messages = [messages]; return currentMessages.concat(messages); }; export * from "../types"; export { AjoraWrapper as Ajora, Actions, Bubble, MessageImage, MessageText, Composer, InputToolbar, LoadEarlier, Message, MessageContainer, Send, utils, }; //# sourceMappingURL=index.js.map