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).
163 lines
4.8 KiB
TypeScript
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',
|
|
},
|
|
});
|