import React, { useEffect, useState, useRef, useMemo } from 'react';
import { BlockLoader, Logger } from '@hashgraphonline/standards-sdk';
import { cn } from '../../lib/utils';
import {
FiX,
FiLink,
FiCopy,
FiExternalLink,
FiRefreshCw,
} from 'react-icons/fi';
import Typography from '../ui/Typography';
import { motion } from 'framer-motion';
import { useConfigStore } from '../../stores/configStore';
const logger = new Logger({ module: 'HashLinkBlockRenderer' });
const FALLBACK_TEMPLATE = `
Topic: {{topicId}} | Network: {{network}}
`;
interface HashLinkBlock {
blockId: string;
hashLink: string;
template: string;
attributes: Record;
}
interface HashLinkBlockRendererProps {
hashLinkBlock: HashLinkBlock;
className?: string;
}
/**
* Generic renderer for HCS-12 HashLink blocks
* Fetches templates from the blockchain and renders them with simple variable substitution
* Templates are expected to be self-contained with their own JavaScript and CSS
*/
const HashLinkBlockRenderer: React.FC = ({
hashLinkBlock,
className,
}) => {
const [isLoading, setIsLoading] = useState(true);
const [renderError, setRenderError] = useState(null);
const [renderedContent, setRenderedContent] = useState(null);
const [loadTime, setLoadTime] = useState(null);
const [isFromCache, setIsFromCache] = useState(false);
const [imagesLoaded, setImagesLoaded] = useState(0);
const [totalImages, setTotalImages] = useState(0);
const { config } = useConfigStore();
const contentRef = useRef(null);
const hasRenderedRef = useRef(false);
const loadStartTime = useRef(null);
const observerRef = useRef(null);
const blockId = hashLinkBlock.blockId;
const template = hashLinkBlock.template;
const network = config?.hedera?.network === 'mainnet' ? 'mainnet' : 'testnet';
const attributesKey = useMemo(() => {
return JSON.stringify(hashLinkBlock.attributes);
}, [hashLinkBlock.attributes]);
const copyHRL = async () => {
try {
await navigator.clipboard.writeText(hashLinkBlock.hashLink);
logger.info('HRL copied to clipboard', { hrl: hashLinkBlock.hashLink });
} catch (error) {
logger.error('Failed to copy HRL to clipboard', error);
}
};
const openInExplorer = () => {
try {
const isMainnet = network === 'mainnet';
const baseUrl = isMainnet
? 'https://hashscan.io'
: 'https://hashscan.io/testnet';
const explorerUrl = `${baseUrl}/topic/${hashLinkBlock.blockId}`;
window.open(explorerUrl, '_blank', 'noopener,noreferrer');
logger.info('Opened HashLink in explorer', { url: explorerUrl });
} catch (error) {
logger.error('Failed to open explorer', error);
}
};
const refreshBlock = () => {
hasRenderedRef.current = false;
setRenderedContent(null);
setRenderError(null);
setIsLoading(true);
setLoadTime(null);
setIsFromCache(false);
setImagesLoaded(0);
setTotalImages(0);
loadStartTime.current = null;
};
const handleKeyDown = (event: React.KeyboardEvent, action: () => void) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
action();
}
};
useEffect(() => {
if (hasRenderedRef.current && renderedContent) {
logger.info('Skipping re-render, content already loaded');
return;
}
const renderHashLinkBlock = async () => {
try {
loadStartTime.current = performance.now();
setIsLoading(true);
setRenderError(null);
setIsFromCache(false);
const logger = new Logger({
module: 'HashLinkRenderer',
level: 'info',
});
const blockLoader = new BlockLoader(network, logger);
let templateContent = template;
const isBlockIdTemplate = templateContent?.match(/^0\.0\.\d+$/);
if (isBlockIdTemplate) {
try {
logger.info(
`Fetching template from blockchain: ${templateContent}`
);
const blockData = await blockLoader.loadBlock(templateContent);
if (blockData.template) {
templateContent = blockData.template;
logger.info('Template fetched successfully from blockchain');
} else {
throw new Error('No template found in block data');
}
} catch (loadError) {
logger.error('Failed to load template from blockchain:', loadError);
setRenderError(
`Failed to load template: ${loadError instanceof Error ? loadError.message : 'Unknown error'}`
);
return;
}
} else if (!templateContent || templateContent.trim() === '') {
templateContent = FALLBACK_TEMPLATE;
logger.info('Using fallback template');
}
let finalHtml = templateContent;
if (finalHtml && finalHtml.includes('{{')) {
Object.entries(hashLinkBlock.attributes).forEach(([key, value]) => {
const regex = new RegExp(`{{${key}}}`, 'g');
finalHtml = finalHtml?.replace(regex, String(value || ''));
});
}
logger.info('Template rendered successfully', {
templateLength: finalHtml?.length,
attributeCount: Object.keys(hashLinkBlock.attributes).length,
});
setRenderedContent(finalHtml);
hasRenderedRef.current = true;
if (loadStartTime.current) {
const endTime = performance.now();
const timeElapsed = Math.round(endTime - loadStartTime.current);
setLoadTime(timeElapsed);
}
if (loadStartTime.current) {
const endTime = performance.now();
const timeElapsed = endTime - loadStartTime.current;
setIsFromCache(timeElapsed < 100);
}
} catch (error) {
logger.error('Template rendering failed:', error);
setRenderError(
error instanceof Error ? error.message : 'Unknown rendering error'
);
} finally {
setIsLoading(false);
}
};
renderHashLinkBlock();
}, [blockId, template, network, attributesKey]);
const scriptsExecutedRef = useRef(false);
useEffect(() => {
if (renderedContent && contentRef.current) {
const images = contentRef.current.querySelectorAll('img');
setTotalImages(images.length);
setImagesLoaded(0);
if ('IntersectionObserver' in window) {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
if (img.dataset.src) {
img.src = img.dataset.src;
img.classList.remove('opacity-0');
img.classList.add('opacity-100');
observerRef.current?.unobserve(img);
}
}
});
},
{ rootMargin: '50px' }
);
images.forEach((img) => {
if (!img.src || img.src === '') {
img.dataset.src = img.src;
img.src =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0iI2YzZjRmNiIvPgo8L3JlY3Q+Cjwvc3ZnPg==';
img.classList.add(
'opacity-0',
'transition-opacity',
'duration-300'
);
observerRef.current?.observe(img);
}
img.addEventListener('load', () => {
setImagesLoaded((prev) => prev + 1);
});
img.addEventListener('error', () => {
setImagesLoaded((prev) => prev + 1);
});
});
}
}
return () => {
observerRef.current?.disconnect();
};
}, [renderedContent]);
useEffect(() => {
if (renderedContent && contentRef.current && !scriptsExecutedRef.current) {
logger.info('Executing scripts in HashLink block');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderedContent;
const scripts = tempDiv.querySelectorAll('script');
logger.info(`Found ${scripts.length} script tags in template`);
scripts.forEach((script, index) => {
const newScript = document.createElement('script');
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
try {
newScript.textContent = script.textContent || '';
contentRef.current?.appendChild(newScript);
logger.info(`Script ${index + 1} executed successfully`);
setTimeout(() => {
newScript.remove();
}, 0);
} catch (error) {
logger.error(`Failed to execute script ${index + 1}:`, error);
}
});
scriptsExecutedRef.current = true;
}
}, [renderedContent]);
if (isLoading) {
return (
{/* Header skeleton */}
{/* Content skeleton */}
{/* Loading overlay */}
Loading HashLink block...
);
}
if (renderError) {
return (
HashLink Block Error
{renderError}
Troubleshooting tips:
• Check your network connection
• Verify the HashLink reference is valid
• Try refreshing if the issue persists
);
}
if (!renderedContent) {
return (
No content available for HashLink block
);
}
return (
HCS-12 HashLink Block
{hashLinkBlock.blockId}
{/* Cache and performance indicators */}
{(loadTime || totalImages > 0) && (
{isFromCache && (
)}
{loadTime && (
{loadTime}ms
)}
{totalImages > 0 && imagesLoaded < totalImages && (
{imagesLoaded}/{totalImages}
)}
)}
);
};
export default React.memo(HashLinkBlockRenderer, (prevProps, nextProps) => {
return (
prevProps.hashLinkBlock.blockId === nextProps.hashLinkBlock.blockId &&
prevProps.hashLinkBlock.template === nextProps.hashLinkBlock.template &&
JSON.stringify(prevProps.hashLinkBlock.attributes) ===
JSON.stringify(nextProps.hashLinkBlock.attributes) &&
prevProps.className === nextProps.className
);
});