Fix reactive companion visibility and add BlobbiPage duplicate prevention

Root cause: useBlobbiCompanionData had a separate query key (['companion-profile'])
from useBlobbonautProfile (['blobbonaut-profile']). When companion was
selected/removed via BlobbiPage, only the main profile query was invalidated,
leaving the companion layer with stale data.

Fix:
- Rewrote useBlobbiCompanionData to use useBlobbonautProfile instead of
  duplicating the profile query
- Now shares the same query cache, so profile updates (including currentCompanion
  changes) immediately propagate to the companion layer
- Added explicit null return when currentCompanionD is undefined for reactive removal

BlobbiPage duplicate prevention:
- Added check for active floating companion (isActiveFloatingCompanion)
- When the displayed Blobbi is the same as the floating companion, show a
  friendly message: '{name} is out exploring right now.' with Footprints icon
- Prevents seeing two identical Blobbis (one floating, one in-page)

This makes companion selection/removal/replacement fully reactive without
requiring page refresh.
This commit is contained in:
filemon
2026-03-24 12:19:00 -03:00
parent b13a5ae1ae
commit e43c0b1e2e
2 changed files with 57 additions and 54 deletions
@@ -3,6 +3,10 @@
*
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* IMPORTANT: This hook uses useBlobbonautProfile to ensure reactivity.
* When the profile is updated (e.g., companion selected/removed), this hook
* automatically receives the update via the shared query cache.
*/
import { useMemo } from 'react';
@@ -10,12 +14,9 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import {
KIND_BLOBBONAUT_PROFILE,
KIND_BLOBBI_STATE,
getBlobbonautQueryDValues,
isValidBlobbonautEvent,
parseBlobbonautEvent,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/lib/blobbi';
@@ -34,46 +35,27 @@ interface UseBlobbiCompanionDataResult {
* Hook to fetch the current companion from the user's Blobbonaut profile.
*
* Flow:
* 1. Fetch the user's kind 31125 (Blobbonaut Profile) event
* 2. Read the current_companion tag
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Read the currentCompanion from the profile
* 3. If it exists, fetch the corresponding kind 31124 (Blobbi State) event
* 4. Return the minimal data needed for rendering
*
* Reactivity:
* - Uses the same query cache as useBlobbonautProfile
* - When profile is updated via updateProfileEvent(), this hook reacts immediately
* - No duplicate queries or stale cache issues
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Step 1: Fetch the Blobbonaut profile
const profileQuery = useQuery({
queryKey: ['companion-profile', user?.pubkey],
queryFn: async ({ signal }) => {
if (!user?.pubkey) return null;
const dValues = getBlobbonautQueryDValues(user.pubkey);
const events = await nostr.query([{
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
'#d': dValues,
}], { signal });
// Get the latest valid event
const validEvents = events
.filter(isValidBlobbonautEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbonautEvent(validEvents[0]);
},
enabled: !!user?.pubkey,
staleTime: 60_000, // 1 minute
gcTime: 5 * 60_000, // 5 minutes
});
// Use the shared profile hook - this ensures reactivity when profile changes
const { profile, isLoading: profileLoading } = useBlobbonautProfile();
// Extract current companion d-tag
const currentCompanionD = profileQuery.data?.currentCompanion;
// Extract current companion d-tag from the reactive profile
const currentCompanionD = profile?.currentCompanion;
// Step 2: Fetch the Blobbi state if we have a current companion
// Fetch the Blobbi state if we have a current companion
const blobbiQuery = useQuery({
queryKey: ['companion-blobbi', user?.pubkey, currentCompanionD],
queryFn: async ({ signal }) => {
@@ -99,8 +81,13 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
gcTime: 5 * 60_000, // 5 minutes
});
// Step 3: Transform to CompanionData
// Transform to CompanionData
// When currentCompanionD becomes null/undefined, companion becomes null
const companion = useMemo((): CompanionData | null => {
// If no current companion is set in profile, return null immediately
// This ensures removal is reactive
if (!currentCompanionD) return null;
const blobbi = blobbiQuery.data;
if (!blobbi) return null;
@@ -117,11 +104,11 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
adultType: blobbi.adultType,
seed: blobbi.seed,
};
}, [blobbiQuery.data]);
}, [currentCompanionD, blobbiQuery.data]);
return {
companion,
isLoading: profileQuery.isLoading || (!!currentCompanionD && blobbiQuery.isLoading),
error: profileQuery.error ?? blobbiQuery.error ?? null,
isLoading: profileLoading || (!!currentCompanionD && blobbiQuery.isLoading),
error: blobbiQuery.error ?? null,
};
}
+31 -15
View File
@@ -27,6 +27,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose } from '@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal';
import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData';
import { cn } from '@/lib/utils';
import {
@@ -765,6 +766,11 @@ function BlobbiDashboard({
const isSleeping = companion.state === 'sleeping';
const isEgg = companion.stage === 'egg';
// Check if this Blobbi is currently the active floating companion
// If so, we hide the visual here to avoid duplication (one floating, one in-page)
const { companion: activeCompanion } = useBlobbiCompanionData();
const isActiveFloatingCompanion = activeCompanion?.d === companion.d;
// Projected state with decay applied (UI-only, recalculates every 60s)
const projectedState = useProjectedBlobbiState(companion);
@@ -1273,21 +1279,31 @@ function BlobbiDashboard({
</div>
{/* Main Blobbi Visual */}
<div className={cn(
"relative transition-all duration-500",
isSleeping && "opacity-80"
)}>
{/* Subtle glow effect behind the egg */}
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
className="size-48 sm:size-56"
/>
</div>
{isActiveFloatingCompanion ? (
// Show message when Blobbi is active as floating companion
<div className="flex flex-col items-center justify-center size-48 sm:size-56 text-center">
<Footprints className="size-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
</div>
) : (
<div className={cn(
"relative transition-all duration-500",
isSleeping && "opacity-80"
)}>
{/* Subtle glow effect behind the egg */}
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
className="size-48 sm:size-56"
/>
</div>
)}
{/* Stage Badge */}
<Badge variant="outline" className="mt-6 capitalize">