/**
* The Pet tab: browse the petdex gallery and use a companion's first sprite
* frame as the bot's profile picture.
*/
import { Button, cn, GlyphSpinner, host, Input, LruCache, RowButton, useQuery } from '@hermes/plugin-sdk'
import { useEffect, useState } from 'react'
import { useBots } from './i18n'
import { ID } from './shared'
// ── pet tab: attach a petdex companion that lives beside the avatar ─────────
// A petdex "spritesheet" is the FULL animation sheet (1536×1872 webp, ~2MB;
// 8×9 grid of 192×208 frames). Using it as an both downloads megabytes
// per tile and shows the whole sheet squashed. Extract frame 0 once per slug
// via canvas, downscale to 96px, and cache the data URL. Concurrency-capped
// so opening the tab doesn't fire dozens of 2MB fetches at once.
const PET_FRAME_W = 192
const PET_FRAME_H = 208
// The gallery is 4500+ pets browsed 24 at a time, and each entry is a decoded
// PNG data URL — an unbounded cache holds every pet the user ever scrolled
// past for the life of the window. Five pages' worth keeps scrolling back up
// instant; past that a revisit pays the fetch and crop again.
const PET_FRAME_CACHE_MAX = 120
const petFrameCache = new LruCache>(PET_FRAME_CACHE_MAX)
let petFetchActive = 0
const petFetchQueue: Array<() => Promise> = []
function pumpPetQueue() {
while (petFetchActive < 4 && petFetchQueue.length) {
const job = petFetchQueue.shift()!
petFetchActive++
job().finally(() => {
petFetchActive--
pumpPetQueue()
})
}
}
function petFrameIcon(spriteUrl: null | string | undefined): Promise {
if (!spriteUrl) {
return Promise.resolve(null)
}
if (!petFrameCache.has(spriteUrl)) {
petFrameCache.set(
spriteUrl,
new Promise(resolve => {
petFetchQueue.push(async () => {
try {
const resp = await fetch(spriteUrl, {
signal: AbortSignal.timeout(15000)
})
const blob = await resp.blob()
// Crop frame 0 during decode — never materialize the full sheet.
const bitmap = await createImageBitmap(blob, 0, 0, PET_FRAME_W, PET_FRAME_H)
const canvas = document.createElement('canvas')
canvas.width = 96
canvas.height = 104
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, 96, 104)
bitmap.close()
resolve(canvas.toDataURL('image/png'))
} catch {
petFrameCache.delete(spriteUrl)
resolve(null)
}
})
pumpPetQueue()
})
)
}
return petFrameCache.get(spriteUrl)!
}
interface PetThumbProps {
size?: number
spriteUrl?: null | string
}
/** One pet tile image: frame 0 only, resolved lazily through the cache. */
function PetThumb({ spriteUrl, size = 40 }: PetThumbProps) {
const [icon, setIcon] = useState(null)
useEffect(() => {
let alive = true
petFrameIcon(spriteUrl).then(url => {
if (alive) {
setIcon(url)
}
})
return () => {
alive = false
}
}, [spriteUrl])
if (!icon) {
return (
)
}
return (
)
}
/** One petdex companion as `pet.gallery` reports it. */
interface PetGalleryEntry {
curated?: boolean
displayName?: string
installed?: boolean
slug: string
/** Full animation sheet (1536×1872 webp); frame 0 is cropped out of it. */
spritesheetUrl?: null | string
}
interface PetTabProps {
image: null | string
onImage: (image: null | string) => void
}
export function PetTab({ image, onImage }: PetTabProps) {
const b = useBots()
// Selection is dialog-local: committed by the dialog's Save like any
// uploaded/generated image (a direct meta write here gets clobbered by
// Save's own image state).
const [selectedSlug, setSelectedSlug] = useState(null)
const { data, isLoading } = useQuery({
queryKey: [ID, 'pet-gallery'],
queryFn: () => host.request<{ pets?: PetGalleryEntry[] }>('pet.gallery', {}),
staleTime: 300000
})
const [query, setQuery] = useState('')
// Windowed rendering: the gallery is 4500+ pets — mounting an per pet
// froze the dialog. Render `limit` at a time and grow on scroll-to-bottom.
const [limit, setLimit] = useState(24)
const pets = data?.pets ?? []
if (isLoading) {
return (
)
}
if (!pets.length) {
return (
No pets in the petdex gallery. Run `hermes pets` to explore.
{visible.map(pet => (
{
// The pet IS the profile picture: extract frame 0
// and hand it to the dialog as the avatar image.
// Persisted when the user hits Save.
setSelectedSlug(pet.slug)
void petFrameIcon(pet.spritesheetUrl).then(icon => {
if (icon) {
onImage(icon)
} else {
setSelectedSlug(null)
host.notify({
kind: 'error',
message: b.avatar.petLoadFailed
})
}
})
}}
>
{pet.displayName}
))}
{limit < ranked.length ? (
{`Scroll for more (${limit} of ${ranked.length})`}