diff --git a/__tests__/MessageBubble.test.tsx b/__tests__/MessageBubble.test.tsx new file mode 100644 index 0000000..5e7f68a --- /dev/null +++ b/__tests__/MessageBubble.test.tsx @@ -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 actions; + }, +})); + +const message = (overrides: Partial = {}): 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(); + 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( + , + ); + 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(); + 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(); + 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(); + expect(actionsProps.onRegenerate).toBeUndefined(); + expect(actionsProps.onEdit).toBeUndefined(); + + rerender(); + expect(actionsProps.onRegenerate).toBe(onRegenerate); + expect(actionsProps.onEdit).toBeUndefined(); + }); + + it('flags actions as streaming only for the last assistant message', () => { + const { rerender } = render(); + expect(actionsProps.isStreaming).toBeFalsy(); + + rerender(); + expect(actionsProps.isStreaming).toBe(true); + }); + + it('renders nothing inside the bubble or actions while an empty answer is streaming', () => { + render(); + expect(screen.queryByText('actions')).toBeNull(); + }); + + it('renders an empty answer as markdown when not streaming', () => { + render(); + expect(screen.queryByText('actions')).toBeNull(); + }); +}); + +describe('MessageBubble metadata', () => { + it('shows the time and the edited badge only for edited messages', () => { + const { rerender } = render(); + expect(screen.queryByText(/edited/)).toBeNull(); + + rerender(); + 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(); + 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(); + expect(screen.getByText('code')).toBeTruthy(); + }); + + it('renders indented code blocks with a "code" header and copy button', async () => { + render(); + expect(screen.getByText('code')).toBeTruthy(); + await act(async () => { + fireEvent.press(screen.getByText('Copy')); + }); + expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()')); + }); +}); diff --git a/components/chat/CodeBlock.tsx b/components/chat/CodeBlock.tsx new file mode 100644 index 0000000..e03bbca --- /dev/null +++ b/components/chat/CodeBlock.tsx @@ -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 ( + + + {language} + onCopy(content)} style={styles.copyButton}> + + {copied ? 'Copied!' : 'Copy'} + + + + {content} + + + ); +} + +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', + }, +}); diff --git a/components/chat/MessageBubble.tsx b/components/chat/MessageBubble.tsx index 24c1fb1..df5f086 100644 --- a/components/chat/MessageBubble.tsx +++ b/components/chat/MessageBubble.tsx @@ -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 ( + + + + ); +} + +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 ( + + {!isUser && } + + + + + + {isUser && } + + ); +} + +/** Timestamp + edited badge. */ +function BubbleMeta({ message }: { message: ChatMessage }) { + const colors = useThemeColor(); + const isUser = message.role === 'user'; + + return ( + + {!isUser && } + + {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {message.isEdited ? ' · edited' : ''} + + + ); +} + 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(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 ( - - - {language} - handleCopy(content)} style={styles.copyButton}> - {copiedText === content ? : } - {copiedText === content ? 'Copied!' : 'Copy'} - - - - {content} - - - ); - }, - code_block: (node: any, children: any, parent: any, parentStyles: any) => { - const content = node.content || ''; - return ( - - - code - handleCopy(content)} style={styles.copyButton}> - {copiedText === content ? : } - {copiedText === content ? 'Copied!' : 'Copy'} - - - - {content} - - - ); - } - }), [copiedText]); - - const renderContent = () => { - if (isUser) { - return ( - - {message.content} - - ); - } - - if (!message.content && showingStreaming) { - return null; // TypingIndicator handled externally - } - - return ( - - {message.content} - - ); - }; + const showingStreaming = !!isStreaming && !!isLastAssistant; + const actionsVisible = (!isUser || showActions) && message.content.length > 0; return ( isUser && setShowActions(true)} style={styles.outerWrap} > - - {/* Avatar */} - {!isUser && ( - - - - )} + + - - {renderContent()} - - - {isUser && ( - - - - )} - - - {/* Timestamp + edited badge */} - - {!isUser && } - - {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - {message.isEdited ? ' · edited' : ''} - - - - {/* Action buttons */} - {(shouldShowActions || !isUser) && message.content.length > 0 && ( + {actionsVisible && ( (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) => ( + + ), + code_block: (node: any) => ( + + ), + }), [copiedText]); + + if (message.role === 'user') { + return ( + + {message.content} + + ); + } + + if (!message.content && showingStreaming) { + return null; // TypingIndicator handled externally + } + + return ( + + {message.content} + + ); +} + +const styles = StyleSheet.create({ + text: { + fontSize: 15, + lineHeight: 22, + }, +}); diff --git a/components/chat/markdownStyles.ts b/components/chat/markdownStyles.ts new file mode 100644 index 0000000..e86f1d0 --- /dev/null +++ b/components/chat/markdownStyles.ts @@ -0,0 +1,99 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; + +type ThemeColors = ReturnType; + +/** 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; +} diff --git a/jest.config.js b/jest.config.js index 6da70c7..8b1e660 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,10 @@ module.exports = { moduleNameMapper: { '^@/(.*)$': '/$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: ['/__tests__/**/*.test.{ts,tsx}'], testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'], };