import { useCallback, useEffect, useRef, useState } from 'react'; import ImagePreview from './ImagePreview'; import MarkdownContent from './MarkdownContent'; import { getTextAndImageContent, type Message } from '../types/message'; import MessageCopyLink from './MessageCopyLink'; import { formatMessageTimestamp } from '../utils/timeUtils'; import Edit from './icons/Edit'; import { Button } from './ui/button'; import { defineMessages, useIntl } from '../i18n'; const i18n = defineMessages({ editPlaceholder: { id: 'userMessage.editPlaceholder', defaultMessage: 'Edit your message...', }, editAriaLabel: { id: 'userMessage.editAriaLabel', defaultMessage: 'Edit message content', }, emptyError: { id: 'userMessage.emptyError', defaultMessage: 'Message cannot be empty', }, editInPlaceDescription: { id: 'userMessage.editInPlaceDescription', defaultMessage: 'Edit in Place updates this session • Fork Session creates a new session', }, cancel: { id: 'userMessage.cancel', defaultMessage: 'Cancel', }, cancelAriaLabel: { id: 'userMessage.cancelAriaLabel', defaultMessage: 'Cancel editing', }, editInPlace: { id: 'userMessage.editInPlace', defaultMessage: 'Edit in Place', }, editInPlaceAriaLabel: { id: 'userMessage.editInPlaceAriaLabel', defaultMessage: 'Edit message in place', }, editInPlaceTitle: { id: 'userMessage.editInPlaceTitle', defaultMessage: 'Update the message in this session', }, forkSession: { id: 'userMessage.forkSession', defaultMessage: 'Fork Session', }, forkSessionAriaLabel: { id: 'userMessage.forkSessionAriaLabel', defaultMessage: 'Fork session with edited message', }, forkSessionTitle: { id: 'userMessage.forkSessionTitle', defaultMessage: 'Create a new session with the edited message', }, editButton: { id: 'userMessage.editButton', defaultMessage: 'Edit', }, editMessageAriaLabel: { id: 'userMessage.editMessageAriaLabel', defaultMessage: 'Edit message: {preview}', }, editMessageTitle: { id: 'userMessage.editMessageTitle', defaultMessage: 'Edit message', }, }); interface UserMessageProps { message: Message; onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void; } export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) { const intl = useIntl(); const contentRef = useRef(null); const textareaRef = useRef(null); const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(''); const [error, setError] = useState(null); const { textContent, imagePaths } = getTextAndImageContent(message); const timestamp = formatMessageTimestamp(message.created); // Effect to handle message content changes and ensure persistence useEffect(() => { // If we're not editing, update the edit content to match the current message if (!isEditing) { setEditContent(textContent); } }, [message.content, textContent, message.id, isEditing]); // Initialize edit mode with current message content const initializeEditMode = useCallback(() => { setEditContent(textContent); setError(null); window.electron.logInfo(`Entering edit mode with content: ${textContent}`); }, [textContent]); // Handle edit button click const handleEditClick = useCallback(() => { const newEditingState = !isEditing; setIsEditing(newEditingState); // Initialize edit content when entering edit mode if (newEditingState) { initializeEditMode(); window.electron.logInfo(`Edit interface shown for message: ${message.id}`); // Focus the textarea after a brief delay to ensure it's rendered setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange( textareaRef.current.value.length, textareaRef.current.value.length ); } }, 50); } window.electron.logInfo(`Edit state toggled: ${newEditingState} for message: ${message.id}`); }, [isEditing, initializeEditMode, message.id]); // Handle content changes in edit mode const handleContentChange = useCallback((e: React.ChangeEvent) => { const newContent = e.target.value; setEditContent(newContent); setError(null); // Clear any previous errors window.electron.logInfo(`Content changed: ${newContent}`); }, []); const handleSave = useCallback( (editType: 'fork' | 'edit' = 'fork') => { if (editContent.trim().length === 0) { setError(intl.formatMessage(i18n.emptyError)); return; } setIsEditing(false); if (editType === 'edit' && editContent.trim() === textContent.trim()) { return; } if (onMessageUpdate && message.id) { onMessageUpdate(message.id, editContent, editType); } }, [editContent, textContent, onMessageUpdate, message.id, intl] ); // Handle cancel action const handleCancel = useCallback(() => { window.electron.logInfo('Cancel clicked - reverting to original content'); setIsEditing(false); setEditContent(textContent); // Reset to original content setError(null); }, [textContent]); // Handle keyboard events for accessibility const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { window.electron.logInfo( `Key pressed: ${e.key}, metaKey: ${e.metaKey}, ctrlKey: ${e.ctrlKey}` ); if (e.key === 'Escape') { e.preventDefault(); handleCancel(); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); window.electron.logInfo('Cmd+Enter detected, calling handleSave'); handleSave(); } }, [handleCancel, handleSave] ); // Auto-resize textarea based on content useEffect(() => { if (textareaRef.current && isEditing) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; } }, [editContent, isEditing]); return ( {isEditing ? ( // Truly wide, centered, in-place edit box replacing the bubble {/* Error message */} {error && ( {error} )} {intl.formatMessage(i18n.editInPlaceDescription, { b: (chunks: React.ReactNode) => {chunks}, })} {intl.formatMessage(i18n.cancel)} handleSave('edit')} variant="secondary" aria-label={intl.formatMessage(i18n.editInPlaceAriaLabel)} title={intl.formatMessage(i18n.editInPlaceTitle)} > {intl.formatMessage(i18n.editInPlace)} handleSave('fork')} aria-label={intl.formatMessage(i18n.forkSessionAriaLabel)} title={intl.formatMessage(i18n.forkSessionTitle)} > {intl.formatMessage(i18n.forkSession)} ) : ( // Normal message display {textContent.trim() && ( )} {imagePaths.length > 0 && ( {imagePaths.map((imagePath, index) => ( ))} )} {timestamp} { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleEditClick(); } }} className="flex items-center gap-1 text-xs text-text-secondary hover:cursor-pointer hover:text-text-primary transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50 rounded" aria-label={intl.formatMessage(i18n.editMessageAriaLabel, { preview: `${textContent.substring(0, 50)}${textContent.length > 50 ? '...' : ''}` })} aria-expanded={isEditing} title={intl.formatMessage(i18n.editMessageTitle)} > {intl.formatMessage(i18n.editButton)} )} ); }