/** * 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.
) } const q = query.trim().toLowerCase() const filtered = q ? pets.filter(pet => (pet.displayName || '').toLowerCase().includes(q) || (pet.slug || '').includes(q)) : pets // Installed and curated pets surface first — they're the likeliest picks. const ranked = filtered.slice().sort((a, b) => { const rank = (pet: PetGalleryEntry) => (pet.installed ? 0 : pet.curated ? 1 : 2) return rank(a) - rank(b) }) const visible = ranked.slice(0, limit) const onScroll = (event: { currentTarget: HTMLDivElement }) => { const el = event.currentTarget if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && limit < ranked.length) { setLimit(prev => Math.min(prev + 24, ranked.length)) } } return (
{b.avatar.pickPet}
{ setQuery(event.target.value) setLimit(24) }} placeholder={`Search ${pets.length} pets…`} value={query} /> {image && selectedSlug ? ( ) : null} {filtered.length === 0 ? (
No pets match.
) : (
{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})`}
) : null}
)}
) }