Files
LocalMind/components/chat/MessageBubble.tsx
evandro 2a294f1f87 refactor(chat): split MessageBubble into avatar, content and code block components
MessageBubble CC 19 -> below 10. The fence and indented code block rules were identical and are now one CodeBlock component; the Markdown style map lives in markdownStyles.ts. Adds characterization tests for the bubble (roles, actions visibility, edited badge, copy feedback).
2026-10-10 04:20:59 -03:00

163 lines
4.8 KiB
TypeScript

import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import { Bot, User } from 'lucide-react-native';
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { MessageContent } from './MessageContent';
interface MessageBubbleProps {
message: ChatMessage;
onEdit?: () => void;
onRegenerate?: () => void;
onDelete?: () => void;
isStreaming?: boolean;
isLastAssistant?: boolean;
}
function Avatar({ isUser }: { isUser: boolean }) {
const colors = useThemeColor();
const Icon = isUser ? User : Bot;
return (
<View style={[styles.avatar, { backgroundColor: isUser ? colors.tint : colors.border }]}>
<Icon size={16} color={isUser ? colors.backgroundColor : colors.text} />
</View>
);
}
function BubbleRow({ message, showingStreaming }: { message: ChatMessage; showingStreaming: boolean }) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isUser = message.role === 'user';
const maxWidth = width > 768 ? '70%' : '85%';
return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
{!isUser && <Avatar isUser={false} />}
<View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
<MessageContent message={message} showingStreaming={showingStreaming} />
</View>
{isUser && <Avatar isUser />}
</View>
);
}
/** Timestamp + edited badge. */
function BubbleMeta({ message }: { message: ChatMessage }) {
const colors = useThemeColor();
const isUser = message.role === 'user';
return (
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
);
}
export function MessageBubble({
message,
onEdit,
onRegenerate,
onDelete,
isStreaming,
isLastAssistant,
}: MessageBubbleProps) {
const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false);
const showingStreaming = !!isStreaming && !!isLastAssistant;
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
return (
<View
onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
>
<BubbleRow message={message} showingStreaming={showingStreaming} />
<BubbleMeta message={message} />
{actionsVisible && (
<MessageActions
role={message.role}
content={message.content}
onEdit={isUser ? onEdit : undefined}
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
onDelete={onDelete}
isStreaming={showingStreaming}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
outerWrap: {
width: '100%',
marginBottom: 12,
},
container: {
width: '100%',
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
},
containerUser: {
justifyContent: 'flex-end',
},
containerAssistant: {
justifyContent: 'flex-start',
},
avatar: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
bubble: {
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 20,
// CRITICAL FOR MARKDOWN IN FLEX ROW:
flexShrink: 1,
},
bubbleUser: {
borderBottomRightRadius: 6,
},
bubbleAssistant: {
borderBottomLeftRadius: 6,
},
metaRow: {
flexDirection: 'row',
paddingHorizontal: 16,
marginTop: 4,
gap: 8,
},
metaRowUser: {
justifyContent: 'flex-end',
paddingRight: 56,
},
metaRowAssistant: {
justifyContent: 'flex-start',
},
timestamp: {
fontSize: 11,
fontWeight: '500',
},
});