From c98b73829092b3b298f1591bab988283d9660705 Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 25 Apr 2026 03:46:37 -0300 Subject: [PATCH 01/10] Add drag-to-feed with chewing animation for Kitchen room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag-to-feed: - Press a food item in the Kitchen carousel to start dragging. - A ghost emoji follows the pointer via direct DOM mutation (no React re-renders during pointermove). - Near Blobbi's mouth the ghost scales down and Blobbi opens its mouth. - Drop near the mouth to feed; drop anywhere else to cancel. - The drag lifecycle is owned by global window listeners (pointermove, pointerup, pointercancel, blur), not by the carousel button. This eliminates ghost-stuck and ghost-reappear bugs caused by React re-renders swapping button-level handlers mid-capture. - Every listener checks pointerId + monotonic session counter. - Cleanup is idempotent and runs on feed/miss/cancel/blur/unmount. Chewing animation (Phase 2 polish): - On successful feed, Blobbi shows a 700ms chewing animation (SMIL mouth oscillation) with a crumb particle burst at the mouth. - The item-use mutation fires immediately on drop — no delay. - After the chewing phase, if the mutation succeeded, Blobbi shows a happy expression for 1500ms before returning to status-based state. - If the mutation fails, chewing clears without showing happy. - feedSeqRef + mountedRef prevent stale timers/promises from writing state after a newer sequence starts or the component unmounts. - actionCleanupRef shared between tap-to-use and drag-to-feed paths prevents a stale tap-to-use timer from clobbering drag-feed emotion. Supporting changes: - New 'chewing' emotion with generateChewingMouth() SMIL generator. - replaceCurrentMouth regex handles animated elements. - BlobbiRoomHero wrapped in React.memo to prevent SVG animation restarts from unrelated parent re-renders. - useStatusReaction memoizes the override recipe so the same actionOverride produces a stable object reference. - @keyframes crumb-fall added to index.css with reduced-motion support. - CrumbBurst component renders 6 CSS-animated particles at the mouth. - ItemCarousel.centerPointerHandlers reduced to { onPointerDown }. - DEBUG_FOOD_DRAG flag for development tracing. --- .../rooms/components/BlobbiRoomHero.tsx | 13 +- .../rooms/components/BlobbiRoomShell.tsx | 5 + src/blobbi/rooms/components/ItemCarousel.tsx | 14 +- src/blobbi/rooms/hooks/useFoodDrag.ts | 259 ++++++++++++++++++ src/blobbi/ui/hooks/useStatusReaction.ts | 22 +- src/blobbi/ui/lib/emotion-types.ts | 4 +- src/blobbi/ui/lib/mouth/detection.ts | 6 +- src/blobbi/ui/lib/mouth/generators.ts | 37 +++ src/blobbi/ui/lib/mouth/index.ts | 1 + src/blobbi/ui/lib/recipe.ts | 21 ++ src/index.css | 24 ++ src/pages/BlobbiPage.tsx | 250 ++++++++++++++++- 12 files changed, 635 insertions(+), 21 deletions(-) create mode 100644 src/blobbi/rooms/hooks/useFoodDrag.ts diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index e5430899..20206ca1 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -6,7 +6,7 @@ * Top padding accounts for the floating room header overlay. */ -import { useMemo, type CSSProperties } from 'react'; +import { memo, useMemo, type CSSProperties } from 'react'; import { Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle, Footprints, Loader2, @@ -86,7 +86,13 @@ export interface BlobbiRoomHeroProps { // ─── Component ──────────────────────────────────────────────────────────────── -export function BlobbiRoomHero({ +/** + * Memoized so that high-frequency drag-state updates in the parent + * (BlobbiDashboard) do not propagate into the Blobbi visual subtree. + * All props from the parent are stable references during food drag, + * so memo effectively short-circuits the entire subtree. + */ +export const BlobbiRoomHero = memo(function BlobbiRoomHero({ companion, currentStats, isSleeping, @@ -144,6 +150,7 @@ export function BlobbiRoomHero({
); -} +}); // ─── Stats Crown ────────────────────────────────────────────────────────────── diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index 416c7083..671fece6 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -94,6 +94,11 @@ export function BlobbiRoomShell({ const touchStartX = useReactRef(null); const onTouchStart = useCallback((e: React.TouchEvent) => { + // If the touch started on a food-drag handle (the carousel food button), + // skip the swipe — that gesture drives a food drag, not a room change. + // This check is synchronous (DOM attribute), so it works even before + // React re-renders with the drag state from the same pointerdown. + if ((e.target as HTMLElement).closest?.('[data-food-drag]')) return; touchStartX.current = e.touches[0].clientX; }, [touchStartX]); diff --git a/src/blobbi/rooms/components/ItemCarousel.tsx b/src/blobbi/rooms/components/ItemCarousel.tsx index edf80ac3..78c44235 100644 --- a/src/blobbi/rooms/components/ItemCarousel.tsx +++ b/src/blobbi/rooms/components/ItemCarousel.tsx @@ -26,6 +26,14 @@ interface ItemCarouselProps { onFocusChange?: (entry: CarouselEntry) => void; /** When set, the carousel visually guides the user toward this item. */ highlightId?: string | null; + /** Optional pointer-down handler forwarded to the center (focused) item. + * Used by KitchenBar for food drag-to-feed. Receives the currently focused + * entry so the caller doesn't need to track index state. After pointerdown, + * the drag hook owns the lifecycle via global window listeners — the button + * does not need onPointerMove / onPointerUp / onPointerCancel. */ + centerPointerHandlers?: { + onPointerDown: (e: React.PointerEvent, entry: CarouselEntry) => void; + }; className?: string; } @@ -38,6 +46,7 @@ export function ItemCarousel({ disabled, onFocusChange, highlightId, + centerPointerHandlers, className, }: ItemCarouselProps) { const [index, setIndex] = useState(0); @@ -127,8 +136,10 @@ export function ItemCarousel({ )} + + e.stopPropagation()} + onOpenAutoFocus={(e) => e.preventDefault()} + > + {/* ── Action pills ── */} + {panel.step === 'actions' && ( +
+ {SOCIAL_ACTIONS.map(({ inventory }) => { + const meta = ACTION_METADATA[inventory]; + return ( + + ); + })} +
+ )} + + {/* ── Item carousel ── */} + {(panel.step === 'carousel' || panel.step === 'pending') && ( +
+
+ + + {activeAction && ACTION_METADATA[activeAction].icon}{' '} + {activeAction && ACTION_METADATA[activeAction].label} + +
+ {carouselEntries.length > 0 ? ( + + ) : ( +

+ No items available. +

+ )} +
+ )} + + {/* ── Success ── */} + {panel.step === 'success' && ( +
+ {ACTION_METADATA[panel.action].icon} + Sent! +
+ )} +
+ + ); +} diff --git a/src/components/BlobbiStateCard.tsx b/src/components/BlobbiStateCard.tsx index dae31314..6cc3065b 100644 --- a/src/components/BlobbiStateCard.tsx +++ b/src/components/BlobbiStateCard.tsx @@ -4,31 +4,49 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { BlobbiStageVisual, type BlobbiLookMode } from '@/blobbi/ui/BlobbiStageVisual'; import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi'; import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState'; +import { useBlobbiInteractions } from '@/blobbi/core/hooks/useBlobbiInteractions'; import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; +import { ReactionSparkles, ReactionBubbles } from '@/blobbi/ui/ReactionOverlays'; +import { FloatingSocialHearts } from '@/blobbi/ui/FloatingSocialHearts'; +import type { InteractionReactionState } from '@/blobbi/ui/hooks/useInteractionReaction'; +import { cn } from '@/lib/utils'; interface BlobbiStateCardProps { event: NostrEvent; /** Controls eye tracking behavior. Default: 'forward' (eyes look straight ahead). */ lookMode?: BlobbiLookMode; + /** Temporary interaction reaction state (body animation, emotion override, particle overlays). */ + interactionReaction?: InteractionReactionState; } -export function BlobbiStateCard({ event, lookMode = 'forward' }: BlobbiStateCardProps) { +export function BlobbiStateCard({ event, lookMode = 'forward', interactionReaction }: BlobbiStateCardProps) { const companion = useMemo(() => parseBlobbiEvent(event), [event]); const isSleeping = companion?.state === 'sleeping'; const isEgg = companion?.stage === 'egg'; + // Fetch kind 1124 interactions targeting this Blobbi. + // Disabled for eggs: they do not participate in the social stat-loss/care flow. + // Not gated on socialOpen: past interactions must still affect projected + // status even after the owner disables social. The hook is disabled when + // companion is null (invalid event) and returns an empty array. + const { interactions } = useBlobbiInteractions(isEgg ? null : (companion ?? null)); + // ── Project stats forward in time, then resolve visual recipe ── // Feed cards show a snapshot, not a live ticker, so we call the pure // calculateProjectedDecay() once per render instead of using the // interval-based useProjectedBlobbiState hook. This gives us the // same decay math the room view uses (applyBlobbiDecay under the // hood) without any per-card setInterval overhead. + // + // When social interactions are available, they are layered on top + // of the decayed stats via the social projection pipeline. const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => { if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' }; - const { stats } = calculateProjectedDecay(companion); + const socialInteractions = interactions.length > 0 ? interactions : undefined; + const { stats } = calculateProjectedDecay(companion, undefined, socialInteractions); const result = resolveStatusRecipe(stats); @@ -37,24 +55,45 @@ export function BlobbiStateCard({ event, lookMode = 'forward' }: BlobbiStateCard const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated; return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label }; - }, [companion, isEgg, isSleeping]); + }, [companion, isEgg, isSleeping, interactions]); if (!companion) return null; + // During an active interaction reaction with an emotion override, the emotion + // prop drives the face instead of the recipe (recipe takes precedence when set). + const reactionActive = interactionReaction?.isActive ?? false; + const hasEmotionOverride = reactionActive && !!interactionReaction?.emotionOverride; + return (
{/* Blobbi visual — reflects current condition */}
- +
+ + {/* Interaction reaction overlays — sparkles, bubbles, hearts (not for eggs) */} + {!isEgg && ( + <> + + + + + )} +
{/* Name */} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index e9849240..0f4404fe 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -32,6 +32,10 @@ import { Link } from "react-router-dom"; /** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */ const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent }))); const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard }))); +const BlobbiSocialActions = lazy(() => import("@/components/BlobbiSocialActions").then(m => ({ default: m.BlobbiSocialActions }))); +import { parseBlobbiEvent } from "@/blobbi/core/lib/blobbi"; +import { useInteractionReaction, INVENTORY_TO_REACTION } from '@/blobbi/ui/hooks/useInteractionReaction'; +import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils'; import { MusicPlaylistContent, MusicTrackContent, @@ -282,6 +286,21 @@ function encodeEventId(event: NostrEvent): string { return nip19.neventEncode({ id: event.id, author: event.pubkey }); } +/** Returns true if the click target is inside an interactive overlay/element. */ +function isInteractiveTarget(e: React.MouseEvent): boolean { + const target = e.target as HTMLElement; + return !!( + target.closest('[role="dialog"]') || + target.closest("[data-radix-dialog-overlay]") || + target.closest("[data-radix-dialog-content]") || + target.closest("[data-vaul-drawer]") || + target.closest("[data-vaul-drawer-overlay]") || + target.closest('[data-testid="zap-modal"]') || + target.closest("button") || + target.closest("a") + ); +} + /** d-tags reserved by NIP-51 for other purposes — hide these kind 30000 events. */ const DEPRECATED_DTAGS = new Set(["mute", "pin", "bookmark", "communities"]); @@ -335,6 +354,13 @@ export const NoteCard = memo(function NoteCard({ const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); + // Blobbi interaction reaction — triggers visual feedback on the card when social action succeeds + const { state: blobbiReactionState, trigger: triggerBlobbiReaction } = useInteractionReaction(); + const handleBlobbiInteractionSuccess = useCallback((action: InventoryAction) => { + const mapped = INVENTORY_TO_REACTION[action]; + if (mapped) triggerBlobbiReaction(mapped); + }, [triggerBlobbiReaction]); + // Check if the current user can zap this event's author const canZapAuthor = user && canZap(metadata); @@ -344,36 +370,12 @@ export const NoteCard = memo(function NoteCard({ // Handler to navigate to post detail, but only if click didn't originate from a modal const handleCardClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest('[role="dialog"]') || - target.closest("[data-radix-dialog-overlay]") || - target.closest("[data-radix-dialog-content]") || - target.closest("[data-vaul-drawer]") || - target.closest("[data-vaul-drawer-overlay]") || - target.closest('[data-testid="zap-modal"]') || - target.closest("button") || - target.closest("a") - ) { - return; - } + if (isInteractiveTarget(e)) return; openPost(); }; const handleAuxClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest('[role="dialog"]') || - target.closest("[data-radix-dialog-overlay]") || - target.closest("[data-radix-dialog-content]") || - target.closest("[data-vaul-drawer]") || - target.closest("[data-vaul-drawer-overlay]") || - target.closest('[data-testid="zap-modal"]') || - target.closest("button") || - target.closest("a") - ) { - return; - } + if (isInteractiveTarget(e)) return; auxOpenPost(e); }; @@ -423,6 +425,12 @@ export const NoteCard = memo(function NoteCard({ const isZap = event.kind === 9735; const isProfile = event.kind === 0; const isBlobbiState = event.kind === 31124; + const blobbiCompanion = useMemo(() => isBlobbiState ? parseBlobbiEvent(event) : null, [event, isBlobbiState]); + const showBlobbiInteract = isBlobbiState + && !!user + && user.pubkey !== event.pubkey + && !!blobbiCompanion?.socialOpen + && blobbiCompanion?.stage !== 'egg'; const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite; const isTextNote = !isVine && @@ -652,7 +660,7 @@ export const NoteCard = memo(function NoteCard({ ) : isBlobbiState ? ( }> - + ) : ( +
); diff --git a/src/components/PostActionBar.tsx b/src/components/PostActionBar.tsx index 63a4cc0a..eefd87d3 100644 --- a/src/components/PostActionBar.tsx +++ b/src/components/PostActionBar.tsx @@ -15,6 +15,7 @@ import { useToast } from '@/hooks/useToast'; import { canZap } from '@/lib/canZap'; import { formatNumber } from '@/lib/formatNumber'; import { shareOrCopy } from '@/lib/share'; +import { cn } from '@/lib/utils'; interface PostActionBarProps { event: NostrEvent; @@ -24,6 +25,10 @@ interface PostActionBarProps { onMore: () => void; /** Extra classes on the outer wrapper div. */ className?: string; + /** Optional extra buttons rendered after the Reaction button. */ + extraButtons?: React.ReactNode; + /** Use compact sizing (smaller icons/padding on mobile). */ + compact?: boolean; } export function PostActionBar({ @@ -32,6 +37,8 @@ export function PostActionBar({ onReply, onMore, className, + extraButtons, + compact, }: PostActionBarProps) { const { toast } = useToast(); const { user } = useCurrentUser(); @@ -62,11 +69,12 @@ export function PostActionBar({
{/* Reply / Comments */} {/* More */}
); diff --git a/src/components/widgets/BlobbiWidget.tsx b/src/components/widgets/BlobbiWidget.tsx index cf8f0702..d852c4e1 100644 --- a/src/components/widgets/BlobbiWidget.tsx +++ b/src/components/widgets/BlobbiWidget.tsx @@ -272,6 +272,8 @@ interface BlobbiWidgetContentProps { } function BlobbiWidgetContent({ companion, onUseItem, onRest, isActionPending, isCurrentCompanion, isActiveFloatingCompanion, isUpdatingCompanion, onToggleCompanion }: BlobbiWidgetContentProps) { + // Projected state with decay only — owner surfaces do not pre-project social + // effects. Social effects are incorporated via explicit consolidation. const projected = useProjectedBlobbiState(companion); const defaultStats: BlobbiStats = { hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100 }; const stats = projected?.stats ?? defaultStats; diff --git a/src/index.css b/src/index.css index 611c0680..23a8441e 100644 --- a/src/index.css +++ b/src/index.css @@ -480,6 +480,120 @@ } } +/* ─── Floating Social Hearts ─────────────────────────────────────────────────── */ + +@keyframes social-heart-float { + 0% { + opacity: 0; + transform: translateY(0) scale(0.6); + } + 20% { + opacity: 0.85; + transform: translateY(-8px) scale(1); + } + 80% { + opacity: 0.6; + transform: translateY(-50px) scale(0.9); + } + 100% { + opacity: 0; + transform: translateY(-65px) scale(0.5); + } +} + +.animate-social-heart-float { + animation: social-heart-float 3.5s ease-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .animate-social-heart-float { + animation: none !important; + opacity: 0 !important; + } +} + +/* ─── Interaction Reaction Animations ────────────────────────────────────────── */ + +/* Happy wiggle — gentle sway triggered by feeding */ +@keyframes reaction-wiggle { + 0%, 100% { transform: rotate(0deg); } + 15% { transform: rotate(-4deg); } + 30% { transform: rotate(4deg); } + 45% { transform: rotate(-3deg); } + 60% { transform: rotate(3deg); } + 75% { transform: rotate(-1.5deg); } + 90% { transform: rotate(1.5deg); } +} + +.animate-reaction-wiggle { + animation: reaction-wiggle 0.8s ease-in-out; +} + +/* Happy bounces — mini jumps triggered by playing */ +@keyframes reaction-bounce { + 0%, 100% { transform: translateY(0); } + 20% { transform: translateY(-8px); } + 40% { transform: translateY(0); } + 55% { transform: translateY(-5px); } + 70% { transform: translateY(0); } + 82% { transform: translateY(-2px); } + 92% { transform: translateY(0); } +} + +.animate-reaction-bounce { + animation: reaction-bounce 0.9s ease-in-out; +} + +/* Sparkle pop — appears, scales, fades */ +@keyframes reaction-sparkle { + 0% { opacity: 0; transform: scale(0.3); } + 25% { opacity: 1; transform: scale(1.2); } + 50% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.5); } +} + +.animate-reaction-sparkle { + animation: reaction-sparkle 1.2s ease-out forwards; +} + +/* Bubble rise — rises slightly, wobbles, stays visible (covering effect) */ +@keyframes reaction-bubble { + 0% { opacity: 0; transform: translateY(0) scale(0.4); } + 20% { opacity: 0.95; transform: translateY(-5px) scale(1); } + 50% { opacity: 0.9; transform: translateY(-8px) scale(1.05); } + 80% { opacity: 0.85; transform: translateY(-6px) scale(1); } + 100% { opacity: 0.7; transform: translateY(-4px) scale(0.95); } +} + +.animate-reaction-bubble { + animation: reaction-bubble 1.1s ease-out forwards; +} + +/* Backdrop fade for bubble coverage */ +@keyframes reaction-bubble-backdrop { + 0% { opacity: 0; } + 20% { opacity: 1; } + 80% { opacity: 1; } + 100% { opacity: 0.8; } +} + +.animate-reaction-bubble-backdrop { + animation: reaction-bubble-backdrop 1.1s ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-reaction-wiggle, + .animate-reaction-bounce { + animation: none !important; + } + .animate-reaction-sparkle, + .animate-reaction-bubble, + .animate-reaction-bubble-backdrop { + animation: none !important; + opacity: 0 !important; + } +} + /* ─── Blobbi Eye Animation ───────────────────────────────────────────────────── */ /** diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 36f4dca5..4c60fa0d 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -3,10 +3,16 @@ import { createPortal } from 'react-dom'; import { Link, useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, TowelRack, X } from 'lucide-react'; +import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, TowelRack, X, Activity, Users } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState'; +import { useBlobbiInteractions } from '@/blobbi/core/hooks/useBlobbiInteractions'; +import { useBlobbiActivityHistory } from '@/blobbi/core/hooks/useBlobbiActivityHistory'; +import { useCanonicalSync } from '@/blobbi/core/hooks/useCanonicalSync'; +import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items'; +import { timeAgo } from '@/lib/timeAgo'; import { useAppContext } from '@/hooks/useAppContext'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useBlobbonautProfileNormalization } from '@/hooks/useBlobbonautProfileNormalization'; @@ -20,6 +26,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { SubHeaderBar } from '@/components/SubHeaderBar'; import { TabButton } from '@/components/TabButton'; @@ -33,12 +40,15 @@ import { useLayoutOptions } from '@/contexts/LayoutContext'; import { openUrl } from '@/lib/downloadFile'; import { cn } from '@/lib/utils'; +import { genUserName } from '@/lib/genUserName'; +import { getProfileUrl } from '@/lib/profileUrl'; import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags, + statsToTagUpdates, filterMigratedLegacyCompanions, type BlobbiCompanion, type BlobbiStats, @@ -111,7 +121,8 @@ import { } from '@/blobbi/rooms'; import { ROOM_BOTTOM_BAR_CLASS } from '@/blobbi/rooms/lib/room-layout'; import { buildGuideTarget, getGuideRoomDirection, type GuideTarget } from '@/blobbi/rooms/lib/stat-guide-config'; -import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; +import { getActionEmotion, SEVERITY_THRESHOLDS } from '@/blobbi/ui/lib/status-reactions'; +import { useInteractionReaction, INVENTORY_TO_REACTION } from '@/blobbi/ui/hooks/useInteractionReaction'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; @@ -381,21 +392,13 @@ function BlobbiContent() { }); // Build the new tags with decayed stats + new state - const nowStr = now.toString(); - // Get streak updates (putting to sleep/waking counts as care activity) const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {}; const newTags = updateBlobbiTags(canonical.allTags, { state: newState, - hunger: decayResult.stats.hunger.toString(), - happiness: decayResult.stats.happiness.toString(), - health: decayResult.stats.health.toString(), - hygiene: decayResult.stats.hygiene.toString(), - energy: decayResult.stats.energy.toString(), + ...statsToTagUpdates(decayResult.stats, now), ...streakUpdates, - last_interaction: nowStr, - last_decay_at: nowStr, }); const prev = canonical.companion.event; @@ -730,9 +733,9 @@ function BlobbiContent() { ); } - // ─── CASE G: Companions loaded, but no valid selection ─── + // ─── CASE G/H: No valid selection or companion not resolved ─── // Show selector to pick which pet to display - if (!selectedD && filteredCompanions.length > 0) { + if (!selectedD || !companion) { if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: pet selector'); return ( <> @@ -763,38 +766,6 @@ function BlobbiContent() { ); } - // ─── CASE H: Selection exists but companion not resolved (edge case) ─── - if (!companion || !selectedD) { - if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: selector (companion not resolved)'); - return ( - <> - setShowAdoptionFlow(true)} - currentCompanion={profile?.currentCompanion} - /> - - {/* Adoption Flow Modal */} - - - setShowAdoptionFlow(false)} - /> - - - - ); - } - // ─── CASE I: Everything ready - show dashboard ─── // At this point: companion is BlobbiCompanion, selectedD is string (narrowed by Case H guard) // Note: Item use registration is handled by useBlobbiActionsRegistration hook above @@ -857,7 +828,7 @@ function DashboardShell({ children }: DashboardShellProps) { // ─── Dashboard Drawer Type ──────────────────────────────────────────────────── /** Which drawer is open; 'none' = room view visible */ -type DashboardDrawer = 'none' | 'missions' | 'more'; +type DashboardDrawer = 'none' | 'missions' | 'activity' | 'more'; // ─── Main Blobbi Dashboard ──────────────────────────────────────────────────── @@ -952,6 +923,78 @@ function BlobbiDashboard({ } }, [isSleeping]); + // ─── Interaction Activity ─── + // Disabled for eggs: they do not participate in social stat-loss/care flow. + const { interactions, isLoading: interactionsLoading } = useBlobbiInteractions(isEgg ? null : companion); + + // Interaction reaction layer — temporary visual rewards for care actions. + // Produces emotion overrides, body animations, and particle overlays. + // Placed before useCanonicalSync so the trigger can be passed directly. + const { state: interactionReaction, trigger: triggerInteractionReaction } = useInteractionReaction(); + + // ─── Automatic Canonical Sync ─── + // On mount (or companion switch), persist accumulated decay and consolidate + // pending social interactions in a single publish. Replaces the old manual + // "Apply pending care" button. Runs at most once per companion d-tag. + const handleSocialConsolidated = useCallback(() => { + triggerInteractionReaction('social_hearts'); + }, [triggerInteractionReaction]); + + useCanonicalSync({ + companion, + interactions, + interactionsLoading, + updateCompanionEvent, + ensureCanonicalBeforeAction, + onSocialConsolidated: handleSocialConsolidated, + }); + + // ─── Social Permission Toggle ─── + const [isSocialToggling, setIsSocialToggling] = useState(false); + + const handleToggleSocial = useCallback(async (open: boolean) => { + if (!companion) return; + + setIsSocialToggling(true); + try { + const canonical = await ensureCanonicalBeforeAction(); + if (!canonical) { + setIsSocialToggling(false); + return; + } + + const newTags = updateBlobbiTags(canonical.allTags, { + social: open ? 'open' : 'closed', + }); + + const prev = canonical.companion.event; + const event = await publishEvent({ + kind: KIND_BLOBBI_STATE, + content: canonical.content, + tags: newTags, + prev, + }); + + updateCompanionEvent(event); + + toast({ + title: open ? 'Social interactions enabled' : 'Social interactions disabled', + description: open + ? 'Other people can now care for this Blobbi.' + : 'Only you can interact with this Blobbi.', + }); + } catch (error) { + console.error('Failed to toggle social permission:', error); + toast({ + title: 'Failed to update', + description: 'Could not change the social interaction setting. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSocialToggling(false); + } + }, [companion, ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent]); + // ─── Stat Guide Flow ─── const [guideTarget, setGuideTarget] = useState(null); @@ -1011,7 +1054,9 @@ function BlobbiDashboard({ const { companion: activeCompanion } = useBlobbiCompanionData(); const isActiveFloatingCompanion = activeCompanion?.d === companion.d; - // Projected state with decay applied (UI-only, recalculates every 60s) + // Projected state with decay applied (UI-only, recalculates every 60s). + // Owner surfaces use decay-only — social effects are incorporated via + // explicit consolidation, not pre-applied projection. const projectedState = useProjectedBlobbiState(companion); // Clear sleep guide after companion actually enters sleeping state @@ -1048,14 +1093,17 @@ function BlobbiDashboard({ // DEV ONLY: Get effective emotion (dev override or base) const devEmotionOverride = useEffectiveEmotion(); - // Action override emotion - set when Blobbi is doing an action (eating, cleaning, etc.) - // This takes priority over status reactions but not dev override - const [actionOverrideEmotion, setActionOverrideEmotion] = useState(null); + // Music/sing override — persistent while the activity is active (not auto-clearing). + // Separate from interactionReaction because music is a duration-based activity, + // not a short reward reaction. + const [musicOverrideEmotion, setMusicOverrideEmotion] = useState(null); // Status-based automatic reactions (recipe-first pipeline). // Uses projected stats (with decay applied) for accurate reactions. // Body effects (dirt, stink) are folded into the recipe by the resolver — // no separate bodyEffects prop needed. + // + // Override priority: interaction reaction > music override > status reactions. const currentStats = useMemo(() => ({ hunger: projectedState?.stats.hunger ?? companion.stats.hunger ?? 100, happiness: projectedState?.stats.happiness ?? companion.stats.happiness ?? 100, @@ -1064,10 +1112,13 @@ function BlobbiDashboard({ energy: projectedState?.stats.energy ?? companion.stats.energy ?? 100, }), [projectedState, companion.stats]); + // Combined emotion override: interaction reaction wins over music. + const combinedEmotionOverride = interactionReaction.emotionOverride ?? musicOverrideEmotion; + const { recipe: rawStatusRecipe, recipeLabel: rawStatusRecipeLabel } = useStatusReaction({ stats: currentStats, enabled: !isEgg, // Keep enabled during sleep so body effects still resolve - actionOverride: isSleeping ? null : actionOverrideEmotion, + actionOverride: isSleeping ? null : combinedEmotionOverride, }); // When sleeping, overlay the sleeping face on top of the status recipe. @@ -1303,29 +1354,29 @@ function BlobbiDashboard({ const handleCloseInlineActivity = () => { setInlineActivity(createNoActivity()); setBlobbiReaction('idle'); - setActionOverrideEmotion(null); + setMusicOverrideEmotion(null); }; // Handle music playback state changes (for Blobbi reaction) const handleMusicPlaybackStart = () => { setBlobbiReaction('listening'); - setActionOverrideEmotion(getActionEmotion('music')); + setMusicOverrideEmotion(getActionEmotion('music')); }; const handleMusicPlaybackStop = () => { setBlobbiReaction('idle'); - setActionOverrideEmotion(null); + setMusicOverrideEmotion(null); }; // Handle sing recording state changes (for Blobbi reaction) const handleSingRecordingStart = () => { setBlobbiReaction('singing'); - setActionOverrideEmotion(getActionEmotion('sing')); + setMusicOverrideEmotion(getActionEmotion('sing')); }; const handleSingRecordingStop = () => { setBlobbiReaction('idle'); - setActionOverrideEmotion(null); + setMusicOverrideEmotion(null); }; // Handle opening track picker to change track (from inline player) @@ -1388,18 +1439,52 @@ function BlobbiDashboard({ }, 1500); }, [ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent]); - // Handle using an item from the items tab + // Handle using an item from the items tab. + // Triggers a temporary interaction reaction based on the action type. + // For 'clean' actions, detects whether the Blobbi was visibly dirty before + // the action and uses 'clean_complete' if the dirt was fully removed. const handleUseItemFromTab = (itemId: string) => { const action = getActionForItem(itemId); if (!action || isUsingItem) return; setUsingItemId(itemId); - setActionOverrideEmotion(getActionEmotion(action as ActionType)); + + // Snapshot hygiene before the action for clean_complete detection. + // "Visibly dirty" = hygiene below the warning threshold (< 70). + const wasDirtyBefore = action === 'clean' + && currentStats.hygiene < SEVERITY_THRESHOLDS.warning; + + // Map inventory action to reaction type (feed/play/clean/medicine → reaction). + const reactionType = INVENTORY_TO_REACTION[action] ?? 'feed'; + + // For non-clean actions, trigger immediately (facial expression before action completes). + if (action !== 'clean') { + triggerInteractionReaction(reactionType); + } + onUseItem(itemId, action).then(() => { - // Clear guide only after the action succeeds if (guideTarget?.targetItemId === itemId) setGuideTarget(null); + + // For clean actions, trigger after the action succeeds so we can + // detect clean_complete from the updated projected stats. + if (action === 'clean') { + // After the action, the companion cache is already updated. + // The projected state will recalculate on next render, but we can + // check whether the item's hygiene effect crossed the threshold. + // The action result doesn't return the new hygiene value directly, + // so we use the item's known effect + snapshot. + const shopItem = getShopItemById(itemId); + const hygieneGain = shopItem?.effect?.hygiene ?? 0; + const projectedHygiene = currentStats.hygiene + hygieneGain; + const isNowClean = projectedHygiene >= SEVERITY_THRESHOLDS.warning; + + if (wasDirtyBefore && isNowClean) { + triggerInteractionReaction('clean_complete'); + } else { + triggerInteractionReaction('clean'); + } + } }).finally(() => { setUsingItemId(null); - setTimeout(() => setActionOverrideEmotion(null), 1500); }); }; @@ -1455,6 +1540,15 @@ function BlobbiDashboard({ onStartEvolution={handleStartEvolution} /> )} + {activeDrawer === 'activity' && ( + + )} {activeDrawer === 'more' && ( Quests + toggleDrawer('activity')}> + + + Activity + + toggleDrawer('more')}> @@ -1518,6 +1618,7 @@ function BlobbiDashboard({ effectiveEmotion={effectiveEmotion} hasDevOverride={hasDevOverride} blobbiReaction={blobbiReaction} + interactionReaction={isEgg ? undefined : interactionReaction} isActiveFloatingCompanion={isActiveFloatingCompanion} isUpdatingCompanion={isUpdatingCompanion} handleSetAsCompanion={handleSetAsCompanion} @@ -2523,6 +2624,138 @@ function MoreTabContent({ } +// ─── Activity Tab Content ───────────────────────────────────────────────────── + +/** Action label + emoji for display in the activity list */ +const INTERACTION_ACTION_DISPLAY: Record = { + feed: { label: 'Feed', icon: '🍎' }, + play: { label: 'Play', icon: '⚽' }, + clean: { label: 'Clean', icon: '🧼' }, + medicate: { label: 'Medicine', icon: '💊' }, +}; + +interface ActivityTabContentProps { + companion: BlobbiCompanion; + socialOpen: boolean; + onToggleSocial: (open: boolean) => Promise; + isSocialToggling: boolean; + isEgg: boolean; +} + +function ActivityTabContent({ companion, socialOpen, onToggleSocial, isSocialToggling, isEgg }: ActivityTabContentProps) { + // Use the history hook: fetches recent interactions WITHOUT checkpoint filtering, + // so consumed interactions remain visible in the activity history. + const { interactions: allInteractions, isLoading } = useBlobbiActivityHistory(isEgg ? null : companion); + + // Recency rule: if more than 20 interactions available, apply 24h filter. + // Otherwise show the most recent ones regardless of age. + const displayInteractions = useMemo(() => { + if (allInteractions.length <= 20) { + return allInteractions; + } + const cutoff = Math.floor(Date.now() / 1000) - 24 * 60 * 60; + return allInteractions.filter((ix) => ix.createdAt >= cutoff).slice(0, 20); + }, [allInteractions]); + + const socialToggleId = 'blobbi-social-toggle'; + + return ( +
+ {/* ─── Social Permission Toggle (hidden for eggs) ─── */} + {isEgg ? ( +
+ +

+ Social care settings will unlock after your Blobbi hatches. +

+
+ ) : ( +
+ + +
+ )} + + {/* ─── Recent Caretakers List ─── */} + {isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ) : displayInteractions.length === 0 ? ( +
+ +

No recent activity

+
+ ) : ( + <> +

+ {allInteractions.length > 20 ? 'Recent caretakers (last 24h)' : 'Recent caretakers'} +

+
+ {displayInteractions.map((ix) => { + const actionInfo = INTERACTION_ACTION_DISPLAY[ix.action] ?? { label: ix.action, icon: '❓' }; + const item = ix.itemId ? getShopItemById(ix.itemId) : undefined; + + return ( +
+ {actionInfo.icon} + {actionInfo.label} + {item && ( + + {item.icon} {item.name} + + )} + + + {timeAgo(ix.createdAt)} + +
+ ); + })} +
+ + )} +
+ ); +} + +/** Small inline component to resolve + link a caretaker's display name. */ +function CaretakerLink({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const displayName = author.data?.metadata?.name ?? genUserName(pubkey); + const profilePath = getProfileUrl(pubkey, author.data?.metadata); + + return ( + e.stopPropagation()} + > + {displayName} + + ); +} + // ─── Blobbi Selector Page ───────────────────────────────────────────────────── interface BlobbiSelectorPageProps { diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index f6c74739..ad186af8 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -37,6 +37,10 @@ import { RenderResolvedEmoji, } from "@/components/CustomEmoji"; const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard }))); +const BlobbiSocialActions = lazy(() => import("@/components/BlobbiSocialActions").then(m => ({ default: m.BlobbiSocialActions }))); +import { parseBlobbiEvent } from "@/blobbi/core/lib/blobbi"; +import { useInteractionReaction, INVENTORY_TO_REACTION } from '@/blobbi/ui/hooks/useInteractionReaction'; +import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils'; const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard }))); import { FileMetadataContent } from "@/components/FileMetadataContent"; import { PeopleListContent } from "@/components/PeopleListContent"; @@ -174,6 +178,7 @@ import { Nip05Badge } from "@/components/Nip05Badge"; import { ProfileHoverCard } from "@/components/ProfileHoverCard"; import { useAuthor } from "@/hooks/useAuthor"; import { useComments } from "@/hooks/useComments"; +import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useEventInteractions, extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions"; import { useMuteList } from "@/hooks/useMuteList"; import { useProfileUrl } from "@/hooks/useProfileUrl"; @@ -1290,6 +1295,20 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const [interactionsOpen, setInteractionsOpen] = useState(false); const [interactionsTab, setInteractionsTab] = useState("reposts"); + const { user } = useCurrentUser(); + const blobbiCompanion = useMemo(() => isBlobbiState ? parseBlobbiEvent(event) : null, [event, isBlobbiState]); + const showBlobbiInteract = isBlobbiState + && !!user + && user.pubkey !== event.pubkey + && !!blobbiCompanion?.socialOpen + && blobbiCompanion?.stage !== 'egg'; + + // Blobbi interaction reaction — triggers visual feedback on the card when social action succeeds + const { state: blobbiReactionState, trigger: triggerBlobbiReaction } = useInteractionReaction(); + const handleBlobbiInteractionSuccess = useCallback((action: InventoryAction) => { + const mapped = INVENTORY_TO_REACTION[action]; + if (mapped) triggerBlobbiReaction(mapped); + }, [triggerBlobbiReaction]); const parentHints = useMemo( () => (isTextNote || isReaction || isRepost || isZap || isPollVote ? getParentEventHints(event) : undefined), @@ -2165,7 +2184,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { ) : isBlobbiState ? ( }> - + ) : isBadgeAward ? ( @@ -2203,6 +2222,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) { onReply={() => setReplyOpen(true)} onMore={() => setMoreMenuOpen(true)} className="-mx-4 px-4" + compact={showBlobbiInteract} + extraButtons={showBlobbiInteract ? ( + + + + ) : undefined} />