react-native-ajora
Version:
The most complete AI agent UI for React Native
310 lines (309 loc) • 14.3 kB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View, TouchableOpacity, Text, Platform, FlatList, ScrollView, ActivityIndicator, } from "react-native";
import Color from "../Color";
import Animated, { runOnJS, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated";
import Item from "./components/Item";
import { LoadEarlier } from "../LoadEarlier";
import ThinkingIndicator from "../ThinkingIndicator";
import { error, warning } from "../logging";
import stylesCommon from "../styles";
import styles from "./styles";
import { isSameDay } from "../utils";
import { MaterialIcons } from "@expo/vector-icons";
import { useChatContext } from "../AjoraContext";
export * from "./types";
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
function MessageContainer(props) {
const { renderChatEmpty: renderChatEmptyProp, onLoadEarlier, loadEarlier = false, listViewProps, invertibleScrollViewProps, extraData = null, isScrollToBottomEnabled = false, scrollToBottomOffset = 200, alignTop = false, scrollToBottomStyle, infiniteScroll = false, isLoadingEarlier = false, renderThinkingIndicator: renderThinkingIndicatorProp, renderFooter: renderFooterProp, renderLoadEarlier: renderLoadEarlierProp, forwardRef, handleOnScroll: handleOnScrollProp, scrollToBottomComponent: scrollToBottomComponentProp, renderSuggestedQuestions, onSend, } = props;
// Ajora context
const { ajora } = useChatContext();
const { activeThreadId, messagesByThread, submitQuery, isLoadingMessages, isThinking, } = ajora;
// Role
const scrollToBottomOpacity = useSharedValue(0);
const [isScrollToBottomVisible, setIsScrollToBottomVisible] = useState(false);
const scrollToBottomStyleAnim = useAnimatedStyle(() => ({
opacity: scrollToBottomOpacity.value,
}), [scrollToBottomOpacity]);
const daysPositions = useSharedValue({});
const listHeight = useSharedValue(0);
const scrolledY = useSharedValue(0);
const renderThinkingIndicator = useCallback(() => {
if (renderThinkingIndicatorProp)
return renderThinkingIndicatorProp();
return <ThinkingIndicator isThinking={isThinking}/>;
}, [isThinking, renderThinkingIndicatorProp]);
const ListFooterComponent = useMemo(() => {
if (renderFooterProp)
return renderFooterProp(props);
return renderThinkingIndicator();
}, [renderFooterProp, renderThinkingIndicator, props]);
const renderLoadEarlier = useCallback(() => {
if (loadEarlier) {
if (renderLoadEarlierProp)
return renderLoadEarlierProp(props);
return <LoadEarlier {...props}/>;
}
return null;
}, [loadEarlier, renderLoadEarlierProp, props]);
const scrollTo = useCallback((options = {
animated: true,
offset: 0,
}) => {
if (forwardRef?.current && options)
forwardRef.current.scrollToOffset(options);
}, [forwardRef]);
// const doScrollToBottom = useCallback(
// (animated: boolean = true) => {
// scrollTo({ offset: 0, animated });
// },
// [scrollTo]
// );
const handleOnScroll = useCallback((event) => {
handleOnScrollProp?.(event);
const { contentOffset: { y: contentOffsetY }, contentSize: { height: contentSizeHeight }, layoutMeasurement: { height: layoutMeasurementHeight }, } = event;
const duration = 250;
const makeScrollToBottomVisible = () => {
setIsScrollToBottomVisible(true);
scrollToBottomOpacity.value = withTiming(1, { duration });
};
const makeScrollToBottomHidden = () => {
scrollToBottomOpacity.value = withTiming(0, { duration }, (isFinished) => {
if (isFinished)
runOnJS(setIsScrollToBottomVisible)(false);
});
};
if (contentOffsetY > scrollToBottomOffset)
makeScrollToBottomVisible();
else
makeScrollToBottomHidden();
}, [handleOnScrollProp, scrollToBottomOffset, scrollToBottomOpacity]);
const renderItem = useCallback(({ item, index, }) => {
const messageItem = item;
if (!messageItem._id)
error("Ajora: `_id` is missing for message", JSON.stringify(item));
if (!messageItem.role) {
error("Ajora: `role` is missing for message", JSON.stringify(messageItem));
}
const { ...restProps } = props;
if (messagesByThread) {
const previousMessage = messagesByThread[index + 1] || {};
const nextMessage = messagesByThread[index - 1] || {};
const messageProps = {
...restProps,
currentMessage: messageItem,
previousMessage: previousMessage,
nextMessage: nextMessage,
position: messageItem.role === "user" ? "right" : "left",
scrolledY,
daysPositions,
listHeight,
};
return <Item {...messageProps}/>;
}
return (<View style={styles.emptyChatContainer}>
<View style={styles.emptyChatContainerInverted}>
<MaterialIcons name="chat-bubble-outline" size={70} color={Color.gray500} style={styles.emptyChatIcon}/>
<Text style={styles.emptyChatTitle}>
No messages found in this thread
</Text>
</View>
</View>);
}, [props, scrolledY, daysPositions, listHeight]);
const renderChatEmpty = useCallback(() => {
if (isLoadingMessages) {
return (<View style={[styles.emptyChatContainer, styles.emptyChatContainerInverted]}>
<View style={styles.emptyChatContent}>
<ActivityIndicator size="small" color={Color.gray500}/>
<Text style={[styles.emptyChatSubtitle, { marginTop: 8 }]}>
Loading messages…
</Text>
</View>
</View>);
}
if (renderSuggestedQuestions)
return renderSuggestedQuestions();
const suggestedQuestions = [
{
title: "Get started",
question: "Where is Ajora Falls located?",
icon: "water",
},
{
title: "Philosophy",
question: "What is the chief end of life?",
icon: "public",
},
{
title: "Democracy",
question: "Famous quote from the Declaration of Independence",
icon: "history",
},
{
title: "Science",
question: "How is the speed of light measured?",
icon: "science",
},
];
const handleQuestionPress = (questionItem) => {
const newMessage = {
_id: Math.round(Math.random() * 1000000).toString(),
role: "user",
thread_id: activeThreadId || "",
parts: [{ text: questionItem.question }],
createdAt: new Date().toISOString(),
};
if (onSend) {
onSend([newMessage], true);
}
else {
warning("[MessageContainer]: `onSend` is not provided");
}
};
if (renderChatEmptyProp)
return (<View style={[styles.emptyChatContainer, styles.emptyChatContainerInverted]}>
{renderChatEmptyProp()}
</View>);
return (<View style={[styles.emptyChatContainer, styles.emptyChatContainerInverted]}>
<View style={styles.emptyChatContent}>
<MaterialIcons name="chat-bubble-outline" size={70} color={Color.gray500} style={styles.emptyChatIcon}/>
<Text style={styles.emptyChatTitle}>Welcome to Ajora!</Text>
<Text style={styles.emptyChatSubtitle}>
Start a conversation by typing a message or select a topic below
</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{suggestedQuestions.map((questionItem, index) => (<TouchableOpacity key={index} style={styles.suggestedQuestionCard} onPress={() => handleQuestionPress(questionItem)} activeOpacity={0.7}>
{questionItem.icon ? (<MaterialIcons name={questionItem.icon} size={24} color={Color.gray500} style={{ marginBottom: 6 }}/>) : null}
<Text style={styles.suggestedQuestionTitle}>
{questionItem.title}
</Text>
<Text style={styles.suggestedQuestionText}>
{questionItem.question}
</Text>
</TouchableOpacity>))}
</ScrollView>
</View>);
}, [
activeThreadId,
submitQuery,
onSend,
renderChatEmptyProp,
messagesByThread,
isLoadingMessages,
]);
const ListHeaderComponent = useMemo(() => {
const content = renderLoadEarlier();
if (!content)
return null;
return <View style={stylesCommon.fill}>{content}</View>;
}, [renderLoadEarlier]);
const renderScrollBottomComponent = useCallback(() => {
if (scrollToBottomComponentProp)
return scrollToBottomComponentProp();
return (<MaterialIcons name="arrow-downward" size={24} style={styles.scrollToBottomIcon} color={Color.gray500}/>);
}, [scrollToBottomComponentProp]);
const renderScrollToBottomWrapper = useCallback(() => {
if (!isScrollToBottomVisible)
return null;
return (<TouchableOpacity onPress={() => scrollTo()}>
<Animated.View style={[
stylesCommon.centerItems,
styles.scrollToBottomStyle,
scrollToBottomStyle,
scrollToBottomStyleAnim,
]}>
{renderScrollBottomComponent()}
</Animated.View>
</TouchableOpacity>);
}, [
scrollToBottomStyle,
renderScrollBottomComponent,
scrollTo,
scrollToBottomStyleAnim,
isScrollToBottomVisible,
]);
const onLayoutList = useCallback((event) => {
listHeight.value = event.nativeEvent.layout.height;
// Always scroll to bottom when messages are loaded
if (activeThreadId)
setTimeout(() => {
scrollTo({ animated: false, offset: 0 });
}, 500);
listViewProps?.onLayout?.(event);
}, [activeThreadId, scrollTo, listHeight, listViewProps]);
const onEndReached = useCallback(() => {
if (infiniteScroll &&
loadEarlier &&
onLoadEarlier &&
!isLoadingEarlier &&
Platform.OS !== "web")
onLoadEarlier();
}, [infiniteScroll, loadEarlier, onLoadEarlier, isLoadingEarlier]);
const keyExtractor = useCallback((item, _index) => item._id.toString(), []);
const renderCell = useCallback((props) => {
const handleOnLayout = (event) => {
props.onLayout?.(event);
const { y, height } = event.nativeEvent.layout;
const newValue = {
y,
height,
createdAt: new Date(props.item.createdAt || new Date()).getTime(),
};
daysPositions.modify((value) => {
"worklet";
const isSameDay = (date1, date2) => {
const d1 = new Date(date1);
const d2 = new Date(date2);
return (d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear());
};
for (const [key, item] of Object.entries(value))
if (isSameDay(newValue.createdAt, item.createdAt) &&
item.y <= newValue.y) {
delete value[key];
break;
}
// @ts-expect-error: https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue#remarks
value[props.item._id] = newValue;
return value;
});
};
return (<View {...props} onLayout={handleOnLayout}>
{props.children}
</View>);
}, [daysPositions]);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrolledY.value = event.contentOffset.y;
runOnJS(handleOnScroll)(event);
},
}, [handleOnScroll]);
// removes unrendered days positions when messages are added/removed
useEffect(() => {
Object.keys(daysPositions.value).forEach((key) => {
const messageIndex = messagesByThread.findIndex((m) => m._id.toString() === key);
let shouldRemove = messageIndex === -1;
if (!shouldRemove) {
const prevMessage = messagesByThread[messageIndex + 1];
const message = messagesByThread[messageIndex];
shouldRemove = !!prevMessage && isSameDay(message, prevMessage);
}
if (shouldRemove)
daysPositions.modify((value) => {
"worklet";
delete value[key];
return value;
});
});
}, [messagesByThread, daysPositions]);
return (<View style={[
styles.contentContainerStyle,
alignTop ? styles.containerAlignTop : stylesCommon.fill,
]}>
<AnimatedFlatList extraData={extraData} ref={forwardRef} keyExtractor={keyExtractor} data={messagesByThread} renderItem={renderItem} inverted={true} automaticallyAdjustContentInsets={false} style={stylesCommon.fill} {...invertibleScrollViewProps} ListEmptyComponent={renderChatEmpty} ListFooterComponent={ListHeaderComponent} ListHeaderComponent={ListFooterComponent} onScroll={scrollHandler} scrollEventThrottle={1} onEndReached={onEndReached} onEndReachedThreshold={0.1} {...listViewProps} onLayout={onLayoutList} CellRendererComponent={renderCell}/>
{isScrollToBottomEnabled ? renderScrollToBottomWrapper() : null}
</View>);
}
export default MessageContainer;
//# sourceMappingURL=index.js.map