277 lines
8.4 KiB
TypeScript
277 lines
8.4 KiB
TypeScript
/**
|
||
* 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 <img> 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<string, Promise<null | string>>(PET_FRAME_CACHE_MAX)
|
||
let petFetchActive = 0
|
||
const petFetchQueue: Array<() => Promise<void>> = []
|
||
|
||
function pumpPetQueue() {
|
||
while (petFetchActive < 4 && petFetchQueue.length) {
|
||
const job = petFetchQueue.shift()!
|
||
petFetchActive++
|
||
job().finally(() => {
|
||
petFetchActive--
|
||
pumpPetQueue()
|
||
})
|
||
}
|
||
}
|
||
|
||
function petFrameIcon(spriteUrl: null | string | undefined): Promise<null | string> {
|
||
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 | string>(null)
|
||
useEffect(() => {
|
||
let alive = true
|
||
petFrameIcon(spriteUrl).then(url => {
|
||
if (alive) {
|
||
setIcon(url)
|
||
}
|
||
})
|
||
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [spriteUrl])
|
||
|
||
if (!icon) {
|
||
return (
|
||
<div
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
borderRadius: 6,
|
||
background: 'var(--chrome-action-hover, rgba(255,255,255,0.06))'
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<img
|
||
alt=""
|
||
src={icon}
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
objectFit: 'contain',
|
||
imageRendering: 'pixelated',
|
||
borderRadius: 6
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
/** 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 | string>(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 <img> 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 (
|
||
<div className="flex justify-center py-4">
|
||
<GlyphSpinner className="text-(--ui-text-tertiary)" spinner="breathe" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!pets.length) {
|
||
return (
|
||
<div className="px-2 py-3 text-center text-xs text-(--ui-text-tertiary)">
|
||
No pets in the petdex gallery. Run `hermes pets` to explore.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="grid w-full gap-2">
|
||
<div className="text-center text-[0.65rem] text-(--ui-text-quaternary)">{b.avatar.pickPet}</div>
|
||
<Input
|
||
className="h-7 text-xs"
|
||
onChange={event => {
|
||
setQuery(event.target.value)
|
||
setLimit(24)
|
||
}}
|
||
placeholder={`Search ${pets.length} pets…`}
|
||
value={query}
|
||
/>
|
||
{image && selectedSlug ? (
|
||
<Button
|
||
className="justify-center"
|
||
onClick={() => {
|
||
setSelectedSlug(null)
|
||
onImage(null)
|
||
}}
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
{b.avatar.removeBackToShape}
|
||
</Button>
|
||
) : null}
|
||
{filtered.length === 0 ? (
|
||
<div className="py-3 text-center text-xs text-(--ui-text-quaternary)">No pets match.</div>
|
||
) : (
|
||
<div
|
||
className="overflow-y-auto"
|
||
onScroll={onScroll}
|
||
style={{
|
||
maxHeight: 220
|
||
}}
|
||
>
|
||
<div className="grid grid-cols-3 gap-1.5">
|
||
{visible.map(pet => (
|
||
<RowButton
|
||
className={cn(
|
||
'grid justify-items-center gap-1 rounded-md p-1.5 transition-colors hover:bg-(--chrome-action-hover)',
|
||
selectedSlug === pet.slug && 'ring-1 ring-(--ui-accent)'
|
||
)}
|
||
key={pet.slug}
|
||
onClick={() => {
|
||
// 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
|
||
})
|
||
}
|
||
})
|
||
}}
|
||
>
|
||
<PetThumb size={40} spriteUrl={pet.spritesheetUrl} />
|
||
<span className="w-full truncate text-center text-[0.6rem] text-(--ui-text-tertiary)">
|
||
{pet.displayName}
|
||
</span>
|
||
</RowButton>
|
||
))}
|
||
</div>
|
||
{limit < ranked.length ? (
|
||
<div className="py-2 text-center text-[0.65rem] text-(--ui-text-quaternary)">{`Scroll for more (${limit} of ${ranked.length})`}</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|