| .. | .. |
|---|
| 1 | | -import React, { useCallback, useEffect, useRef } from "react"; |
|---|
| 2 | | -import { FlatList, View } from "react-native"; |
|---|
| 1 | +import React, { useCallback, useMemo } from "react"; |
|---|
| 2 | +import { ActivityIndicator, FlatList, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | 4 | import { MessageBubble } from "./MessageBubble"; |
|---|
| 5 | 5 | import { TypingIndicator } from "./TypingIndicator"; |
|---|
| .. | .. |
|---|
| 9 | 9 | messages: Message[]; |
|---|
| 10 | 10 | isTyping?: boolean; |
|---|
| 11 | 11 | onDeleteMessage?: (id: string) => void; |
|---|
| 12 | + onLoadMore?: () => void; |
|---|
| 13 | + hasMore?: boolean; |
|---|
| 12 | 14 | } |
|---|
| 13 | 15 | |
|---|
| 14 | | -export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) { |
|---|
| 15 | | - const listRef = useRef<FlatList<Message>>(null); |
|---|
| 16 | | - const prevLengthRef = useRef(0); |
|---|
| 17 | | - |
|---|
| 18 | | - // Track the last message's content so transcript reflections trigger a scroll |
|---|
| 19 | | - const lastContent = messages.length > 0 ? messages[messages.length - 1].content : ""; |
|---|
| 20 | | - |
|---|
| 21 | | - // Flag: when true, every content size change triggers a scroll to bottom. |
|---|
| 22 | | - // Used for bulk loads (restart, session switch) where FlatList renders lazily. |
|---|
| 23 | | - const bulkScrollRef = useRef(false); |
|---|
| 24 | | - |
|---|
| 25 | | - useEffect(() => { |
|---|
| 26 | | - if (messages.length > 0) { |
|---|
| 27 | | - const delta = Math.abs(messages.length - prevLengthRef.current); |
|---|
| 28 | | - if (delta > 1) { |
|---|
| 29 | | - // Bulk load — let onContentSizeChange handle scrolling |
|---|
| 30 | | - bulkScrollRef.current = true; |
|---|
| 31 | | - setTimeout(() => { bulkScrollRef.current = false; }, 3000); |
|---|
| 32 | | - } else { |
|---|
| 33 | | - // Single new message — smooth scroll |
|---|
| 34 | | - setTimeout(() => { |
|---|
| 35 | | - listRef.current?.scrollToEnd({ animated: true }); |
|---|
| 36 | | - }, 50); |
|---|
| 37 | | - } |
|---|
| 38 | | - } |
|---|
| 39 | | - prevLengthRef.current = messages.length; |
|---|
| 40 | | - }, [messages.length, isTyping, lastContent]); |
|---|
| 41 | | - |
|---|
| 42 | | - const handleContentSizeChange = useCallback(() => { |
|---|
| 43 | | - if (bulkScrollRef.current) { |
|---|
| 44 | | - listRef.current?.scrollToEnd({ animated: false }); |
|---|
| 45 | | - } |
|---|
| 46 | | - }, []); |
|---|
| 16 | +export function MessageList({ messages, isTyping, onDeleteMessage, onLoadMore, hasMore }: MessageListProps) { |
|---|
| 17 | + // Inverted FlatList renders bottom-up — newest messages at the bottom (visually), |
|---|
| 18 | + // which means we reverse the data so index 0 = newest = rendered at bottom. |
|---|
| 19 | + const invertedData = useMemo(() => [...messages].reverse(), [messages]); |
|---|
| 47 | 20 | |
|---|
| 48 | 21 | // Play from a voice message and auto-chain all consecutive assistant voice messages after it |
|---|
| 49 | 22 | const handlePlayVoice = useCallback(async (messageId: string) => { |
|---|
| 50 | 23 | const idx = messages.findIndex((m) => m.id === messageId); |
|---|
| 51 | 24 | if (idx === -1) return; |
|---|
| 52 | 25 | |
|---|
| 53 | | - // Collect this message + all consecutive assistant voice messages after it |
|---|
| 54 | 26 | const chain: Message[] = []; |
|---|
| 55 | 27 | for (let i = idx; i < messages.length; i++) { |
|---|
| 56 | 28 | const m = messages[i]; |
|---|
| 57 | 29 | if (m.role === "assistant" && m.type === "voice" && m.audioUri) { |
|---|
| 58 | 30 | chain.push(m); |
|---|
| 59 | 31 | } else if (i > idx) { |
|---|
| 60 | | - // Stop at the first non-voice or non-assistant message |
|---|
| 61 | 32 | break; |
|---|
| 62 | 33 | } |
|---|
| 63 | 34 | } |
|---|
| 64 | 35 | |
|---|
| 65 | 36 | if (chain.length === 0) return; |
|---|
| 66 | 37 | |
|---|
| 67 | | - // Stop current playback, then queue all chunks |
|---|
| 68 | 38 | await stopPlayback(); |
|---|
| 69 | 39 | for (const m of chain) { |
|---|
| 70 | 40 | playAudio(m.audioUri!); |
|---|
| .. | .. |
|---|
| 73 | 43 | |
|---|
| 74 | 44 | return ( |
|---|
| 75 | 45 | <FlatList |
|---|
| 76 | | - ref={listRef} |
|---|
| 77 | | - data={messages} |
|---|
| 46 | + inverted |
|---|
| 47 | + data={invertedData} |
|---|
| 78 | 48 | keyExtractor={(item) => item.id} |
|---|
| 79 | 49 | renderItem={({ item }) => ( |
|---|
| 80 | 50 | <MessageBubble |
|---|
| .. | .. |
|---|
| 83 | 53 | onPlayVoice={handlePlayVoice} |
|---|
| 84 | 54 | /> |
|---|
| 85 | 55 | )} |
|---|
| 86 | | - onContentSizeChange={handleContentSizeChange} |
|---|
| 56 | + onEndReached={hasMore ? onLoadMore : undefined} |
|---|
| 57 | + onEndReachedThreshold={0.5} |
|---|
| 87 | 58 | contentContainerStyle={{ paddingVertical: 12 }} |
|---|
| 88 | 59 | showsVerticalScrollIndicator={false} |
|---|
| 89 | | - ListFooterComponent={ |
|---|
| 60 | + ListHeaderComponent={ |
|---|
| 90 | 61 | <> |
|---|
| 91 | 62 | {isTyping && <TypingIndicator />} |
|---|
| 92 | 63 | <View style={{ height: 4 }} /> |
|---|
| 93 | 64 | </> |
|---|
| 94 | 65 | } |
|---|
| 66 | + ListFooterComponent={ |
|---|
| 67 | + hasMore ? ( |
|---|
| 68 | + <View style={{ paddingVertical: 16, alignItems: "center" }}> |
|---|
| 69 | + <ActivityIndicator size="small" /> |
|---|
| 70 | + </View> |
|---|
| 71 | + ) : null |
|---|
| 72 | + } |
|---|
| 95 | 73 | /> |
|---|
| 96 | 74 | ); |
|---|
| 97 | 75 | } |
|---|