diff --git a/src/hooks/useBlobbisCollection.ts b/src/hooks/useBlobbisCollection.ts index cf39ded9..32aa4430 100644 --- a/src/hooks/useBlobbisCollection.ts +++ b/src/hooks/useBlobbisCollection.ts @@ -11,11 +11,26 @@ import { type BlobbiCompanion, } from '@/lib/blobbi'; +/** Maximum number of d-tags per query chunk to avoid relay issues */ +const CHUNK_SIZE = 20; + +/** + * Split an array into chunks of a given size. + */ +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + /** * Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user. * * Features: - * - Fetches multiple pets by d-tag list in a single query + * - Fetches ALL pets by d-tag list (no limit: 1) + * - Chunks large d-lists into multiple queries for relay compatibility * - Keeps only the newest event per d-tag * - Returns both a lookup record and array of companions * - Provides invalidation and optimistic update helpers @@ -42,21 +57,37 @@ export function useBlobbisCollection(dList: string[] | undefined) { return { companionsByD: {}, companions: [] }; } - const filter = { - kinds: [KIND_BLOBBI_STATE], - authors: [user.pubkey], - '#d': sortedDList, - }; + // Log the dList we're about to query + console.log('[Blobbi] dList:', sortedDList); - console.log('[useBlobbisCollection] Sending query with filter:', JSON.stringify(filter, null, 2)); - console.log('[useBlobbisCollection] Requesting d-tags:', sortedDList); + // Chunk the d-list for relay compatibility + const chunks = chunkArray(sortedDList, CHUNK_SIZE); + console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)'); - const events = await nostr.query([filter], { signal }); + // Query all chunks in parallel + const allEvents: NostrEvent[] = []; - console.log('[useBlobbisCollection] Events received:', events.length); + for (const chunk of chunks) { + const filter = { + kinds: [KIND_BLOBBI_STATE], + authors: [user.pubkey], + '#d': chunk, + // IMPORTANT: No limit - fetch ALL pets matching the d-tags + }; + + // Log the filter immediately before query + console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2)); + + const events = await nostr.query([filter], { signal }); + allEvents.push(...events); + + console.log('[useBlobbisCollection] Chunk returned', events.length, 'events'); + } + + console.log('[useBlobbisCollection] Total events received:', allEvents.length); // Filter to valid events - const validEvents = events.filter(isValidBlobbiEvent); + const validEvents = allEvents.filter(isValidBlobbiEvent); console.log('[useBlobbisCollection] Valid events:', validEvents.length); diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 714019ea..822aa927 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1,12 +1,13 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Egg, Moon, Sun, Eye, EyeOff, Loader2, Sparkles, RefreshCw } from 'lucide-react'; +import { Egg, Moon, Sun, Eye, EyeOff, Loader2, Sparkles, RefreshCw, ArrowLeftRight, Check } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useBlobbisCollection } from '@/hooks/useBlobbisCollection'; import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; import { isLegacyBlobbiD } from '@/lib/blobbi'; import { toast } from '@/hooks/useToast'; @@ -15,8 +16,17 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; +/** + * Get the localStorage key for the selected Blobbi. + * User-scoped: blobbi:selected:d: + */ +function getSelectedBlobbiKey(pubkey: string): string { + return `blobbi:selected:d:${pubkey}`; +} + import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, @@ -82,38 +92,74 @@ function BlobbiContent() { updateProfileEvent, } = useBlobbonautProfile(); - // Build the list of all d-tags to fetch: - // unique(profile.has[] + currentCompanion if present) + // STEP 1: Build dList from profile.has[] + currentCompanion const dList = useMemo(() => { if (!profile) return undefined; + // Build unique list: profile.has[] + currentCompanion (if not already in list) const allDs = new Set(profile.has); - if (profile.currentCompanion) { + if (profile.currentCompanion && !allDs.has(profile.currentCompanion)) { allDs.add(profile.currentCompanion); } const result = Array.from(allDs); - console.log('[BlobbiContent] dList for collection query:', result); + + // Debug log as specified + console.log('[Blobbi] dList:', result); + return result.length > 0 ? result : undefined; }, [profile]); - // Fetch ALL Blobbi pets for this user + // STEP 2 & 3: Fetch ALL Blobbi pets (with chunking in the hook) const { companionsByD, + companions, isLoading: collectionLoading, isFetching: collectionFetching, invalidate: invalidateCollection, updateCompanionEvent, } = useBlobbisCollection(dList); - // Determine the selected companion: - // Priority: currentCompanion > first in has[] > undefined + // STEP 5: localStorage for UI selection (user-scoped key) + const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none'; + const [storedSelectedD, setStoredSelectedD] = useLocalStorage(localStorageKey, null); + + // State for showing the Blobbi selector modal + const [showSelector, setShowSelector] = useState(false); + + // STEP 6: Selection Priority + // 1) localStorage selection (if valid and exists in collection) + // 2) first item from profile.has that exists in companionsByD + // 3) undefined (show selector) const selectedD = useMemo(() => { if (!profile) return undefined; - if (profile.currentCompanion) return profile.currentCompanion; - if (profile.has.length > 0) return profile.has[0]; + + // Priority 1: localStorage selection (if it exists in loaded collection) + if (storedSelectedD && companionsByD[storedSelectedD]) { + console.log('[Blobbi] Using localStorage selection:', storedSelectedD); + return storedSelectedD; + } + + // Priority 2: First item from profile.has that exists in companionsByD + for (const d of profile.has) { + if (companionsByD[d]) { + console.log('[Blobbi] Using first valid from profile.has:', d); + return d; + } + } + + // Priority 3: No valid selection + console.log('[Blobbi] No valid selection found'); return undefined; - }, [profile]); + }, [profile, storedSelectedD, companionsByD]); + + // Auto-save selection to localStorage when it changes + useEffect(() => { + if (selectedD && selectedD !== storedSelectedD) { + console.log('[Blobbi] Auto-saving selection to localStorage:', selectedD); + setStoredSelectedD(selectedD); + } + }, [selectedD, storedSelectedD, setStoredSelectedD]); // Get the selected companion from the collection const companion = selectedD ? companionsByD[selectedD] ?? null : null; @@ -126,11 +172,15 @@ function BlobbiContent() { const companionFetching = collectionFetching; const invalidateCompanion = invalidateCollection; - // For compatibility with existing code, use selectedD as effectiveCompanionD - const effectiveCompanionD = selectedD; - const [actionInProgress, setActionInProgress] = useState(null); + // Handler for selecting a Blobbi from the selector + const handleSelectBlobbi = useCallback((d: string) => { + console.log('[Blobbi] User selected:', d); + setStoredSelectedD(d); + setShowSelector(false); + }, [setStoredSelectedD]); + // ─── Helper: Migrate Legacy Pet ─── const migrateLegacyPet = useCallback(async (): Promise<{ canonicalD: string; event: import('@nostrify/nostrify').NostrEvent } | null> => { if (!user?.pubkey || !companion || !needsMigration || !profile) { @@ -432,9 +482,9 @@ function BlobbiContent() { ); } - // Profile exists, but no effectiveCompanionD (no current_companion and empty has[]) + // Profile exists, but dList is empty (no pets in profile.has and no currentCompanion) // Case C: Profile exists but no pets → show "Create Egg" - if (!effectiveCompanionD) { + if (!dList || dList.length === 0) { return (
@@ -468,15 +518,29 @@ function BlobbiContent() { ); } - // We have effectiveCompanionD, wait for companion to load - if (companionLoading && !companion) { + // We have dList, wait for collection to load + if (companionLoading) { return ; } - // effectiveCompanionD exists but companion query returned null - // This could mean the pet doesn't exist on relays yet, or the event is invalid - // Show a helpful state instead of "Create Egg" - if (!companion) { + // STEP 7: No valid selection but we have pets → show Blobbi Selector + // This happens when: + // - localStorage selection doesn't exist in companionsByD + // - No item from profile.has exists in companionsByD + // - But we have loaded companions available + if (!selectedD && companions.length > 0) { + return ( + + ); + } + + // dList has items but collection is empty after loading + // This could mean the pets don't exist on relays yet + if (!selectedD || companions.length === 0) { return (
@@ -517,6 +581,17 @@ function BlobbiContent() { ); } + // Selected companion not found in collection (shouldn't happen, but safety check) + if (!companion) { + return ( + + ); + } + // Case A: Profile exists and companion exists → Render the Blobbi return (
@@ -535,6 +610,34 @@ function BlobbiContent() { {(profileFetching || companionFetching) && ( )} + + {/* STEP 8: Switch Blobbi Button */} + {companions.length > 1 && ( + + + + + + + Switch Blobbi + +
+ {companions.map((c) => ( + handleSelectBlobbi(c.d)} + isSelected={c.d === selectedD} + /> + ))} +
+
+
+ )} + {companion.state === 'sleeping' ? ( <> @@ -749,6 +852,130 @@ function InfoItem({ label, value }: { label: string; value: string }) { ); } +// ─── Blobbi Selector Page ───────────────────────────────────────────────────── + +interface BlobbiSelectorPageProps { + companions: BlobbiCompanion[]; + onSelect: (d: string) => void; + isLoading?: boolean; +} + +function BlobbiSelectorPage({ companions, onSelect, isLoading }: BlobbiSelectorPageProps) { + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Choose Your Blobbi

+

Select a companion to care for

+
+
+ {isLoading && ( + + )} +
+ + {/* Blobbi List */} +
+ {companions.map((companion) => ( + onSelect(companion.d)} + /> + ))} +
+
+ ); +} + +// ─── Blobbi Selector Card ───────────────────────────────────────────────────── + +interface BlobbiSelectorCardProps { + companion: BlobbiCompanion; + onSelect: () => void; + isSelected?: boolean; +} + +function BlobbiSelectorCard({ companion, onSelect, isSelected }: BlobbiSelectorCardProps) { + const isSleeping = companion.state === 'sleeping'; + + return ( + + +
+ {/* Blobbi Visual */} +
+ +
+ + {/* Info */} +
+
+

{companion.name}

+ {isSelected && ( + + )} +
+

+ {companion.stage} Blobbi +

+
+ + {isSleeping ? ( + <> + + Sleeping + + ) : ( + <> + + Active + + )} + + + {companion.visibleToOthers ? ( + <> + + Visible + + ) : ( + <> + + Hidden + + )} + +
+
+
+
+
+ ); +} + // ─── Loading State ──────────────────────────────────────────────────────────── function LoadingState() {