Seed Protocol
@seedprotocol/react

Images and media

Components and hooks for showing Image properties stored on the device, and for resolving raw media strings from feeds into images, video, audio, file links, HTML and JSON.

Which component or hook to use depends on what you're holding:

You haveUse
An Image item property from the local databaseSeedImage, or useEnsureLocalImage for your own <img>
A raw string from a feed or XML (a URL, an Arweave transaction ID, a seed UID)SeedMediaImage, SeedMediaVideo, SeedMediaAudio, SeedMediaFile, or useResolvedMediaRef
HTML or JSON to displaySeedHtml, SeedJson
import {
  SeedImage,
  useEnsureLocalImage,
  useResolvedMediaRef,
  SeedMediaImage,
  SeedMediaVideo,
  SeedMediaAudio,
  SeedMediaFile,
  SeedHtml,
  attachArweaveLoadFailover,
  SeedJson,
  formatSeedJson,
  useNormalizedFeedItemFields,
} from '@seedprotocol/react'

Image files live in the browser's private file system (OPFS), under the SDK's images/ folder, with resized copies in width folders (images/480/, images/760/, images/1024/, images/1440/ and images/1920/). Saving an image writes all five copies; an image downloaded from Arweave gets one when it's first shown at a width. For uploading images and how they're stored, see Working with file and image properties.

SeedImage

Renders an Image item property as an <img>. It picks the stored size closest to width, and while the file isn't on the device yet, it downloads it from Arweave and shows a gateway URL in the meantime. When that URL fails to load, it moves on to the next read gateway.

type SeedImageProps = React.ImgHTMLAttributes<HTMLImageElement> & {
  imageProperty: IItemProperty<any>
  width?: number
  height?: number
  filename?: string
  ensure?: boolean
}

The real type is built on DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, and SeedImage forwards ref to the <img> on React 18 and 19.

Props

NameTypeDefaultDescription
imagePropertyIItemProperty<any>requiredThe Image property, for example from useItemProperty or item.properties.
widthnumberThe display width in pixels. Picks the closest stored size and, with ensure, creates that size if it's missing. Also set as the <img>'s width.
heightnumberPassed to the <img>.
filenamestringUse this filename instead of the property's value.
ensurebooleantrueDownload the image and create the needed size if they're missing. With false, only what's already on the device is shown.
altstringthe property nameAlt text.
other <img> propsPassed to the <img>.

What it shows

SeedImage uses the first source it has, in this order:

  1. the stored size closest to width, when width is set and sized copies exist
  2. the original file on the device
  3. the property's value when it's a File or Blob (a preview before saving) or a blob: URL
  4. while ensure is on and nothing local is ready, an Arweave gateway URL (gatewayHref from useEnsureLocalImage). If the <img> fails to load it, SeedImage tries the next gateway, and still calls your onError
  5. a transparent 1×1 placeholder

It renders nothing when the property has no value, no filename and no Arweave transaction ID. When the property is cleared it stops showing the image and renders nothing, and when the value changes to another file it shows the new one.

SeedImage is wrapped in React.memo with the default shallow comparison of all props, so it re-renders when any prop changes, className, style and handlers included.

Example

import { SeedImage, useItemProperty } from '@seedprotocol/react'

function PostCover({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'cover' })
  if (!property) return null

  return (
    <SeedImage
      imageProperty={property}
      width={760}
      alt="Cover image"
      style={{ width: 760, maxWidth: '100%' }}
    />
  )
}

useEnsureLocalImage

The download-and-resize step of SeedImage as a hook, for when you render the image yourself. It makes sure the original is on the device (downloading it from Arweave if needed) and that a resized copy exists for width.

function useEnsureLocalImage(params: {
  imageProperty: IItemProperty<any>
  filename?: string
  width?: number
  enabled?: boolean
}): {
  status: 'idle' | 'loading' | 'ready' | 'unsupported' | 'failed' | 'missing-id'
  gatewayHref: string | undefined
  gatewayHrefs: string[]
  onGatewayError: () => void
  result: EnsureImageLocalResult | null
}

The types are exported as UseEnsureLocalImageParams and UseEnsureLocalImageReturn.

Parameters

NameTypeDefaultDescription
imagePropertyIItemProperty<any>requiredThe Image property, as for SeedImage.
filenamestringUse this filename (or Arweave transaction ID) instead of the property's value.
widthnumberThe display width. The hook creates the closest standard size (480, 760, 1024, 1440 or 1920). Without it, it creates the 480 size.
enabledbooleantruefalse skips the work and returns status: 'idle'.

Returns

