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" && (
!
)}
);
}