Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add HCS-1 support inside of Hashscan #1523

Open
kantorcodes opened this issue Nov 27, 2024 · 0 comments
Open

Add HCS-1 support inside of Hashscan #1523

kantorcodes opened this issue Nov 27, 2024 · 0 comments
Labels
enhancement New feature or request

Comments

@kantorcodes
Copy link

kantorcodes commented Nov 27, 2024

Problem

With the growing adoption of Hashinals and HCS-1 files on the Hedera network, there is a need to provide native support for viewing these files directly within Hashscan. This enhancement would improve user experience by allowing users to view Hashinal content without leaving the explorer interface, similar to how NFTs utilizing IPFS or Arweave are rendered.

User stories

As a user:

  • I want to view Hashinal inscriptions directly within Hashscan when viewing NFT details
  • I want to see the rendered content of text-based inscriptions (JSON, HTML, SVG, etc.)
  • I want to view image-based inscriptions in their proper format
  • I want the viewing experience to be fast and reliable

Solution

Hashinal Metadata Format

Hashinals use the Hedera Consensus Service (HCS) to store their metadata. The metadata is referenced in the NFT's metadata field using the HRL format (Hedera Resource Locator):

hcs://<version>/<topicId>

where:

  • version: The version of the standard referenced in the location e.g. 1 for HCS-1
  • topicId: The Hedera topic ID where the inscription metadata is stored (e.g., 0.0.3994496)

Integration with HCS-1 CDN

The Mirror Node Explorer will integrate with a HCS-1 CDN to fetch and render Hashinal content. The implementation will follow these key points:

  1. Metadata Parsing
interface HCSMetadata {
  version: string;
  topicId: string;
}

function parseHCSMetadata(metadata: string): HCSMetadata | null {
  const HCS_REGEX = /^hcs:\/\/(\d+)\/(.+)$/;
  const match = metadata.match(HCS_REGEX);
  if (!match) return null;
  
  return {
    version: match[1],
    topicId: match[2]
  };
}
  1. CDN Endpoint Structure
https://kiloscribe.com/api/inscription-cdn/<topicId>?network=mainnet
  1. Implementation Details

To make it easy to understand, I've provided an example implementation using JavaScript that leverages the CDN and the HRL format on an NFT's metadata field.

class HCSFileRenderer {
  constructor(metadata, network = 'mainnet') {
    this.metadata = metadata;
    this.network = network;
    this.parsedMetadata = this.parseHCSMetadata(metadata);
  }

  parseHCSMetadata(metadata) {
    const HCS_REGEX = /^hcs:\/\/(\d+)\/(.+)$/;
    const match = metadata.match(HCS_REGEX);
    if (!match) return null;
    
    return {
      version: match[1],
      topicId: match[2]
    };
  }

  getCDNUrl() {
    if (!this.parsedMetadata) return null;
    return `https://kiloscribe.com/api/inscription-cdn/${this.parsedMetadata.topicId}?network=${this.network}`;
  }

  createImageElement(url) {
    const img = document.createElement('img');
    img.src = url;
    img.style.maxWidth = '100%';
    img.style.height = 'auto';
    return img;
  }

  createVideoElement(url) {
    const video = document.createElement('video');
    video.controls = true;
    video.style.maxWidth = '100%';
    
    const source = document.createElement('source');
    source.src = url;
    video.appendChild(source);
    
    return video;
  }

  createAudioElement(url) {
    const audio = document.createElement('audio');
    audio.controls = true;
    audio.style.width = '100%';
    audio.style.maxWidth = '500px';
    
    const source = document.createElement('source');
    source.src = url;
    audio.appendChild(source);
    
    return audio;
  }

  createIframeElement(url) {
    const iframe = document.createElement('iframe');
    iframe.src = url;
    iframe.style.width = '100%';
    iframe.style.height = '400px';
    iframe.style.border = 'none';
    iframe.sandbox = 'allow-same-origin allow-scripts';
    return iframe;
  }

  createJsonViewer(metadata) {
    const pre = document.createElement('pre');
    pre.style.backgroundColor = '#f5f5f5';
    pre.style.padding = '1rem';
    pre.style.overflow = 'auto';
    pre.textContent = JSON.stringify(metadata, null, 2);
    return pre;
  }

