fix: load ALL Blobbis and implement proper UI selection flow
- Update useBlobbisCollection to fetch ALL pets without limit:1 - Add chunking support (20 items per chunk) for relay compatibility - Add debug logs for dList and 31124 query filter - Implement localStorage-based UI selection (user-scoped key) - Selection priority: localStorage > first in profile.has > show selector - Add BlobbiSelectorPage for when no valid selection exists - Add BlobbiSelectorCard component for pet selection UI - Add 'Switch Blobbi' button in header for users with multiple pets - Separate concerns: currentCompanion (global) vs selectedBlobbi (page UI)
This commit is contained in:
@@ -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<T>(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);
|
||||
|
||||
|
||||
+250
-23
@@ -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:<pubkey>
|
||||
*/
|
||||
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<string>(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<string | null>(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<string | null>(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 (
|
||||
<main className="flex flex-col items-center justify-center p-6 gap-6 min-h-[60vh]">
|
||||
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
|
||||
@@ -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 <LoadingState />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<BlobbiSelectorPage
|
||||
companions={companions}
|
||||
onSelect={handleSelectBlobbi}
|
||||
isLoading={companionFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<main className="flex flex-col items-center justify-center p-6 gap-6 min-h-[60vh]">
|
||||
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
|
||||
@@ -517,6 +581,17 @@ function BlobbiContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Selected companion not found in collection (shouldn't happen, but safety check)
|
||||
if (!companion) {
|
||||
return (
|
||||
<BlobbiSelectorPage
|
||||
companions={companions}
|
||||
onSelect={handleSelectBlobbi}
|
||||
isLoading={companionFetching}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Case A: Profile exists and companion exists → Render the Blobbi
|
||||
return (
|
||||
<main className="container max-w-2xl mx-auto p-4 pb-20 space-y-6">
|
||||
@@ -535,6 +610,34 @@ function BlobbiContent() {
|
||||
{(profileFetching || companionFetching) && (
|
||||
<RefreshCw className="size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
|
||||
{/* STEP 8: Switch Blobbi Button */}
|
||||
{companions.length > 1 && (
|
||||
<Dialog open={showSelector} onOpenChange={setShowSelector}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ArrowLeftRight className="size-3" />
|
||||
Switch
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Switch Blobbi</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 pt-2">
|
||||
{companions.map((c) => (
|
||||
<BlobbiSelectorCard
|
||||
key={c.d}
|
||||
companion={c}
|
||||
onSelect={() => handleSelectBlobbi(c.d)}
|
||||
isSelected={c.d === selectedD}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Badge variant={companion.state === 'sleeping' ? 'secondary' : 'default'}>
|
||||
{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 (
|
||||
<main className="container max-w-2xl mx-auto p-4 pb-20 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Egg className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Choose Your Blobbi</h1>
|
||||
<p className="text-sm text-muted-foreground">Select a companion to care for</p>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<RefreshCw className="size-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blobbi List */}
|
||||
<div className="grid gap-4">
|
||||
{companions.map((companion) => (
|
||||
<BlobbiSelectorCard
|
||||
key={companion.d}
|
||||
companion={companion}
|
||||
onSelect={() => onSelect(companion.d)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blobbi Selector Card ─────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiSelectorCardProps {
|
||||
companion: BlobbiCompanion;
|
||||
onSelect: () => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
function BlobbiSelectorCard({ companion, onSelect, isSelected }: BlobbiSelectorCardProps) {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:border-primary/50 hover:shadow-md',
|
||||
isSelected && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Blobbi Visual */}
|
||||
<div
|
||||
className={cn(
|
||||
'size-16 rounded-full flex items-center justify-center shrink-0',
|
||||
'bg-gradient-to-br from-amber-100 via-orange-50 to-yellow-100',
|
||||
'dark:from-amber-900/30 dark:via-orange-900/20 dark:to-yellow-900/30',
|
||||
'border-2 border-amber-200 dark:border-amber-800',
|
||||
isSleeping && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
<Egg className={cn(
|
||||
'size-8 text-amber-500',
|
||||
!isSleeping && 'animate-pulse'
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{companion.name}</h3>
|
||||
{isSelected && (
|
||||
<Check className="size-4 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{companion.stage} Blobbi
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={isSleeping ? 'secondary' : 'default'} className="text-xs">
|
||||
{isSleeping ? (
|
||||
<>
|
||||
<Moon className="size-3 mr-1" />
|
||||
Sleeping
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sun className="size-3 mr-1" />
|
||||
Active
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{companion.visibleToOthers ? (
|
||||
<>
|
||||
<Eye className="size-3 mr-1" />
|
||||
Visible
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading State ────────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingState() {
|
||||
|
||||
Reference in New Issue
Block a user