import { memo } from "react" import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native" import { Ionicons } from "@expo/vector-icons" import { Markdown } from "../markdown" import { ToolCallCard } from "./ToolCallCard" import { ReasoningBlock } from "./ReasoningBlock" import type { Message, Part } from "../../lib/sdk" const SCREEN_WIDTH = Dimensions.get("window").width function isImageMime(mime?: string): boolean { return !!mime && mime.startsWith("image/") } interface Props { message: Message parts: Part[] isDark: boolean // Only wired up for user messages — long-press opens the "Edit message" / // revert action sheet. Identified by messageID (not a closure over parts) // so it stays correct even if the memo below bails on a stale render. onLongPress?: (messageID: string) => void } // TODO: Replace with streamdown-rn once React 19 types PR lands - it has // built-in block-level memoization that eliminates re-renders for stable blocks export const MessageBubble = memo( function MessageBubble({ message, parts, isDark, onLongPress }: Props) { const isUser = message.role === "user" const textParts = parts.filter((p) => p.type === "text") const reasoningParts = parts.filter((p) => p.type === "reasoning") const toolParts = parts.filter((p) => p.type === "tool") const fileParts = parts.filter((p) => p.type === "file" && isImageMime(p.mime)) const text = textParts.map((p) => p.text).join("\n") || "" const reasoning = reasoningParts.map((p) => p.text).join("\n") || "" return ( onLongPress(message.id) : undefined} disabled={!isUser || !onLongPress} style={[ s.bubble, isUser ? s.user : s.assistant, isUser && isDark && s.userDark, !isUser && isDark && s.assistantDark, ]} testID={`chat-bubble-${message.role}`} > {/* Role indicator */} {isUser ? "You" : "Assistant"} {message.model && {message.model.modelID}} {!isUser && message.modelID && {message.modelID}} {/* Image attachments */} {fileParts.length > 0 && ( {fileParts.map((fp) => ( {fp.filename && ( {fp.filename} )} ))} )} {/* Reasoning (collapsible) */} {reasoning.length > 0 && } {/* Message text */} {text.length > 0 && (isUser ? ( {text} ) : ( {text} ))} {/* Tool calls */} {toolParts.map((tool) => ( ))} {/* Tokens/cost for assistant messages */} {!isUser && message.tokens && ( {message.tokens.input + message.tokens.output} tokens {message.cost ? ` · $${message.cost.toFixed(4)}` : ""} )} ) }, (prev, next) => { // Only re-render if message content actually changed // This prevents completed messages from re-rendering during streaming. // The store replaces changed parts/messages with NEW object references, // so a reference-equality sweep over every part catches every real change // (including tool parts, which have no `.text`) while still skipping // unchanged (completed) messages during other messages' streaming. if (prev.message !== next.message) return false if (prev.isDark !== next.isDark) return false if (prev.onLongPress !== next.onLongPress) return false if (prev.parts.length !== next.parts.length) return false for (let i = 0; i < prev.parts.length; i++) { if (prev.parts[i] !== next.parts[i]) return false } return true }, ) const s = StyleSheet.create({ bubble: { marginBottom: 16, padding: 12, borderRadius: 12, maxWidth: "100%" }, user: { backgroundColor: "#f5f5f5", marginLeft: 32 }, userDark: { backgroundColor: "#1a1a1a" }, assistant: { backgroundColor: "#f0f0ff" }, assistantDark: { backgroundColor: "#1a1a2e" }, header: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 }, role: { fontSize: 13, fontWeight: "600", color: "#666666" }, roleUser: { color: "#0a0a0a" }, textWhite: { color: "#ffffff" }, modelTag: { fontSize: 11, color: "#999999", backgroundColor: "#e5e5e5", paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: "hidden", }, modelTagDark: { backgroundColor: "#2a2a2a", color: "#888888" }, messageText: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" }, markdownWrap: { marginHorizontal: -4 }, tokens: { fontSize: 11, color: "#999999", marginTop: 8 }, tokensDark: { color: "#666666" }, // Images imageScroll: { marginBottom: 8 }, imageRow: { gap: 8 }, imageWrap: { alignItems: "center" }, attachedImage: { width: Math.min(200, SCREEN_WIDTH * 0.5), height: Math.min(200, SCREEN_WIDTH * 0.5), borderRadius: 8, backgroundColor: "#e5e5e5", }, imageLabel: { fontSize: 10, color: "#666666", marginTop: 2, maxWidth: 200 }, imageLabelDark: { color: "#888888" }, })