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,154 @@
|
|||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import React from 'react';
|
||||||
|
import { MessageBubble } from '../components/chat/MessageBubble';
|
||||||
|
import { ChatMessage } from '../store/useAppStore';
|
||||||
|
|
||||||
|
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
|
||||||
|
|
||||||
|
let actionsProps: any;
|
||||||
|
jest.mock('../components/chat/MessageActions', () => ({
|
||||||
|
MessageActions: (props: any) => {
|
||||||
|
actionsProps = props;
|
||||||
|
const { Text } = require('react-native');
|
||||||
|
return <Text>actions</Text>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||||
|
id: '1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'hello **world**',
|
||||||
|
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
actionsProps = undefined;
|
||||||
|
(Clipboard.setStringAsync as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MessageBubble user message', () => {
|
||||||
|
it('shows the plain text and keeps the actions hidden until touched', () => {
|
||||||
|
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
|
||||||
|
expect(screen.getByText('hi there')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('actions')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals edit and delete (but not regenerate) after a touch', () => {
|
||||||
|
const onEdit = jest.fn();
|
||||||
|
const onDelete = jest.fn();
|
||||||
|
const onRegenerate = jest.fn();
|
||||||
|
render(
|
||||||
|
<MessageBubble
|
||||||
|
message={message({ role: 'user', content: 'hi' })}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
isLastAssistant
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
|
||||||
|
|
||||||
|
expect(screen.getByText('actions')).toBeTruthy();
|
||||||
|
expect(actionsProps.role).toBe('user');
|
||||||
|
expect(actionsProps.onEdit).toBe(onEdit);
|
||||||
|
expect(actionsProps.onDelete).toBe(onDelete);
|
||||||
|
expect(actionsProps.onRegenerate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never renders actions for an empty message', () => {
|
||||||
|
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
|
||||||
|
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
|
||||||
|
expect(screen.queryByText('actions')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MessageBubble assistant message', () => {
|
||||||
|
it('renders markdown and always shows actions', () => {
|
||||||
|
render(<MessageBubble message={message()} />);
|
||||||
|
expect(screen.getByText('world')).toBeTruthy();
|
||||||
|
expect(screen.getByText('actions')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers regenerate only on the last assistant message and never edit', () => {
|
||||||
|
const onEdit = jest.fn();
|
||||||
|
const onRegenerate = jest.fn();
|
||||||
|
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
|
||||||
|
expect(actionsProps.onRegenerate).toBeUndefined();
|
||||||
|
expect(actionsProps.onEdit).toBeUndefined();
|
||||||
|
|
||||||
|
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
|
||||||
|
expect(actionsProps.onRegenerate).toBe(onRegenerate);
|
||||||
|
expect(actionsProps.onEdit).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags actions as streaming only for the last assistant message', () => {
|
||||||
|
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
|
||||||
|
expect(actionsProps.isStreaming).toBeFalsy();
|
||||||
|
|
||||||
|
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
|
||||||
|
expect(actionsProps.isStreaming).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
|
||||||
|
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
|
||||||
|
expect(screen.queryByText('actions')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty answer as markdown when not streaming', () => {
|
||||||
|
render(<MessageBubble message={message({ content: '' })} />);
|
||||||
|
expect(screen.queryByText('actions')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MessageBubble metadata', () => {
|
||||||
|
it('shows the time and the edited badge only for edited messages', () => {
|
||||||
|
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
|
||||||
|
expect(screen.queryByText(/edited/)).toBeNull();
|
||||||
|
|
||||||
|
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
|
||||||
|
expect(screen.getByText(/· edited/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MessageBubble code blocks', () => {
|
||||||
|
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
|
||||||
|
|
||||||
|
it('shows the language, the code and copies it to the clipboard', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
render(<MessageBubble message={message({ content: fenced })} />);
|
||||||
|
expect(screen.getByText('ts')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
|
||||||
|
expect(screen.getByText('Copy')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.press(screen.getByText('Copy'));
|
||||||
|
});
|
||||||
|
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
|
||||||
|
expect(screen.getByText('Copied!')).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Copy')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels a fence without a language as "code"', () => {
|
||||||
|
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
|
||||||
|
expect(screen.getByText('code')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders indented code blocks with a "code" header and copy button', async () => {
|
||||||
|
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
|
||||||
|
expect(screen.getByText('code')).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.press(screen.getByText('Copy'));
|
||||||
|
});
|
||||||
|
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import { ChatMessage } from '@/store/useAppStore';
|
import { ChatMessage } from '@/store/useAppStore';
|
||||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
import { Bot, User } from 'lucide-react-native';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import React, { useState } from 'react';
|
||||||
import { Bot, Check, Copy, User } from 'lucide-react-native';
|
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
|
||||||
import { MessageActions } from './MessageActions';
|
import { MessageActions } from './MessageActions';
|
||||||
|
import { MessageContent } from './MessageContent';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
@@ -16,6 +15,58 @@ interface MessageBubbleProps {
|
|||||||
isLastAssistant?: 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({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -24,217 +75,21 @@ export function MessageBubble({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
isLastAssistant,
|
isLastAssistant,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { width } = useWindowDimensions();
|
|
||||||
const colors = useThemeColor();
|
|
||||||
const isLargeScreen = width > 768;
|
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleCopy = async (text: string) => {
|
const showingStreaming = !!isStreaming && !!isLastAssistant;
|
||||||
await Clipboard.setStringAsync(text);
|
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
onTouchStart={() => isUser && setShowActions(true)}
|
onTouchStart={() => isUser && setShowActions(true)}
|
||||||
style={styles.outerWrap}
|
style={styles.outerWrap}
|
||||||
>
|
>
|
||||||
<View style={[
|
<BubbleRow message={message} showingStreaming={showingStreaming} />
|
||||||
styles.container,
|
<BubbleMeta message={message} />
|
||||||
isUser ? styles.containerUser : styles.containerAssistant,
|
|
||||||
]}>
|
|
||||||
{/* Avatar */}
|
|
||||||
{!isUser && (
|
|
||||||
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
|
|
||||||
<Bot size={16} color={colors.text} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[
|
{actionsVisible && (
|
||||||
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 && (
|
|
||||||
<MessageActions
|
<MessageActions
|
||||||
role={message.role}
|
role={message.role}
|
||||||
content={message.content}
|
content={message.content}
|
||||||
@@ -266,15 +121,7 @@ const styles = StyleSheet.create({
|
|||||||
containerAssistant: {
|
containerAssistant: {
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
},
|
},
|
||||||
avatarAssistant: {
|
avatar: {
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
avatarUser: {
|
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
@@ -295,10 +142,6 @@ const styles = StyleSheet.create({
|
|||||||
bubbleAssistant: {
|
bubbleAssistant: {
|
||||||
borderBottomLeftRadius: 6,
|
borderBottomLeftRadius: 6,
|
||||||
},
|
},
|
||||||
text: {
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
|
||||||
metaRow: {
|
metaRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@@ -316,45 +159,4 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '500',
|
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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ module.exports = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/$1',
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
},
|
},
|
||||||
|
// jest-expo's default pattern plus the ESM-only markdown renderer.
|
||||||
|
transformIgnorePatterns: [
|
||||||
|
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
|
||||||
|
],
|
||||||
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
|
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
|
||||||
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
|
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user