  async render(container, mimeType) {
    const url = this.getCDNUrl();
    if (!url) return;

    let element;
    
    if (mimeType.startsWith('image/')) {
      element = this.createImageElement(url);
    } else if (mimeType.startsWith('video/')) {
      element = this.createVideoElement(url);
    } else if (mimeType.startsWith('audio/')) {
      element = this.createAudioElement(url);
    } else if (mimeType.startsWith('text/html')) {
      element = this.createIframeElement(url);
    } else if (mimeType === 'application/json') {
      const response = await fetch(url);
      const metadata = await response.json();
      element = this.createJsonViewer(metadata);
    }

    if (element) {
      container.innerHTML = '';
      container.appendChild(element);
    }
  }
}

// Usage example:
const renderer = new HCSFileRenderer('hcs://1/0.0.3994496');
const container = document.getElementById('hcs-content');
renderer.render(container, 'image/png');

This implementation:

  1. Supports all common content types (images, videos, audio, HTML, SVG, JSON)
  2. Uses native HTML elements without framework dependencies
  3. Handles content appropriately based on MIME type
  4. Implements proper sandboxing for HTML/SVG content
  5. Provides error handling and fallbacks

React / Vue Examples

React Implementation

import React, { useEffect, useState } from 'react';

interface HCS1RendererProps {
  metadata: string;  // e.g. "hcs://1/0.0.3994496"
  network?: 'mainnet' | 'testnet';
}

const HCS1Renderer: React.FC<HCS1RendererProps> = ({ 
  metadata, 
  network = 'mainnet' 
}) => {
  const [error, setError] = useState<string | null>(null);
  const [mimeType, setMimeType] = useState<string | null>(null);

  const parseHCSMetadata = (metadata: string) => {
    const match = metadata.match(/^hcs:\/\/(\d+)\/(.+)$/);
    return match ? { version: match[1], topicId: match[2] } : null;
  };

  const getCDNUrl = () => {
    const parsed = parseHCSMetadata(metadata);
    return parsed 
      ? `https://kiloscribe.com/api/inscription-cdn/${parsed.topicId}?network=${network}`
      : null;
  };

  useEffect(() => {
    const checkMimeType = async () => {
      const url = getCDNUrl();
      if (!url) {
        setError('Invalid HCS metadata format');
        return;
      }

      try {
        const response = await fetch(url, { method: 'HEAD' });
        setMimeType(response.headers.get('content-type'));
      } catch (err) {
        setError('Failed to load content');
      }
    };

    checkMimeType();
  }, [metadata, network]);

  if (error) return <div className="error">{error}</div>;
  if (!mimeType) return <div className="loading">Loading...</div>;

  const url = getCDNUrl();

  switch (true) {
    case mimeType.startsWith('image/'):
      return (
        <img 
          src={url} 
          alt="HCS-1 Content"
          style={{ maxWidth: '100%', height: 'auto' }}
        />
      );

    case mimeType.startsWith('video/'):
      return (
        <video controls style={{ maxWidth: '100%' }}>
          <source src={url} type={mimeType} />
          Your browser does not support video playback
        </video>
      );

    case mimeType.startsWith('audio/'):
      return (
        <audio controls style={{ width: '100%', maxWidth: '500px' }}>
          <source src={url} type={mimeType} />
          Your browser does not support audio playback
        </audio>
      );

    case mimeType.startsWith('text/html'):
      return (
        <iframe
          src={url}
          style={{ width: '100%', height: '400px', border: 'none' }}
          sandbox="allow-same-origin allow-scripts"
          title="HCS-1 Content"
        />
      );

    case mimeType === 'application/json':
      return <JsonViewer url={url} />;

    default:
      return <div>Unsupported content type: {mimeType}</div>;
  }
};

// Optional JSON viewer component
const JsonViewer: React.FC<{ url: string }> = ({ url }) => {
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(console.error);
  }, [url]);

  if (!data) return <div>Loading JSON...</div>;

  return (
    <pre style={{ 
      backgroundColor: '#f5f5f5', 
      padding: '1rem',
      overflow: 'auto' 
    }}>
      {JSON.stringify(data, null, 2)}
    </pre>
  );
};

export default HCS1Renderer;

Vue Implementation

