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 = `

{{name}}

by {{creator}}

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 && (
Cache
)} {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 ); });