| .. | .. |
|---|
| 1 | | -import React, { useEffect, useRef } from "react"; |
|---|
| 1 | +import React, { useCallback, useEffect, useRef } from "react"; |
|---|
| 2 | 2 | import { FlatList, View } from "react-native"; |
|---|
| 3 | 3 | import { Message } from "../../types"; |
|---|
| 4 | 4 | import { MessageBubble } from "./MessageBubble"; |
|---|
| 5 | +import { TypingIndicator } from "./TypingIndicator"; |
|---|
| 6 | +import { stopPlayback, playAudio } from "../../services/audio"; |
|---|
| 5 | 7 | |
|---|
| 6 | 8 | interface MessageListProps { |
|---|
| 7 | 9 | messages: Message[]; |
|---|
| 10 | + isTyping?: boolean; |
|---|
| 11 | + onDeleteMessage?: (id: string) => void; |
|---|
| 8 | 12 | } |
|---|
| 9 | 13 | |
|---|
| 10 | | -export function MessageList({ messages }: MessageListProps) { |
|---|
| 14 | +export function MessageList({ messages, isTyping, onDeleteMessage }: MessageListProps) { |
|---|
| 11 | 15 | const listRef = useRef<FlatList<Message>>(null); |
|---|
| 12 | 16 | |
|---|
| 13 | 17 | useEffect(() => { |
|---|
| 14 | 18 | if (messages.length > 0) { |
|---|
| 15 | | - // Small delay to allow layout to complete |
|---|
| 16 | 19 | setTimeout(() => { |
|---|
| 17 | 20 | listRef.current?.scrollToEnd({ animated: true }); |
|---|
| 18 | 21 | }, 50); |
|---|
| 19 | 22 | } |
|---|
| 20 | | - }, [messages.length]); |
|---|
| 23 | + }, [messages.length, isTyping]); |
|---|
| 24 | + |
|---|
| 25 | + // Play from a voice message and auto-chain all consecutive assistant voice messages after it |
|---|
| 26 | + const handlePlayVoice = useCallback(async (messageId: string) => { |
|---|
| 27 | + const idx = messages.findIndex((m) => m.id === messageId); |
|---|
| 28 | + if (idx === -1) return; |
|---|
| 29 | + |
|---|
| 30 | + // Collect this message + all consecutive assistant voice messages after it |
|---|
| 31 | + const chain: Message[] = []; |
|---|
| 32 | + for (let i = idx; i < messages.length; i++) { |
|---|
| 33 | + const m = messages[i]; |
|---|
| 34 | + if (m.role === "assistant" && m.type === "voice" && m.audioUri) { |
|---|
| 35 | + chain.push(m); |
|---|
| 36 | + } else if (i > idx) { |
|---|
| 37 | + // Stop at the first non-voice or non-assistant message |
|---|
| 38 | + break; |
|---|
| 39 | + } |
|---|
| 40 | + } |
|---|
| 41 | + |
|---|
| 42 | + if (chain.length === 0) return; |
|---|
| 43 | + |
|---|
| 44 | + // Stop current playback, then queue all chunks |
|---|
| 45 | + await stopPlayback(); |
|---|
| 46 | + for (const m of chain) { |
|---|
| 47 | + playAudio(m.audioUri!); |
|---|
| 48 | + } |
|---|
| 49 | + }, [messages]); |
|---|
| 21 | 50 | |
|---|
| 22 | 51 | return ( |
|---|
| 23 | 52 | <FlatList |
|---|
| 24 | 53 | ref={listRef} |
|---|
| 25 | 54 | data={messages} |
|---|
| 26 | 55 | keyExtractor={(item) => item.id} |
|---|
| 27 | | - renderItem={({ item }) => <MessageBubble message={item} />} |
|---|
| 56 | + renderItem={({ item }) => ( |
|---|
| 57 | + <MessageBubble |
|---|
| 58 | + message={item} |
|---|
| 59 | + onDelete={onDeleteMessage} |
|---|
| 60 | + onPlayVoice={handlePlayVoice} |
|---|
| 61 | + /> |
|---|
| 62 | + )} |
|---|
| 28 | 63 | contentContainerStyle={{ paddingVertical: 12 }} |
|---|
| 29 | 64 | onContentSizeChange={() => { |
|---|
| 30 | 65 | listRef.current?.scrollToEnd({ animated: false }); |
|---|
| 31 | 66 | }} |
|---|
| 32 | 67 | showsVerticalScrollIndicator={false} |
|---|
| 33 | | - ListFooterComponent={<View style={{ height: 4 }} />} |
|---|
| 68 | + ListFooterComponent={ |
|---|
| 69 | + <> |
|---|
| 70 | + {isTyping && <TypingIndicator />} |
|---|
| 71 | + <View style={{ height: 4 }} /> |
|---|
| 72 | + </> |
|---|
| 73 | + } |
|---|
| 34 | 74 | /> |
|---|
| 35 | 75 | ); |
|---|
| 36 | 76 | } |
|---|