<!-- HCS1Renderer.vue -->
<template>
  <div class="hcs1-renderer">
    <div v-if="error" class="error">{{ error }}</div>
    <div v-else-if="!mimeType" class="loading">Loading...</div>
    <template v-else>
      <!-- Image -->
      <img
        v-if="isType('image')"
        :src="cdnUrl"
        alt="HCS-1 Content"
        class="media-content"
      >

      <!-- Video -->
      <video
        v-else-if="isType('video')"
        controls
        class="media-content"
      >
        <source :src="cdnUrl" :type="mimeType">
        Your browser does not support video playback
      </video>

      <!-- Audio -->
      <audio
        v-else-if="isType('audio')"
        controls
        class="audio-content"
      >
        <source :src="cdnUrl" :type="mimeType">
        Your browser does not support audio playback
      </audio>

      <!-- HTML-->
      <iframe
        v-else-if="isType('text/html')"
        :src="cdnUrl"
        class="iframe-content"
        sandbox="allow-same-origin allow-scripts"
        title="HCS-1 Content"
      ></iframe>

      <!-- JSON -->
      <json-viewer
        v-else-if="mimeType === 'application/json'"
        :url="cdnUrl"
      />

      <!-- Unsupported -->
      <div v-else class="error">
        Unsupported content type: {{ mimeType }}
      </div>
    </template>
  </div>
</template>

<script>
import { defineComponent, ref, onMounted } from 'vue';
import JsonViewer from './JsonViewer.vue';

export default defineComponent({
  name: 'HCS1Renderer',
  components: {
    JsonViewer
  },
  props: {
    metadata: {
      type: String,
      required: true
    },
    network: {
      type: String,
      default: 'mainnet',
      validator: (value) => ['mainnet', 'testnet'].includes(value)
    }
  },
  setup(props) {
    const error = ref(null);
    const mimeType = ref(null);

    const parseHCSMetadata = (metadata) => {
      const match = metadata.match(/^hcs:\/\/(\d+)\/(.+)$/);
      return match ? { version: match[1], topicId: match[2] } : null;
    };

    const getCDNUrl = () => {
      const parsed = parseHCSMetadata(props.metadata);
      return parsed 
        ? `https://kiloscribe.com/api/inscription-cdn/${parsed.topicId}?network=${props.network}`
        : null;
    };

    const cdnUrl = getCDNUrl();

    const isType = (type) => mimeType.value?.startsWith(type);

    onMounted(async () => {
      if (!cdnUrl) {
        error.value = 'Invalid HCS metadata format';
        return;
      }

      try {
        const response = await fetch(cdnUrl, { method: 'HEAD' });
        mimeType.value = response.headers.get('content-type');
      } catch (err) {
        error.value = 'Failed to load content';
      }
    });

    return {
      error,
      mimeType,
      cdnUrl,
      isType
    };
  }
});
</script>

<style scoped>
.hcs1-renderer {
  width: 100%;
}

.media-content {
  max-width: 100%;
  height: auto;
}

.audio-content {
  width: 100%;
  max-width: 500px;
}

.iframe-content {
  width: 100%;
  height: 400px;
  border: none;
}

.error {
  color: red;
  padding: 1rem;
}

.loading {
  padding: 1rem;
}
</style>

<!-- JsonViewer.vue -->
<template>
  <pre class="json-viewer" v-if="data">{{ formattedData }}</pre>
  <div v-else class="loading">Loading JSON...</div>
</template>

<script>
import { defineComponent, ref, onMounted, computed } from 'vue';

export default defineComponent({
  name: 'JsonViewer',
  props: {
    url: {
      type: String,
      required: true
    }
  },
  setup(props) {
    const data = ref(null);

    const formattedData = computed(() => {
      return JSON.stringify(data.value, null, 2);
    });

    onMounted(async () => {
      try {
        const response = await fetch(props.url);
        data.value = await response.json();
      } catch (error) {
        console.error('Error loading JSON:', error);
      }
    });

    return {
      data,
      formattedData
    };
  }
});
</script>

<style scoped>
.json-viewer {
  background-color: #f5f5f5;
  padding: 1rem;
  overflow: auto;
  white-space: pre-wrap;
}

.loading {
  padding: 1rem;
}
</style>

Usage examples:

React:

// Using the React component
const App = () => {
  return (
    <HCS1Renderer 
      metadata="hcs://1/0.0.3994496"
      network="mainnet"
    />
  );
};

Vue:

<!-- Using the Vue component -->
<template>
  <HCS1Renderer 
    metadata="hcs://1/0.0.3994496"
    network="mainnet"
  />
</template>

<script>
import HCS1Renderer from './components/HCS1Renderer.vue';

export default {
  components: {
    HCS1Renderer
  }
};
</script>

Alternatives

No response

@kantorcodes kantorcodes added the enhancement New feature or request label Nov 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant