import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import Typography from '../ui/Typography'; import type { Message } from '../../stores/agentStore'; import { FiHash, FiClock, FiCopy, FiCheck, FiMaximize2, FiX, FiImage, } from 'react-icons/fi'; import { cn } from '../../lib/utils'; import Logo from '../ui/Logo'; import UserProfileImage from '../ui/UserProfileImage'; import AttachmentDisplay from './AttachmentDisplay'; import { TransactionApprovalButton } from './TransactionApproval'; import { useAgentStore } from '../../stores/agentStore'; import { useConfigStore } from '../../stores/configStore'; import { CodeBlock } from '../ui/CodeBlock'; import { FormMessageBubble } from './FormMessageBubble'; import HashLinkBlockRenderer from './HashLinkBlockRenderer'; import type { UserProfile } from '../../types/userProfile'; import { processMarkdown as renderMarkdown } from '../../utils/markdownProcessor'; interface MessageBubbleProps { message: Message; userProfile?: UserProfile | null; isHCS10?: boolean; agentName?: string; onAgentProfileClick?: ( accountId: string, agentName: string, network: string ) => void; } interface UserIdentificationProps { isHCS10: boolean; message: Message; myAccountId?: string; } /** * Component to determine if a message is from the current user in HCS10 context * @param isHCS10 - Whether this is an HCS10 message * @param message - The message object containing metadata * @param myAccountId - The current user's account ID * @returns boolean indicating if the message is from the current user */ function UserIdentificationComponent({ isHCS10, message, myAccountId, }: UserIdentificationProps): boolean { if (!isHCS10) { return message.role === 'user'; } const operatorId = message.metadata?.operatorId; const normalizeAccountId = (accountId: string) => { if (!accountId) return ''; if (accountId.includes('@')) { const parts = accountId.split('@'); return parts[parts.length - 1]; } return accountId.replace(/^.*?(\d+\.\d+\.\d+).*$/, '$1'); }; const normalizedMyAccountId = normalizeAccountId(myAccountId || ''); const normalizedOperatorId = normalizeAccountId(String(operatorId || '')); if (normalizedMyAccountId && normalizedOperatorId) { return normalizedMyAccountId === normalizedOperatorId; } return false; } interface TransactionButtonRendererProps { isHCS10: boolean; message: Message; isUser: boolean; config: { hedera?: { network?: string; }; } | null; } /** * Component to render transaction-related buttons and status for HCS10 messages * @param isHCS10 - Whether this is an HCS10 message * @param message - The message object containing transaction metadata * @param isUser - Whether the message is from the current user * @param config - Configuration object containing network settings * @returns JSX element or null if no transaction button needed */ function TransactionButtonRenderer({ isHCS10, message, isUser, config, }: TransactionButtonRendererProps): React.JSX.Element | null { const shouldShowTransactionButton = isHCS10 && message.metadata?.op === 'transaction' && message.metadata?.scheduleId && !isUser; const isMalformedTransaction = isHCS10 && message.metadata?.op === 'transaction' && !message.metadata?.scheduleId && !isUser; if (shouldShowTransactionButton) { try { return (
); } catch (error) { return (
⚠️ Transaction Approval Button Error:{' '} {error instanceof Error ? error.message : 'Unknown error'}
Schedule ID: {String(message.metadata.scheduleId || '')}
); } } if (isMalformedTransaction) { return (
⚠️ Malformed Transaction Operation
This transaction operation is missing a required scheduleId field. Content: {String(message.metadata.data || '') || message.content}
); } return null; } /** * Enhanced content extraction for both personal and HCS-10 messages */ function extractHCS10MessageContent(message: Message): string { const isNonEmpty = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && v !== '[Empty message]'; if (isNonEmpty(message.content)) { return message.content; } if (message.metadata?.isHCS10Message) { const meta = (message.metadata || {}) as Record; const tryParseJsonString = (s: string): string | null => { const str = s.trim(); if (!str.startsWith('{')) return isNonEmpty(str) ? str : null; try { const obj = JSON.parse(str) as unknown; if (typeof obj === 'object' && obj !== null) { const rec = obj as Record; const candidates: Array = [ rec.text, rec.content, rec.message, rec.body && typeof rec.body === 'object' ? (rec.body as Record).text : undefined, rec.body && typeof rec.body === 'object' ? (rec.body as Record).content : undefined, rec.payload && typeof rec.payload === 'object' ? (rec.payload as Record).text : undefined, rec.payload && typeof rec.payload === 'object' ? (rec.payload as Record).content : undefined, typeof rec.data === 'string' ? rec.data : undefined, ]; const found = candidates.find(isNonEmpty); if (found) return found as string; } return str; } catch { return isNonEmpty(s) ? s : null; } }; const primaryRaw = meta.data; if (isNonEmpty(primaryRaw)) { const parsed = tryParseJsonString(primaryRaw); if (parsed) return parsed; } if (typeof primaryRaw === 'object' && primaryRaw !== null) { const rec = primaryRaw as Record; const nestedCandidates: Array = [ rec.text, rec.content, rec.message, rec.body && typeof rec.body === 'object' ? (rec.body as Record).text : undefined, rec.body && typeof rec.body === 'object' ? (rec.body as Record).content : undefined, rec.payload && typeof rec.payload === 'object' ? (rec.payload as Record).text : undefined, rec.payload && typeof rec.payload === 'object' ? (rec.payload as Record).content : undefined, typeof rec.data === 'string' ? rec.data : undefined, ]; const found = nestedCandidates.find(isNonEmpty); if (found) return found as string; const asString = JSON.stringify(primaryRaw); if (isNonEmpty(asString)) return asString; } const fallbackMetaKeys: Array = [ 'content', 'message', 'text', ]; for (const key of fallbackMetaKeys) { const v = meta[key]; if (isNonEmpty(v)) return v as string; } } return '[Empty message]'; } function parseScheduleMessage(content: string, isUser: boolean): string { if ( isUser || !content.trim().startsWith('{') || !content.includes('scheduleId') ) { return content; } try { const parsed = JSON.parse(content); if (parsed.success && parsed.scheduleId) { return `Transaction scheduled successfully! Schedule ID: ${parsed.scheduleId}`; } } catch {} return content; } function cleanMessageContent(content: string): string { const cleanedContent = content.replace( /\n\n[\s\S]*?/g, '' ); return cleanedContent.replace( /\n[\s\S]*?/g, '' ); } function MessageBubbleImpl({ message, userProfile, isHCS10 = false, agentName, onAgentProfileClick, }: MessageBubbleProps) { const config = useConfigStore((state) => state.config); const isUser = UserIdentificationComponent({ isHCS10, message, myAccountId: config?.hedera?.accountId, }); const isSystem = message.role === 'system'; const getDisplayName = () => { if (!isHCS10) { return isUser ? 'You' : 'Assistant'; } if (isUser) { return userProfile?.display_name || userProfile?.alias || 'You'; } else { const operatorId = message.metadata?.operatorId as string; if (operatorId) { if (agentName) { return agentName; } else { const accountId = operatorId.replace(/^.*?(\d+\.\d+\.\d+).*$/, '$1'); return accountId || 'Unknown'; } } return agentName || 'Agent'; } }; const displayName = getDisplayName(); const [isCopied, setIsCopied] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [imageModal, setImageModal] = useState<{ imageData: string; imageName: string; } | null>(null); const operationalMode = config?.advanced?.operationalMode || 'autonomous'; const approveTransaction = useAgentStore((s) => s.approveTransaction); const rejectTransaction = useAgentStore((s) => s.rejectTransaction); const contentParts = useMemo(() => { const rawContent = extractHCS10MessageContent(message); let cleanedContent = cleanMessageContent(rawContent); if ( !isUser && message.metadata?.transactionBytes && operationalMode === 'provideBytes' ) { const transactionType = message.metadata?.parsedTransaction?.humanReadableType || message.metadata?.parsedTransaction?.type || 'transaction'; const typeText = transactionType .toLowerCase() .replace(' transaction', ''); cleanedContent = `Your ${typeText} transaction is ready for approval!`; } const parsedContent = parseScheduleMessage(cleanedContent, isUser); const codePattern = /```([a-z]*)\n([\s\S]*?)```/g; let lastIndex = 0; const results = []; let match; while ((match = codePattern.exec(parsedContent)) !== null) { if (match.index > lastIndex) { results.push({ type: 'text', content: parsedContent.slice(lastIndex, match.index), }); } results.push({ type: 'code', language: match[1] || 'typescript', content: match[2].trim(), }); lastIndex = match.index + match[0].length; } if (lastIndex < parsedContent.length) { results.push({ type: 'text', content: parsedContent.slice(lastIndex), }); } return results; }, [message, isUser, operationalMode]); const stableKey = (...args: string[]) => { let h = 5381; for (const s of args) { for (let i = 0; i < s.length; i++) { h = ((h << 5) + h) ^ s.charCodeAt(i); } } return 'k' + (h >>> 0).toString(16); }; const renderedParts = useMemo( () => contentParts.map((part) => part.type === 'code' ? { type: 'code' as const, key: stableKey('code', part.language || '', part.content), language: part.language, content: part.content, } : { type: 'text' as const, key: stableKey('text', '', part.content), html: renderMarkdown(part.content), } ), [contentParts] ); const processMarkdown = (text: string) => { let processed = text; processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => { const cleanMath = math .trim() .replace(/\\text\{([^}]+)\}/g, '$1') .replace(/\\,/g, ' ') .replace(/\\/g, ''); return `
${cleanMath}
`; }); processed = processed.replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => { const cleanMath = math .trim() .replace(/\\text\{([^}]+)\}/g, '$1') .replace(/\\,/g, ' ') .replace(/\\/g, ''); return `${cleanMath}`; }); processed = processed.replace(/\$\$([^$]+)\$\$/g, (_, math) => { return `
${math.trim()}
`; }); processed = processed.replace(/\$([^$]+)\$/g, (_, math) => { return `${math}`; }); processed = processed.replace(/`([^`]+)`/g, (_, code) => { return `${code}`; }); processed = processed.replace( /\*\*([^*]+)\*\*/g, '$1' ); processed = processed.replace( /__([^_]+)__/g, '$1' ); processed = processed.replace(/\*([^*]+)\*/g, '$1'); processed = processed.replace(/_([^_]+)_/g, '$1'); processed = processed.replace( /\[([^\]]+)\]\(([^)]+)\)/g, '$1' ); processed = processed.replace( /^#### (.*$)/gm, '

$1

' ); processed = processed.replace( /^### (.*$)/gm, '

$1

' ); processed = processed.replace( /^## (.*$)/gm, '

$1

' ); processed = processed.replace( /^# (.*$)/gm, '

$1

' ); processed = processed.replace( /^\s*[-*] (.+)$/gm, '
  • • $1
  • ' ); processed = processed.replace(/()/s, '
      $1
    '); processed = processed.replace(/\n/g, '
    '); return processed; }; useEffect(() => {}, [ message.metadata?.scheduleId, operationalMode, config?.advanced, ]); const formatTime = (timestamp: Date) => { return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, }).format(timestamp); }; const handleAgentProfileClick = () => { if ( isHCS10 && !isUser && message.metadata?.operatorId && onAgentProfileClick ) { const operatorId = message.metadata.operatorId as string; const accountId = operatorId.replace(/^.*?(\d+\.\d+\.\d+).*$/, '$1'); onAgentProfileClick( accountId, agentName || 'Agent', config?.hedera?.network || 'testnet' ); } }; const handleCopyMessage = async () => { try { const rawContent = extractHCS10MessageContent(message); let textToCopy = cleanMessageContent(rawContent); if ( !isUser && message.metadata?.transactionBytes && operationalMode === 'provideBytes' ) { const transactionType = message.metadata?.parsedTransaction?.humanReadableType || message.metadata?.parsedTransaction?.type || 'transaction'; const typeText = transactionType .toLowerCase() .replace(' transaction', ''); textToCopy = `Your ${typeText} transaction is ready for approval!`; } await navigator.clipboard.writeText(textToCopy); setIsCopied(true); setTimeout(() => { setIsCopied(false); }, 2000); } catch { } }; if (isSystem) { return (
    {message.content}
    ); } return ( <> {/* Fullscreen Modal */} {isFullscreen && (
    {/* Modal Header */}
    {isUser ? ( ) : (
    )} {displayName} {formatTime(message.timestamp)}
    {/* Modal Content */}
    {renderedParts.map((part, index) => { if (part.type === 'code') { return ( ); } return (
    ); })} {/* Show attachments in modal for user messages */} {isUser && Array.isArray(message.metadata?.attachments) && ( setImageModal({ imageData, imageName }) } /> )}
    {/* Modal Footer */}
    )}
    {isUser ? ( ) : isHCS10 && agentName ? (
    ) : (
    )}
    {/* Action buttons */}
    {/* Expand button - only show for longer messages */} {contentParts.some((part) => part.content.length > 500) ? ( ) : null} {/* Copy button */}
    1 ? 'space-y-2' : ''}> {isUser ? contentParts.map((part, index) => { if (part.type === 'code') { return ( ); } return ( {part.content} ); }) : renderedParts.map((part) => { if (part.type === 'code') { return ( ); } return (
    ); })}
    {/* Show attachments for user messages */} {isUser && Array.isArray(message.metadata?.attachments) && ( setImageModal({ imageData, imageName }) } /> )} {/* Transaction approval inside bubble for assistant messages */} {(() => { if (operationalMode === 'provideBytes' && !isUser) { const hasTx = Boolean(message.metadata?.scheduleId || message.metadata?.transactionBytes); if (hasTx) { return (
    { try { const sessId = useAgentStore.getState().currentSession?.id || undefined; await useAgentStore .getState() .markTransactionExecuted(messageId, transactionId, sessId); } catch {} }} />
    ); } } return null; })()} {/* Show notes when not in provideBytes mode or no transaction */} {(() => { if ( !isUser && Array.isArray(message.metadata?.notes) && message.metadata.notes.length > 0 ) { const hasTransactionInProvideBytes = operationalMode === 'provideBytes' && (message.metadata?.scheduleId || message.metadata?.transactionBytes); if (!hasTransactionInProvideBytes) { return (
    Notes:
      {(message.metadata.notes as string[]).map( (note: string, index: number) => (
    • • {note}
    • ) )}
    ); } } return null; })()} {/* Render form message if present */} {!isUser && message.metadata?.formMessage ? (
    ) : null} {!isUser && message.metadata?.hashLinkBlock ? ( ; } } className='mx-0 max-w-none' /> ) : null}
    {message.metadata?.transactionId ? (
    ) : null} {operationalMode === 'autonomous' && message.metadata?.scheduleId ? (
    ) : null}
    {(() => { if ( operationalMode === 'autonomous' && message.metadata?.transactionBytes && !isUser ) { return ( ); } return null; })()}
    {/* Image Modal */} {imageModal && (
    setImageModal(null)} > e.stopPropagation()} > {/* Modal Header */}
    {imageModal.imageName}
    {/* Modal Content */}
    {imageModal.imageName} setImageModal(null)} />
    )} ); } function areEqual(prev: MessageBubbleProps, next: MessageBubbleProps): boolean { const pm = prev.message; const nm = next.message; if (pm === nm) return true; if (pm.id !== nm.id) return false; if (pm.role !== nm.role) return false; const pTime = pm.timestamp?.valueOf?.() ?? 0; const nTime = nm.timestamp?.valueOf?.() ?? 0; if (pTime !== nTime) return false; if (pm.content !== nm.content) return false; const pmd = pm.metadata ?? {}; const nmd = nm.metadata ?? {}; const pmdRec = pmd as Record; const nmdRec = nmd as Record; const fields = [ 'op', 'scheduleId', 'transactionBytes', 'description', 'data', 'message', 'text', 'operatorId', ] as const; for (const f of fields) { if (pmdRec[f as string] !== nmdRec[f as string]) return false; } const pNotesArr = Array.isArray(pmdRec.notes) ? (pmdRec.notes as unknown[]) : []; const nNotesArr = Array.isArray(nmdRec.notes) ? (nmdRec.notes as unknown[]) : []; if (pNotesArr.join('|') !== nNotesArr.join('|')) return false; const pAtt = Array.isArray(pmdRec.attachments) ? (pmdRec.attachments as unknown[]) : []; const nAtt = Array.isArray(nmdRec.attachments) ? (nmdRec.attachments as unknown[]) : []; if (pAtt.length !== nAtt.length) return false; for (let i = 0; i < pAtt.length; i++) { const pa = (pAtt[i] as Record) || {}; const na = (nAtt[i] as Record) || {}; if (pa.name !== na.name) return false; if (pa.size !== na.size) return false; if (pa.type !== na.type) return false; } if (prev.isHCS10 !== next.isHCS10) return false; if (prev.agentName !== next.agentName) return false; return true; } const MessageBubble = React.memo(MessageBubbleImpl, areEqual); export default MessageBubble;