Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Unwanted Scrolling Behaviour in FlashList #1981

Unanswered
Vishal-D4 asked this question in Q&A
Discussion options

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

You must be logged in to vote

Replies: 0 comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
Q&A
Labels
None yet
1 participant

AltStyle によって変換されたページ (->オリジナル) /