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, '');
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 (
);
}
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 ? (
{String(message.metadata.transactionId || '').slice(0, 8)}
...
) : null}
{operationalMode === 'autonomous' &&
message.metadata?.scheduleId ? (
{String(message.metadata.scheduleId || '').slice(0, 8)}
) : null}
{(() => {
if (
operationalMode === 'autonomous' &&
message.metadata?.transactionBytes &&
!isUser
) {
return (
);
}
return null;
})()}
{/* Image Modal */}
{imageModal && (
setImageModal(null)}
>
e.stopPropagation()}
>
{/* Modal Header */}
{/* Modal Content */}

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;