-
Notifications
You must be signed in to change notification settings - Fork 365
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View,
RefreshControl,
ActivityIndicator,
TouchableOpacity,
ViewToken,
} from 'react-native';
import { createStyleSheet, useStyles } from 'react-native-unistyles';
import { MessageItemD, MessageType } from '../../../../types/conversations/message';
import MessageBox from './MessageBox';
import DateSeparator from './components/DateSeparator';
import { formatMessageDate } from './utils/dateUtils';
import { deleteMessageApi } from '../../../../network/api/conversations';
import useProjectStore from '../../../../store/projectStore';
import { useAuth } from '../../../../context/AuthContext';
import useMessagesStore from '../../../../store/messagesStore';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { isToday } from 'date-fns';
import { Typography } from '../../../../components/v2';
import FeedbackItem from './components/message-types/FeedbackItem';
import { ArrowDown } from 'lucide-react-native';
import ParticipantAdded from './components/(message_types)/ParticipantAdded';
import Resolved from './components/(message_types)/Resolved';
import IntentDetected from './components/(message_types)/IntentDetected';
import SystemFunction from './components/(message_types)/SystemFunction';
import ApiCall from './components/(message_types)/ApiCall';
import AiAction from './components/(message_types)/AiAction';
import CustomCode from './components/(message_types)/CustomCode';
import KnowledgeBase from './components/(message_types)/KnowledgeBase';
import useChatStore from '../../../../store/chatStore';
import { FlashList,FlashListRef } from '@shopify/flash-list';
interface Props {
messages: MessageItemD[];
onLoadMore: () => void;
sessionUid: string;
onMessageLongPress?: (message: MessageItemD) => void;
}
export default function MessageList({
messages,
onLoadMore,
sessionUid,
onMessageLongPress,
}: Props) {
const { styles, theme } = useStyles(stylesheet);
const { token } = useAuth();
const { activeProjectUId } = useProjectStore();
const { deleteMessage, loading, unReadMessagesCount, updateUnReadMessagesCount } = useMessagesStore();
const { getLiveTranslation } = useChatStore();
const transltationState = getLiveTranslation(sessionUid)
const [currentDate, setCurrentDate] = useState<string | null>(null);
const dateIndicatorOpacity = useSharedValue(0);
const textOpacity = useSharedValue(1);
const flashListRef = useRef<FlashListRef>(null);
const scrollToBottomButtonOpacity = useSharedValue(0);
const [isAtBottom, setIsAtBottom] = useState(true); // Start at bottom since newest messages are at the top
// Scroll to bottom function
const scrollToBottom = useCallback(() => {
if (flashListRef.current && messages.length > 0) {
try {
// Scroll to the last index (newest message in reversed array)
flashListRef.current.scrollToEnd({ animated: true });
// Reset unseen messages count when scrolling to bottom
updateUnReadMessagesCount(0);
setIsAtBottom(true);
} catch (error) {
console.warn('Failed to scroll to bottom:', error);
}
}
}, [messages.length, updateUnReadMessagesCount]);
// Handle scroll events to detect when user is at bottom
const handleScrollEnd = useCallback((event: any) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
// Check if we're at the bottom (newest messages)
const isAtBottomNow = contentOffset.y + layoutMeasurement.height >= contentSize.height - 50; // 50px threshold
setIsAtBottom(isAtBottomNow);
if (isAtBottomNow) {
// User has reached the bottom, reset unseen messages count
updateUnReadMessagesCount(0);
}
}, [updateUnReadMessagesCount]);
// Update button visibility based on unseen messages count and scroll position
useEffect(() => {
if (unReadMessagesCount >= 1 && !isAtBottom) {
scrollToBottomButtonOpacity.value = withSpring(1);
} else {
scrollToBottomButtonOpacity.value = withSpring(0);
}
}, [unReadMessagesCount, isAtBottom, scrollToBottomButtonOpacity]);
// Reset unseen messages count when user is at bottom and new messages arrive
useEffect(() => {
if (isAtBottom && unReadMessagesCount > 0) {
updateUnReadMessagesCount(0);
}
}, [isAtBottom, unReadMessagesCount, updateUnReadMessagesCount]);
const handleDeleteMessage = useCallback(
async (messageId: string) => {
console.log('messageId', messageId, activeProjectUId, sessionUid);
if (!token || !activeProjectUId || !sessionUid) return;
try {
await deleteMessageApi({
token,
project_uid: activeProjectUId,
session_uid: sessionUid,
message_id: messageId,
});
// Remove the message from the store
deleteMessage({
sessionUid,
messageId: Number(messageId),
});
} catch (error) {
console.error('Failed to delete message:', error);
}
},
[token, activeProjectUId, sessionUid, deleteMessage],
);
// Handle viewable items changed
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (viewableItems.length > 0) {
const topMessage = viewableItems[viewableItems.length - 1].item as MessageItemD;
const messageDate = new Date(topMessage.createdAt || Date.now());
const dateStr = formatMessageDate(messageDate);
// Only show date indicator if it's not today
if (!isToday(messageDate)) {
if (dateStr !== currentDate) {
// Fade out current text, update date, fade in new text
textOpacity.value = withSequence(
withTiming(0, { duration: 150 }),
withTiming(1, { duration: 150 }),
);
}
setCurrentDate(dateStr);
dateIndicatorOpacity.value = withSpring(1);
} else {
dateIndicatorOpacity.value = withSpring(0);
setCurrentDate(null);
}
} else {
dateIndicatorOpacity.value = withSpring(0);
}
},
[dateIndicatorOpacity, textOpacity, currentDate],
);
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
minimumViewTime: 100,
}).current;
const MESSAGE_TYPE_COMPONENTS: Partial<Record<MessageType, React.ComponentType<{ message: MessageItemD }>>> = {
[MessageType.intent_detected]: IntentDetected,
[MessageType.added_participants]: ParticipantAdded,
[MessageType.mark_resolved]: Resolved,
// Tool actions
[MessageType.system_function]: SystemFunction,
[MessageType.api_call]: ApiCall,
[MessageType.ai_action]: AiAction,
[MessageType.custom_code]: CustomCode,
[MessageType.knowledgebase]: KnowledgeBase,
} as const;
// Create reversed messages array for consistent indexing
const reversedMessages = [...messages].reverse();
const renderMessage = useCallback(
({ item: message, index }: { item: MessageItemD; index: number }) => {
// Show avatar for last message in a group OR when next message has different sender
const showAvatar =
index === reversedMessages.length - 1 || (reversedMessages[index + 1]?.send_by !== message.send_by);
// Show date for first message OR when current message date differs from previous
const showDate =
index === 0 ||
formatMessageDate(new Date(message.createdAt || new Date())) !==
formatMessageDate(
new Date(reversedMessages[index - 1]?.createdAt || new Date()),
);
if (message.content_type === 'feedback') {
return (
<FeedbackItem message={message} />
)
}
if (message.type && message.type in MESSAGE_TYPE_COMPONENTS) {
const MessageComponent = MESSAGE_TYPE_COMPONENTS[message.type as MessageType];
if (MessageComponent) {
return <MessageComponent message={message} />
}
}
return (
<View style={{ flexDirection: showDate ? 'column' : 'column-reverse' }}>
{showDate && (
<DateSeparator
date={formatMessageDate(
new Date(message.createdAt || new Date()),
)}
/>
)}
<TouchableOpacity
onLongPress={() => onMessageLongPress?.(message)}
activeOpacity={0.8}>
<MessageBox
message={message}
showAvatar={showAvatar}
deleteMessage={handleDeleteMessage}
sessionUid={sessionUid}
/>
</TouchableOpacity>
</View>
);
},
[messages, handleDeleteMessage, onMessageLongPress, sessionUid],
);
const getUniqueKey = useCallback((item: MessageItemD, index: number) => {
let baseKey = '';
// If we have a localId, use it with a prefix
if (item.localId !== undefined && item.localId !== null) {
baseKey = `local_${item.localId}`;
}
// If we have message_id, use it
else if (item.message_id !== undefined && item.message_id !== null) {
baseKey = `msg_${item.message_id}`;
}
// If we have id, use it
else if (item.id !== undefined && item.id !== null) {
baseKey = `id_${item.id}`;
}
// Fallback to timestamp
else {
const timestamp = item.createdAt
? new Date(item.createdAt).getTime()
: Date.now();
baseKey = `time_${timestamp}`;
}
// Always append the index to ensure uniqueness during fast scrolling
return `${baseKey}_idx_${index}`;
}, []);
// const keyExtractor = useCallback((item: MessageItemD) => item.message_id?.toString() || item.id?.toString() || item.createdAt?.toString() || Date.now().toString(), []);
const maintainVisibleContentPositionConfig = useMemo(
() => ({
// autoscrollToBottomThreshold: 0.1,
// startRenderingFromBottom: true,
}),
[]
);
// Animated style for the floating date indicator
const dateIndicatorStyle = useAnimatedStyle(() => ({
opacity: dateIndicatorOpacity.value,
transform: [{ scale: dateIndicatorOpacity.value }],
}));
const dateTextStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));
// Animated style for the scroll to bottom button
const scrollToBottomButtonStyle = useAnimatedStyle(() => ({
opacity: scrollToBottomButtonOpacity.value,
transform: [{ scale: scrollToBottomButtonOpacity.value }],
}));
return (
{!transltationState?.show && <Animated.View style={[styles.dateIndicator, dateIndicatorStyle]}>
<Animated.View style={dateTextStyle}>
{currentDate}
</Animated.View>
</Animated.View>
}
{/* Scroll to bottom button */}
<Animated.View style={[styles.scrollToBottomButton, scrollToBottomButtonStyle]}>
<TouchableOpacity
style={styles.scrollToBottomButtonContent}
onPress={scrollToBottom}
activeOpacity={0.8}
>
<View style={styles.scrollToBottomButtonInner}>
<ArrowDown size={16} color={theme.colors.surface} />
<View style={styles.messageCountBadge}>
<Typography variant="captionMedium" style={styles.messageCountText}>
{unReadMessagesCount}
</Typography>
</View>
</View>
</TouchableOpacity>
</Animated.View>
<FlashList
ref={flashListRef}
style={styles.list}
keyboardShouldPersistTaps="handled"
data={reversedMessages}
renderItem={renderMessage}
keyExtractor={getUniqueKey}
contentContainerStyle={styles.contentContainer}
onStartReached={onLoadMore}
initialScrollIndex={reversedMessages.length - 1}
initialScrollIndexParams={{ viewOffset: 10000 }}
onMomentumScrollEnd={handleScrollEnd}
onScrollEndDrag={handleScrollEnd}
ListEmptyComponent={() => (
<View style={styles.emptyContainer}>
{loading ? <ActivityIndicator size="small" color={theme.colors.primary} /> : <Typography variant='body1' color='textSecondary'>No messages found</Typography>}
</View>
)}
maintainVisibleContentPosition={maintainVisibleContentPositionConfig}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
/>
</View>
);
}
const stylesheet = createStyleSheet(theme => ({
container: {
flex: 1,
width: '100%',
},
list: {
flex: 1,
},
contentContainer: {
paddingVertical: theme.spacing.md,
flexGrow: 1,
},
loadingContainer: {
paddingVertical: theme.spacing.md,
alignItems: 'center',
},
dateIndicator: {
position: 'absolute',
top: 16,
alignSelf: 'center',
backgroundColor: theme.colors.surface,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
zIndex: 100,
shadowColor: theme.colors.textSecondary,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
borderWidth: 1,
borderColor: theme.colors.border,
},
dateIndicatorText: {
color: theme.colors.textPrimary,
fontSize: 13,
fontWeight: '600',
},
scrollToBottomButton: {
position: 'absolute',
bottom: 20,
right: 20,
zIndex: 100,
},
scrollToBottomButtonContent: {
// backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.full,
shadowColor: theme.colors.textSecondary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 5,
elevation: 8,
},
scrollToBottomButtonInner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor:theme.colors.primary,
borderRadius: theme.borderRadius.full,
paddingHorizontal: 12,
paddingVertical: 8,
// gap: 8,
},
messageCountBadge: {
position: 'absolute',
right: -4,
top: -4,
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.full,
minWidth: 20,
height: 20,
paddingHorizontal: 6,
justifyContent: 'center',
alignItems: 'center',
},
messageCountText: {
color: theme.colors.primary,
fontSize: 11,
fontWeight: '700',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight:'100%',
}
}));
Here is my ChatMessagesList component, i am having scrolling issue here as i recently upgraded to v2, i had to remove 'inverted' prop and reverse the messages array
Beta Was this translation helpful? Give feedback.