/** * @file usePets — the pet list and its actions, shared by the mascot's * right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`): * `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing * a featured pet (downloaded on its first pick), uploading an image or a * `.codex-pet.zip` (worn at once), and deleting your own after a confirm * (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size / * shown / speech-bubble prefs. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; import { api, type PetList, type PetView } from "../../lib/api"; import { confirmDialog } from "../../lib/confirm"; import i18n from "../../i18n"; import { sagiPrefs, type PetChoice } from "./prefs"; const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); export function usePets() { const [list, setList] = useState(null); // Which action is running: a featured id, a pet id, "image" or "bundle". const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const reload = useCallback( () => api.pets .list() .then(setList) .catch((err: unknown) => setError(message(err))), [] ); useEffect(() => { void reload(); }, [reload]); const run = async (key: string, work: () => Promise) => { setBusy(key); setError(null); try { await work(); } catch (err) { setError(message(err)); } finally { setBusy(null); } }; const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName }); const install = (featuredId: string) => run(featuredId, async () => { wear((await api.pets.installFeatured(featuredId)).pet); await reload(); }); const add = (file: File, bundle: boolean) => run(bundle ? "bundle" : "image", async () => { const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file); wear(pet); await reload(); }); // One place both callers route through, so neither can delete on one click. const remove = async (p: PetView) => { const ok = await confirmDialog({ title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }), message: i18n.t("settings:sagi.pet.deleteMessage"), confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"), }); if (!ok) return; await run(p.id, async () => { await api.pets.remove(p.id); if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" }); await reload(); }); }; const own = list?.pets.filter((p) => p.source === "upload") ?? []; // Featured id → the installed copy, once downloaded. const installed = new Map( (list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p]) ); return { list, own, installed, busy, error, wear, install, add, remove }; } type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean }; const readWorn = (): Worn => ({ pet: sagiPrefs.getPet(), size: sagiPrefs.getSize(), shown: sagiPrefs.getEnabled(), bubbles: !sagiPrefs.getMuted(), }); /** The worn pet and the mascot's prefs, following every change (any tab). */ export function useWornPet(): Worn { const [worn, setWorn] = useState(readWorn); useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []); return worn; }