Working with file and image properties
Add Image and File properties to a model, save uploads from a file input, show them with SeedImage, offer files for download, and know what publishing does with them.
Image and File properties hold binary content: a post's cover photo, a PDF attachment. The SDK stores the bytes as files on the device and keeps a reference to them in the item.
You'll need: the blog schema and the React setup from Reading and writing data. Everything here runs in the browser; on Node, image resizing and downloads from Arweave aren't available.
Add the properties
Add an Image and a File property to Post in seed.schema.json, next to the properties it already has, and raise version as for any edit to the file (Evolving the model says why):
"cover": { "type": "Image" },
"attachment": { "type": "File" }Save an upload
Assign the File from an <input type="file"> to the property. Like any assignment, it saves in the background: the SDK writes the file to the device and links it to the post.
import { useItemProperty } from '@seedprotocol/react'
export function CoverPicker({ seedLocalId }: { seedLocalId: string }) {
const { property } = useItemProperty({ seedLocalId, propertyName: 'cover' })
return (
<input
type="file"
accept="image/*"
disabled={!property}
onChange={(e) => {
const file = e.target.files?.[0]
if (file && property) property.value = file
}}
/>
)
}You can pass files when creating an item too: createItem('Post', { title: 'Hello', cover: file }).
The SDK stores each upload under its own name with a generated ID in front (LOoB5jP5k9-photo.png), so two posts that each upload a photo.png keep their own files. Keep the extension and the file's type when you build a File yourself: publishing sets the upload's Content-Type from them (see What publishing does).
What you can assign
Assign File objects. Other values work for some types and not others:
| Value | Image | File |
|---|---|---|
A File | Saved under the file's name, with a generated ID in front | Saved under the file's name, with a generated ID in front |
A Blob | Saved under a generated name with an extension from its MIME type | Rejected by validation |
A blob: URL | Fetched and saved under a generated name | Fetched and saved under a generated name |
A data: URL | Any image type, SVG included: decoded and saved under a generated name with an extension from its MIME type | Decoded (base64 or percent-encoded) and saved under a generated name with an extension from its MIME type |
An http(s) URL | Fetched with fetch() on any host, localhost and IP addresses included, so the server must allow CORS | Fetched and saved under the URL's last path segment, with a generated ID in front |
| A plain string | Links the file if it's the name of a file already in the images folder. A host and path without a scheme, such as cdn.example.com/cover.png, is fetched over https, and raw base64 of an image is decoded. Anything else, such as photo.png when no file of that name is in the folder, fails with No file data found | Links the file if it's the name of a file already in the files folder. Anything else, a raw base64 string included, is saved as the file's text under a generated name with no extension. To save bytes from base64, pass a data: URL |
A fetched URL is saved with whatever body the server returns, an error page included. To check the response first, fetch it yourself, check response.ok, and assign the Blob (or, for a File property, a File built from it). To save text as a file, wrap it: new File([text], 'notes.txt', { type: 'text/plain' }).
Nothing checks size or type. accept="image/*" only filters the picker, so check file.size and file.type before assigning if you need limits. Every byte you save is uploaded when you publish.
Wait for the save
To know when the file is stored, or why it wasn't, wait on save():
property.value = file
try {
await property.save()
} catch (err) {
console.error('Upload failed', err)
}A value the SDK can't fetch or read fails when its write is applied. save() then rejects with an error whose name is 'OutboxOpFailedError', whose message is the original error's, and whose errorName is the original error's class: 'TypeError' for a URL that can't be fetched (Failed to fetch in Chromium), 'Error' for No file data found. The property shows the value it had before. save lists the other errors.
Once a new image is stored, the SDK writes a resized copy at each of the five widths SeedImage picks from, in the background. save() doesn't wait for them, so for a large photo they can appear a few seconds later.
Show an image
SeedImage renders an Image property as an <img>. Pass the width you display it at: it picks the closest stored size (480, 760, 1024, 1440 or 1920 px).
import { SeedImage, useItemProperty } from '@seedprotocol/react'
export function PostCover({ seedLocalId }: { seedLocalId: string }) {
const { property } = useItemProperty({ seedLocalId, propertyName: 'cover' })
if (!property) return null
return (
<div style={{ width: 480, maxWidth: '100%' }}>
<SeedImage
imageProperty={property}
width={480}
alt="Cover image"
style={{ width: '100%', height: 'auto' }}
/>
</div>
)
}SeedImage renders nothing until the property has a value, so you can render it before an image is chosen. Right after a file is assigned, it shows that file as a preview while the save runs. width is also set as the <img>'s width attribute; override it with CSS, as above, for a responsive image. SeedImage lists every prop and the order it picks sources in.
Reading post.cover gives a blob: URL once the image is available locally. Until then it reads undefined, and the property's valueStatus is 'missing'. The SDK revokes that URL when the value moves to another file and when the item is unloaded. An item nothing holds unloads on its own once the grace period ends (60 seconds by default, see unload), so hold the item while a URL from it is on screen, or render through useItem, useItemProperty or SeedImage, which hold what they show while mounted. Read the property again after a change rather than keeping the URL. Prefer SeedImage, which also handles images that aren't on the device yet.
Images from other devices
A post synced from EAS, or created on another device, has a cover that was uploaded to Arweave but isn't on this device, so post.cover reads undefined until it's downloaded. SeedImage works from the stored file name instead: it downloads the image and meanwhile shows it from an Arweave gateway, moving to the next gateway if the image fails to load. To control that yourself, for example to show a placeholder, use useEnsureLocalImage:
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; `enabled` keeps it idle until there is one.
const { status } = useEnsureLocalImage({
imageProperty: property as IItemProperty<any>,
width: 760,
enabled: !!property,
})
if (status === 'ready' && property) return <SeedImage imageProperty={property} width={760} ensure={false} alt="" />
if (status === 'failed') return <p>Image unavailable</p>
return <div className="cover-placeholder" />
}For an image saved on this device, status goes straight to 'ready'. To show the gateway copy in your own <img> while it downloads, use the hook's gatewayHref as src and pass its onGatewayError as onError, so a gateway that doesn't have the file yet hands over to the next. See useEnsureLocalImage for the other statuses.
Offer a file for download
A File property's value is a URL to the stored file: a blob: URL in the browser, a file:// URL on Node. The SDK makes a new one each time the item loads, so the value works as a link after a page reload. It revokes the old one when the value moves to another file, so render the link from the property, as here, rather than keeping the URL. refResolvedValue is the file's name. A file that isn't on this device yet, such as one from another device, reads undefined while refResolvedValue still names it, so the component below shows the name without a link:
import { useItemProperty } from '@seedprotocol/react'
export function Attachment({ seedLocalId }: { seedLocalId: string }) {
const { property } = useItemProperty({ seedLocalId, propertyName: 'attachment' })
const url = property?.value
const filename = property?.refResolvedValue
if (!filename) return <p>No attachment</p>
if (!url) return <p>{filename}</p>
return <a href={url} download={filename}>Download {filename}</a>
}Don't store the URL itself: a blob: URL ends with the page, or sooner when the SDK revokes it. File, Image and Html properties has the detail.
Where the files live
In the browser, files are stored in the browser's private file system (OPFS), in the folder named by config.filesDir (.seed if you followed Add Seed to your app):
| Folder | Contents |
|---|---|
<filesDir>/images/ | Original images |
<filesDir>/images/480/, 760/, 1024/, 1440/, 1920/ | Resized copies |
<filesDir>/files/ | File property files |
useImageFiles() lists the original images and useFiles('files') lists the files, and both refresh when the SDK saves a new one:
import { useFiles, useImageFiles } from '@seedprotocol/react'
function StorageSummary() {
const { imageFiles } = useImageFiles()
const { files } = useFiles('files')
return <p>{imageFiles.length} image(s), {files.length} file(s) on this device</p>
}Files aren't cleaned up. Assigning a new image writes a new file and leaves the old one, and deleting a post doesn't remove its files. To let people see and clear what's stored, use OPFSFilesManager.
OPFS belongs to the browser profile and origin. Clearing site data deletes every file, along with the database. Until a post is published, its files exist only there.
What publishing does
Saving a file only stores it on the device. When you publish the post with @seedprotocol/publish, each Image and File value is uploaded to Arweave:
- The original file is uploaded, as its own Arweave transaction. The resized copies stay local.
- The upload's
Content-Typetag comes from the stored file's type, else its extension (images including AVIF and HEIC, documents, audio and video are recognized), else its first bytes. ItsContent-SHA-256tag holds the SHA-256 of the bytes, and devices that download the file check it. - The post's attestation for
coverpoints to a separate storage item, whose attestation records the Arweave transaction ID. Other devices use that ID to download the file.
Arweave is public and permanent. Anyone with the transaction ID can fetch the file, and unpublishing the post doesn't remove it. Don't publish files you might need to take back. Permanence explains what lasts, and Publishing and revoking walks through publishing.
Troubleshooting
No file data found. save() rejects with an error named OutboxOpFailedError that has this message and errorName 'Error'. The value assigned to an Image property wasn't a File, Blob, data: URL, URL, the name of a file in the images folder, or base64 of an image. A file name such as photo.png is linked only when that file is already in the folder; to save a file from your app's own server, assign its full URL.
Invalid value for property attachment: Expected union value. A Blob was assigned to a File property. Wrap it: new File([blob], 'name.ext', { type: blob.type }).
A File property holds base64 text instead of the decoded bytes. A plain string is saved as text. Pass a data: URL (data:application/pdf;base64,…) or a File.
Related
- Images and media:
SeedImage,useEnsureLocalImageand the media components - Files and OPFS:
useFilesandOPFSFilesManager - ItemProperty: how
File,ImageandHtmlvalues are stored - Permanence: what publishing uploads, and what stays forever
- Publishing and revoking
Model relations between items
Define Relation and List-of-Relation properties, set and read them, load related items, find an item's children, and know what publishing does with the items you point at.
Querying with liveQuery
Choose between the item hooks and live SQL queries, write a live query that sorts and filters posts by their current values, and handle loading, errors and Node.