import React, { useCallback, useEffect, useState } from "react"; import { ActionSheetIOS, Alert, Image, Platform, Pressable, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { Message } from "../../types"; import { playSingle, stopPlayback, onPlayingChange } from "../../services/audio"; import { ImageViewer } from "./ImageViewer"; import { useTheme } from "../../contexts/ThemeContext"; interface MessageBubbleProps { message: Message; onDelete?: (id: string) => void; onPlayVoice?: (id: string) => void; } function formatDuration(ms?: number): string | null { if (!ms || ms <= 0) return null; const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } function formatTime(timestamp: number): string { const d = new Date(timestamp); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } export function MessageBubble({ message, onDelete, onPlayVoice }: MessageBubbleProps) { const [isPlaying, setIsPlaying] = useState(false); const [showViewer, setShowViewer] = useState(false); const { colors, isDark } = useTheme(); const handleLongPress = useCallback(() => { const hasText = !!message.content; if (Platform.OS === "ios") { const options = ["Cancel"]; if (hasText) options.push("Copy"); if (onDelete) options.push("Delete Message"); const destructiveIndex = onDelete ? options.indexOf("Delete Message") : undefined; ActionSheetIOS.showActionSheetWithOptions( { options, destructiveButtonIndex: destructiveIndex, cancelButtonIndex: 0, }, (index) => { const selected = options[index]; if (selected === "Copy") Clipboard.setStringAsync(message.content ?? ""); else if (selected === "Delete Message") onDelete?.(message.id); }, ); } else { const buttons: any[] = [{ text: "Cancel", style: "cancel" }]; if (hasText) buttons.push({ text: "Copy", onPress: () => Clipboard.setStringAsync(message.content ?? "") }); if (onDelete) buttons.push({ text: "Delete", style: "destructive", onPress: () => onDelete(message.id) }); Alert.alert("Message", undefined, buttons); } }, [onDelete, message.id, message.content]); // Track whether THIS bubble's audio is playing via the singleton URI useEffect(() => { return onPlayingChange((uri) => { setIsPlaying(uri !== null && uri === message.audioUri); }); }, [message.audioUri]); const isUser = message.role === "user"; const isSystem = message.role === "system"; const handleVoicePress = useCallback(async () => { if (!message.audioUri) return; if (isPlaying) { await stopPlayback(); } else if (onPlayVoice) { // Let parent handle chain playback (plays this + subsequent chunks) onPlayVoice(message.id); } else { await playSingle(message.audioUri, () => {}); } }, [isPlaying, message.audioUri, onPlayVoice, message.id]); if (isSystem) { return ( {message.content} ); } const bubbleBg = isUser ? colors.accent : isDark ? "#252538" : colors.bgSecondary; const bubbleRadius = isUser ? { borderTopRightRadius: 4 } : { borderTopLeftRadius: 4 }; return ( {message.type === "image" && message.imageBase64 ? ( setShowViewer(true)}> {message.content ? ( {message.content} ) : null} setShowViewer(false)} /> ) : message.type === "voice" ? ( {isPlaying ? "\u23F8" : "\u25B6"} {Array.from({ length: 20 }).map((_, i) => ( ))} {formatDuration(message.duration) && ( {formatDuration(message.duration)} )} {message.content ? ( {message.content} ) : null} ) : ( {message.content} )} {formatTime(message.timestamp)} {isUser && message.status === "error" && ( ! )} ); }