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).
This commit is contained in:
1 parent
434cba2a69
commit
2a294f1f87
6 files changed
+465
-260
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { Check, Copy } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface CodeBlockProps {
|
||||
language: string;
|
||||
content: string;
|
||||
copied: boolean;
|
||||
onCopy: (text: string) => void;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
|
||||
const CopyIcon = copied ? Check : Copy;
|
||||
|
||||
return (
|
||||
<View style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
||||
<TouchableOpacity onPress={() => onCopy(content)} style={styles.copyButton}>
|
||||
<CopyIcon size={14} color="#A0A0A0" />
|
||||
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
codeBlockWrapper: {
|
||||
backgroundColor: '#1E1E1E',
|
||||
borderRadius: 8,
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
codeBlockHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2D2D2D',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#404040',
|
||||
},
|
||||
codeBlockLanguage: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
textTransform: 'lowercase',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
},
|
||||
copyText: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
},
|
||||
codeScrollView: {
|
||||
padding: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: '#D4D4D4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Bot, Check, Copy, User } from 'lucide-react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||
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;
|
||||
@@ -16,6 +15,58 @@ interface MessageBubbleProps {
|
||||
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,
|
||||
@@ -24,217 +75,21 @@ export function MessageBubble({
|
||||
isStreaming,
|
||||
isLastAssistant,
|
||||
}: MessageBubbleProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const colors = useThemeColor();
|
||||
const isLargeScreen = width > 768;
|
||||
const isUser = message.role === 'user';
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const showingStreaming = isStreaming && isLastAssistant;
|
||||
const shouldShowActions = !isUser || showActions;
|
||||
|
||||
// UseMemo for dynamic colors
|
||||
const markdownStyles = useMemo(() => ({
|
||||
body: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: colors.backgroundColor,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.inputBg,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.border,
|
||||
paddingLeft: 12,
|
||||
marginVertical: 4,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bullet_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
ordered_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
list_item: {
|
||||
marginVertical: 2,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginVertical: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
marginVertical: 6,
|
||||
color: colors.text,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginVertical: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
link: {
|
||||
color: '#3B82F6',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
paragraph: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
} as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
|
||||
|
||||
const markdownRules = useMemo(() => ({
|
||||
fence: (node: any, children: any, parent: any, parentStyles: any) => {
|
||||
const content = node.content || '';
|
||||
const language = node.sourceInfo || 'code';
|
||||
return (
|
||||
<View key={node.key} style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
||||
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
|
||||
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
|
||||
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
code_block: (node: any, children: any, parent: any, parentStyles: any) => {
|
||||
const content = node.content || '';
|
||||
return (
|
||||
<View key={node.key} style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>code</Text>
|
||||
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
|
||||
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
|
||||
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}), [copiedText]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isUser) {
|
||||
return (
|
||||
<Text style={[styles.text, { color: colors.messageUserText }]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.content && showingStreaming) {
|
||||
return null; // TypingIndicator handled externally
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
const showingStreaming = !!isStreaming && !!isLastAssistant;
|
||||
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
onTouchStart={() => isUser && setShowActions(true)}
|
||||
style={styles.outerWrap}
|
||||
>
|
||||
<View style={[
|
||||
styles.container,
|
||||
isUser ? styles.containerUser : styles.containerAssistant,
|
||||
]}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
|
||||
<Bot size={16} color={colors.text} />
|
||||
</View>
|
||||
)}
|
||||
<BubbleRow message={message} showingStreaming={showingStreaming} />
|
||||
<BubbleMeta message={message} />
|
||||
|
||||
<View style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleUser : styles.bubbleAssistant,
|
||||
{ maxWidth: isLargeScreen ? '70%' : '85%' },
|
||||
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
|
||||
!isUser && { borderWidth: 1, borderColor: colors.border }
|
||||
]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
|
||||
{isUser && (
|
||||
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
|
||||
<User size={16} color={colors.backgroundColor} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Timestamp + edited badge */}
|
||||
<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>
|
||||
|
||||
{/* Action buttons */}
|
||||
{(shouldShowActions || !isUser) && message.content.length > 0 && (
|
||||
{actionsVisible && (
|
||||
<MessageActions
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
@@ -266,15 +121,7 @@ const styles = StyleSheet.create({
|
||||
containerAssistant: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
avatarAssistant: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
avatarUser: {
|
||||
avatar: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
@@ -295,10 +142,6 @@ const styles = StyleSheet.create({
|
||||
bubbleAssistant: {
|
||||
borderBottomLeftRadius: 6,
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
@@ -316,45 +159,4 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
codeBlockWrapper: {
|
||||
backgroundColor: '#1E1E1E',
|
||||
borderRadius: 8,
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
codeBlockHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2D2D2D',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#404040',
|
||||
},
|
||||
codeBlockLanguage: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
textTransform: 'lowercase',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
},
|
||||
copyText: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
},
|
||||
codeScrollView: {
|
||||
padding: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: '#D4D4D4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { createMarkdownStyles } from './markdownStyles';
|
||||
|
||||
interface MessageContentProps {
|
||||
message: ChatMessage;
|
||||
/** True while this message is the answer currently being streamed. */
|
||||
showingStreaming: boolean;
|
||||
}
|
||||
|
||||
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
|
||||
const colors = useThemeColor();
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
|
||||
|
||||
const markdownRules = useMemo(() => ({
|
||||
fence: (node: any) => (
|
||||
<CodeBlock
|
||||
key={node.key}
|
||||
language={node.sourceInfo || 'code'}
|
||||
content={node.content || ''}
|
||||
copied={copiedText === (node.content || '')}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
),
|
||||
code_block: (node: any) => (
|
||||
<CodeBlock
|
||||
key={node.key}
|
||||
language="code"
|
||||
content={node.content || ''}
|
||||
copied={copiedText === (node.content || '')}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
),
|
||||
}), [copiedText]);
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<Text style={[styles.text, { color: colors.messageUserText }]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.content && showingStreaming) {
|
||||
return null; // TypingIndicator handled externally
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
type ThemeColors = ReturnType<typeof useThemeColor>;
|
||||
|
||||
/** Style map for the Markdown renderer, derived from the active theme colours. */
|
||||
export function createMarkdownStyles(colors: ThemeColors) {
|
||||
return {
|
||||
body: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: colors.backgroundColor,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.inputBg,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.border,
|
||||
paddingLeft: 12,
|
||||
marginVertical: 4,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bullet_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
ordered_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
list_item: {
|
||||
marginVertical: 2,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginVertical: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
marginVertical: 6,
|
||||
color: colors.text,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginVertical: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
link: {
|
||||
color: '#3B82F6',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
paragraph: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
// The Markdown renderer's style types do not accept every RN style key literal.
|
||||
} as any;
|
||||
}
|
||||
Reference in new issue
Block a user