FieldTypeDescription
statusstring'idle' before starting or when there's nothing to look up; 'loading' while working; then the result's status.
gatewayHrefstring | undefinedThe current gateway URL for the image's transaction ID, when one is known: an entry of gatewayHrefs, starting with the first. Set even before the download finishes, so you can show it right away. undefined once every gateway has failed.
gatewayHrefsstring[]<gateway>/raw/<txId> on every read gateway: preferred gateways first (where a just-published image appears before it reaches the others), then the public gateways, then the configured host if it isn't already listed. Empty without a transaction ID.
onGatewayError() => voidMoves gatewayHref to the next gateway. Call it from the <img>'s onError.
resultEnsureImageLocalResult | null{ status, filePath?, createdWidths? }: where the original is stored and which sizes were created.

The final statuses mean:

StatusMeaning
'ready'The original is on the device and the requested size exists.
'failed'The file wasn't found locally and couldn't be downloaded, or resizing failed.
'missing-id'There's no filename or transaction ID to look up.
'unsupported'This environment can't store downloaded files in OPFS.

The transaction ID comes, in order, from the property's stored transaction ID, filename, the property's resolved reference, or its value, whichever is a 43-character Arweave ID. Calls for the same image and size share one download.

Example

import type { IItemProperty } from '@seedprotocol/sdk'
import { SeedImage, useEnsureLocalImage, useItemProperty } from '@seedprotocol/react'

function Cover({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'cover' })
  // The hook needs a property; while there's none, `enabled: false` keeps it idle.
  const imageProperty = property as IItemProperty<any>
  const { status, gatewayHref, onGatewayError } = useEnsureLocalImage({
    imageProperty,
    width: 1024,
    enabled: !!property,
  })

  if (property && status === 'ready') {
    return <SeedImage imageProperty={imageProperty} width={1024} ensure={false} alt="Cover" />
  }
  if (gatewayHref) {
    return <img src={gatewayHref} onError={onGatewayError} alt="Cover (loading from Arweave)" />
  }
  return <p>{status === 'failed' ? 'Image unavailable' : 'Loading image…'}</p>
}

Once status is 'ready', the file is local, so SeedImage can show it with ensure={false}.

useResolvedMediaRef

Turns a raw media string, as found in feeds and XML, into a URL you can put in src or href.

function useResolvedMediaRef(params: {
  value: string | undefined | null
  enabled?: boolean
  treatAs?: 'arweaveTx' | 'seedUid' | 'url'
}): {
  href: string | undefined
  status: 'idle' | 'loading' | 'ready' | 'empty' | 'unresolved' | 'error'
  source: 'direct' | 'gateway' | 'localBlob' | undefined
  error: Error | null
  result: ResolveMediaRefResult | null
  refetch: () => void
}

The types are exported as UseResolvedMediaRefParams and UseResolvedMediaRefReturn.

Parameters

NameTypeDefaultDescription
valuestring | undefined | nullrequiredThe raw string.
enabledbooleantruefalse skips resolving and returns status: 'idle'.
treatAs'arweaveTx' | 'seedUid' | 'url'Skip detection and treat value as this kind.

How values resolve

value looks likeResult
http(s)://, blob: or data: URLready, source: 'direct', the URL as is
an EAS explorer attestation URLunresolved (it isn't media)
a 43-character Arweave transaction IDready: a blob: URL of the local copy in images/ (source: 'localBlob') if there is one, otherwise the gateway URL (source: 'gateway')
a seed UID (0x + 64 hex characters)looks up the item's stored transaction ID in the local database, then resolves it as above; unresolved if there isn't one
a seed local ID, or anything elseunresolved
empty or whitespace, null or undefinedidle (the 'empty' status in the type isn't reached through this hook)

Returns

FieldTypeDescription
hrefstring | undefinedThe URL for the current value, when it resolved. After value, enabled or treatAs changes, and after refetch, href is undefined and status is 'loading' until the new resolve finishes.
statusstringSee above. 'error' means the lookup threw.
source'direct' | 'gateway' | 'localBlob' | undefinedWhere href points.
errorError | nullSet when status is 'error'.
resultResolveMediaRefResult | nullThe SDK's full result, including the reason when unresolved.
refetch() => voidResolve again, for example after the file has been downloaded.

A blob: URL the hook created for a local copy (source: 'localBlob') is revoked when value, enabled or treatAs changes, on refetch, and on unmount, so don't keep href beyond the component's life.

import { useResolvedMediaRef } from '@seedprotocol/react'

function EnclosureLink({ value }: { value: string }) {
  const { href, status } = useResolvedMediaRef({ value })
  if (status === 'loading') return <span>…</span>
  if (!href) return <span>Media unavailable</span>
  return <a href={href}>Open media</a>
}

SeedMediaImage, SeedMediaVideo, SeedMediaAudio, SeedMediaFile

Elements for raw media strings. Each resolves value with useResolvedMediaRef and renders nothing until it has a URL, so give them a placeholder of your own if you need one.

type SeedMediaImageProps = Omit<ImgProps, 'src'> & MediaProps<ImgProps & { src: string }>
type SeedMediaVideoProps = Omit<VideoProps, 'src'> & MediaProps<VideoProps & { src: string }>
type SeedMediaAudioProps = Omit<AudioProps, 'src'> & MediaProps<AudioProps & { src: string }>
type SeedMediaFileProps = Omit<AnchorProps, 'href'> &
  MediaProps<AnchorProps & { href: string }> & { download?: string | boolean }

type MediaProps<P> = {
  value: string | undefined | null
  enabled?: boolean
  treatAs?: 'arweaveTx' | 'seedUid' | 'url'
  render?: (props: P) => React.ReactNode
}

MediaProps, ImgProps and the rest are shorthand on this page for the shared props and React's element props. The package exports the four …Props types.

Props

NameApplies toDefaultDescription
valueallrequiredThe raw media string.
enabledalltruefalse renders nothing.
treatAsallAs in useResolvedMediaRef.
renderallRender your own element. Receives the merged props, with src (or href) set.
altimage''Alt text.
controlsvideo, audiotrueShow the browser's controls.
downloadfilePassed to the link's download attribute.
target, relfilesee belowFor http(s) links, target defaults to '_blank' and rel to 'noopener noreferrer'.
childrenfilethe URL's last path segment, or "Open"The link text.
other element propsallPassed to the element.

Example

import { SeedMediaAudio, SeedMediaFile, SeedMediaImage, SeedMediaVideo } from '@seedprotocol/react'

function Enclosures({ image, video, audio, pdf }: Record<'image' | 'video' | 'audio' | 'pdf', string>) {
  return (
    <>
      <SeedMediaImage value={image} alt="Episode artwork" width={320} />
      <SeedMediaVideo value={video} preload="metadata" />
      <SeedMediaAudio value={audio} />
      <SeedMediaFile value={pdf} download>
        Download transcript
      </SeedMediaFile>
    </>
  )
}

A File item property stored on the device doesn't need these components: its value is already a URL to the stored file, made again each time the item loads. Build the link as Offer a file for download does, which also names the download; File, Image and Html properties says when the SDK revokes that URL.

SeedHtml

Renders HTML through a sanitizer that you supply. The SDK stores HTML as is and doesn't ship a default policy, so sanitize is required.

type SeedHtmlProps = Omit<DivProps, 'dangerouslySetInnerHTML' | 'children'> & {
  html: string | null | undefined
  sanitize: (raw: string) => string
  render?: (props: { html: string }) => React.ReactNode
}
NameTypeDefaultDescription
htmlstring | null | undefinedrequiredThe HTML. Empty or whitespace-only renders nothing.
sanitize(raw: string) => stringrequiredYour sanitizer, for example DOMPurify.sanitize. Runs again when html or sanitize changes, so define it outside the component (or with useCallback); an inline arrow function runs it on every render.
render(props: { html: string }) => ReactNodeReceive the sanitized HTML and render your own container instead of the default <div>. The load failover below then needs attachArweaveLoadFailover on your container.
other <div> propsPassed to the <div>.

Before sanitize runs, SeedHtml moves each Arweave link in a src, href, poster or srcset attribute onto the first gateway in the reader's read list, keeping the link's /raw/<id> or /<id> form and the rest of the markup byte for byte. It counts ar://<id>, /raw/<id> on any host, and /<id> on a known gateway. With the default gateway settings, ar://<id> and https://ar.seedprotocol.io/raw/<id> both become https://arweave.net/raw/<id>, so your sanitizer sees https links and never ar://. The stored Html doesn't change. When an img, video, audio or source inside the <div> then fails to load, SeedHtml points it at the same transaction on the next gateway in the list, until the list runs out. Links in published content explains where these links come from and which host each one carries.

import DOMPurify from 'dompurify'
import { SeedHtml, useItemProperty } from '@seedprotocol/react'

const sanitize = (raw: string) => DOMPurify.sanitize(raw)

function PostBody({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'body' })
  const html = property?.value != null ? String(property.value) : undefined

  return <SeedHtml className="prose" html={html} sanitize={sanitize} />
}

attachArweaveLoadFailover

The load failover SeedHtml runs on its own <div>, for a container you render with render.

function attachArweaveLoadFailover(container: HTMLElement): () => void

It listens for load errors in the capture phase on container (load errors don't bubble), and when an img, video, audio or source inside it fails on an Arweave link in src, srcset or poster, moves that link to the next gateway in the read list. A <source> that moves makes its media element reload. It returns a function that removes the listener.

import { useEffect, useRef } from 'react'
import DOMPurify from 'dompurify'
import { SeedHtml, attachArweaveLoadFailover } from '@seedprotocol/react'

const sanitize = (raw: string) => DOMPurify.sanitize(raw)

function ArticleBody({ html }: { html: string }) {
  const ref = useRef<HTMLElement>(null)
  useEffect(() => (ref.current ? attachArweaveLoadFailover(ref.current) : undefined), [])
  return <article ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
}

export function Article({ html }: { html: string | undefined }) {
  return <SeedHtml html={html} sanitize={sanitize} render={(props) => <ArticleBody html={props.html} />} />
}

SeedJson

Displays a value as formatted JSON in a <pre>. It never evaluates the value.

type SeedJsonProps = Omit<PreProps, 'children' | 'dangerouslySetInnerHTML'> & {
  value: unknown
  format?: (value: unknown) => string
  formatOptions?: FormatSeedJsonOptions
  render?: (props: { text: string }) => React.ReactNode
}
NameTypeDefaultDescription
valueunknownrequiredWhat to show. A string that looks like a JSON object or array is parsed first, so a Json property's stored string shows formatted.
format(value: unknown) => stringReplace the built-in formatting.
formatOptionsFormatSeedJsonOptionsOptions for the built-in formatSeedJson.
render(props: { text: string }) => ReactNodeRender your own element with the formatted text.
other <pre> propsPassed to the <pre>.
import { SeedJson, useItemProperty } from '@seedprotocol/react'

function MetadataView({ seedLocalId }: { seedLocalId: string }) {
  const { property } = useItemProperty({ seedLocalId, propertyName: 'metadata' })
  return <SeedJson value={property?.value} formatOptions={{ maxDepth: 4 }} />
}

formatSeedJson

The formatter behind SeedJson, for use outside React.

function formatSeedJson(
  value: unknown,
  options?: { maxDepth?: number; maxStringLength?: number; space?: string | number },
): string
OptionDefaultDescription
maxDepth6Deeper objects show as "[Max depth]".
maxStringLength50000Longer strings are cut and end with ….
space2Indentation, passed to JSON.stringify.

A string that looks like a JSON object or array is parsed and re-formatted; any other string is returned as is (cut to maxStringLength), without quotes. It handles values JSON.stringify can't: BigInt and symbols become strings, functions "[Function]", dates ISO strings, and an object that contains itself "[Circular]". undefined and null return 'undefined' and 'null', and a value that still can't be serialized returns '[Unserializable JSON]'. The options type is exported as FormatSeedJsonOptions.

Only a real cycle prints "[Circular]": an object that appears twice in the value without one is printed in full both times.

useNormalizedFeedItemFields

Classifies the fields of a parsed feed item using a manifest that says what each field holds. It runs synchronously and doesn't touch the network. Pass the results to the SeedMedia* components and SeedHtml.

function useNormalizedFeedItemFields(
  item: Record<string, unknown> | null | undefined,
  manifest: FeedFieldManifest,
): Record<string, NormalizedFeedFieldValue | undefined>

FeedFieldManifest and NormalizedFeedFieldValue come from @seedprotocol/sdk:

type FeedFieldManifest = Record<string, {
  role: 'image' | 'audio' | 'video' | 'file' | 'html' | 'text'
  treatAs?: 'arweaveTx' | 'seedUid' | 'url'
}>

type NormalizedFeedFieldValue =
  | { role: 'image' | 'audio' | 'video' | 'file'; raw: string; classification: MediaRefClassification }
  | { role: 'html'; raw: string }
  | { role: 'text'; raw: string }

For each manifest key, the hook reads the item's field under that key, or its snake_case or camelCase spelling, as a trimmed string. Missing or empty fields map to undefined. Media roles also get a classification (URL, Arweave ID, seed UID and so on). null or undefined for item returns {}.

The result is memoised on item and manifest. Define the manifest outside the component, or memoise it, so it isn't recomputed on every render.

import DOMPurify from 'dompurify'
import type { FeedFieldManifest } from '@seedprotocol/sdk'
import { SeedHtml, SeedMediaImage, useNormalizedFeedItemFields } from '@seedprotocol/react'

const sanitize = (raw: string) => DOMPurify.sanitize(raw)

const manifest: FeedFieldManifest = {
  featureImage: { role: 'image' },
  html: { role: 'html' },
  title: { role: 'text' },
}

function FeedEntry({ entry }: { entry: Record<string, unknown> }) {
  const fields = useNormalizedFeedItemFields(entry, manifest)

  return (
    <article>
      <h2>{fields.title?.raw}</h2>
      <SeedMediaImage value={fields.featureImage?.raw} alt="" />
      <SeedHtml html={fields.html?.raw} sanitize={sanitize} />
    </article>
  )
}

On this page