feat(companion): implement proper sleep/wake state, fix mobile tap interaction
- Fix sleep visuals on floating companion: companionDataToBlobbi adapter now passes through actual state and isSleeping instead of hardcoding 'active'/false, so sleeping Blobbi renders closed eyes and Zzz - Refactor companion sleep button as direct action: sleep/wake toggle is routed through BlobbiActionsProvider (toggleSleep registration) instead of the item-flow system. Companion menu button shows Wake up (sun emoji) when sleeping, Sleep (moon emoji) when awake - Freeze companion movement during sleep: state machine respects isSleeping flag, clears all timers/targets, forces idle state. Float animation and sway CSS animation also disabled while sleeping. Blobbi stays parked exactly where sleep was triggered - Fix mobile tap on companion: remove duplicate touch event handlers (touchstart/touchmove/touchend) that conflicted with pointer events. Pointer events handle mouse+touch+pen natively. Use containerRef for setPointerCapture instead of e.target for reliable cross-platform tracking. Remove preventDefault from pointerdown to avoid blocking browser touch-to-pointer synthesis
This commit is contained in:
@@ -193,8 +193,9 @@ export function BlobbiCompanion({
|
||||
}
|
||||
|
||||
// Calculate floating animation offset (gentle sway/float)
|
||||
// Skip during entry animation, dragging, or debug mode
|
||||
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode)
|
||||
// Skip during entry animation, dragging, debug mode, or sleeping
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode && !isSleeping)
|
||||
? calculateFloatAnimation(animationTime, state === 'walking')
|
||||
: { x: 0, y: 0, rotation: 0 };
|
||||
|
||||
@@ -228,12 +229,15 @@ export function BlobbiCompanion({
|
||||
: undefined;
|
||||
|
||||
// Drag handlers with click detection
|
||||
// Uses pointer events only (handles mouse, touch, and pen natively)
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Capture pointer for tracking outside element
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
// Capture pointer on the container (not e.target which may be a child)
|
||||
// for reliable tracking across element boundaries during drag
|
||||
if (containerRef.current) {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Start click detection tracking
|
||||
clickDetection.handlePointerDown({ x: e.clientX, y: e.clientY });
|
||||
@@ -254,7 +258,9 @@ export function BlobbiCompanion({
|
||||
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
if (containerRef.current) {
|
||||
containerRef.current.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
// Finalize click detection - will call onClick if it was a click
|
||||
clickDetection.handlePointerUp();
|
||||
@@ -265,42 +271,6 @@ export function BlobbiCompanion({
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, onEndDrag]);
|
||||
|
||||
// Touch handlers for mobile (with click detection)
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 0) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
clickDetection.handlePointerDown({ x: touch.clientX, y: touch.clientY });
|
||||
}, [clickDetection]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 0) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const position = { x: touch.clientX, y: touch.clientY };
|
||||
|
||||
// Check if movement exceeds click threshold (starts drag)
|
||||
const isDrag = clickDetection.handlePointerMove(position);
|
||||
|
||||
// If dragging, update position
|
||||
if (motion.isDragging || isDrag) {
|
||||
const newX = touch.clientX - config.size / 2;
|
||||
const newY = touch.clientY - config.size / 2;
|
||||
onUpdateDrag({ x: newX, y: newY });
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
// Finalize click detection
|
||||
clickDetection.handlePointerUp();
|
||||
|
||||
// Always end drag state
|
||||
if (motion.isDragging) {
|
||||
onEndDrag();
|
||||
}
|
||||
}, [clickDetection, motion.isDragging, onEndDrag]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -321,9 +291,6 @@ export function BlobbiCompanion({
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<BlobbiCompanionVisual
|
||||
companion={companion}
|
||||
|
||||
@@ -125,6 +125,7 @@ export function BlobbiCompanionLayer() {
|
||||
useItem: contextUseItem,
|
||||
canUseItems,
|
||||
isItemOnCooldown,
|
||||
toggleSleep,
|
||||
} = useBlobbiActions();
|
||||
|
||||
// ── Item use with emotion override ─────────────────────────────────────────
|
||||
@@ -181,6 +182,34 @@ export function BlobbiCompanionLayer() {
|
||||
|
||||
// ── Companion click ────────────────────────────────────────────────────────
|
||||
|
||||
// ── Sleep action (direct, not item-based) ───────────────────────────────────
|
||||
|
||||
const handleSleepAction = useCallback(async () => {
|
||||
if (!toggleSleep) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[CompanionLayer] toggleSleep not registered');
|
||||
}
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
try {
|
||||
await toggleSleep();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[CompanionLayer] Sleep toggle failed:', error);
|
||||
}
|
||||
}
|
||||
}, [toggleSleep, closeMenu]);
|
||||
|
||||
/** Intercept action selection: sleep is a direct action, others go through item flow. */
|
||||
const handleActionClick = useCallback((action: Parameters<typeof selectAction>[0]) => {
|
||||
if (action === 'sleep') {
|
||||
handleSleepAction();
|
||||
} else {
|
||||
selectAction(action);
|
||||
}
|
||||
}, [handleSleepAction, selectAction]);
|
||||
|
||||
const handleCompanionClick = useCallback(() => {
|
||||
if (isEntering) return;
|
||||
toggleMenu();
|
||||
@@ -262,8 +291,9 @@ export function BlobbiCompanionLayer() {
|
||||
companionSize={config.size}
|
||||
actions={availableActions}
|
||||
selectedAction={menuState.selectedAction}
|
||||
onActionClick={selectAction}
|
||||
onActionClick={handleActionClick}
|
||||
onClickOutside={handleClickOutside}
|
||||
isSleeping={isSleeping}
|
||||
/>
|
||||
|
||||
<HangingItems
|
||||
|
||||
@@ -164,7 +164,9 @@ export function BlobbiCompanionVisual({
|
||||
}, [floatOffset]);
|
||||
|
||||
// Reaction state for CSS animations on the OUTER wrapper
|
||||
const reaction = isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
|
||||
// When sleeping, always idle — no swaying/happy animation
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const reaction = isSleeping ? 'idle' : isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
|
||||
|
||||
// ── Shadow ─────────────────────────────────────────────────────────────────
|
||||
const SHADOW_FADE_DISTANCE = 30;
|
||||
|
||||
@@ -210,6 +210,9 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
}, config.attention.postRouteDelay);
|
||||
}, [findMainContentPosition, triggerAttention, config.attention.postRouteDuration, config.attention.postRouteDelay]);
|
||||
|
||||
// Determine if companion is sleeping
|
||||
const companionSleeping = companion?.state === 'sleeping';
|
||||
|
||||
// State management
|
||||
// Pass the shared motionRef so state can read live motion values
|
||||
const {
|
||||
@@ -224,6 +227,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
motionRef,
|
||||
bounds,
|
||||
attentionTarget: currentAttention,
|
||||
isSleeping: companionSleeping,
|
||||
});
|
||||
|
||||
// Motion management
|
||||
|
||||
@@ -33,6 +33,8 @@ interface UseBlobbiCompanionStateOptions {
|
||||
forceInitialWalk?: boolean;
|
||||
/** Current attention target (from UI attention system) */
|
||||
attentionTarget?: AttentionTarget | null;
|
||||
/** Whether the companion is sleeping (freezes all decisions/movement) */
|
||||
isSleeping?: boolean;
|
||||
}
|
||||
|
||||
interface UseBlobbiCompanionStateResult {
|
||||
@@ -59,6 +61,7 @@ export function useBlobbiCompanionState({
|
||||
bounds,
|
||||
forceInitialWalk = true,
|
||||
attentionTarget,
|
||||
isSleeping = false,
|
||||
}: UseBlobbiCompanionStateOptions): UseBlobbiCompanionStateResult {
|
||||
const [state, setState] = useState<CompanionState>('idle');
|
||||
const [direction, setDirection] = useState<CompanionDirection>('right');
|
||||
@@ -137,7 +140,7 @@ export function useBlobbiCompanionState({
|
||||
|
||||
// Make a decision about what to do next
|
||||
const makeDecision = useCallback(() => {
|
||||
if (!isActive || motionRef.current.isDragging) {
|
||||
if (!isActive || isSleeping || motionRef.current.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +176,7 @@ export function useBlobbiCompanionState({
|
||||
// Schedule next decision
|
||||
const duration = transition.duration ?? randomDuration(config.idleTime);
|
||||
timerRef.current = window.setTimeout(makeDecision, duration);
|
||||
}, [isActive, bounds, state, config, startObservation]);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation]);
|
||||
|
||||
// Handle reaching target
|
||||
const onReachedTarget = useCallback(() => {
|
||||
@@ -208,9 +211,22 @@ export function useBlobbiCompanionState({
|
||||
}
|
||||
}, [makeDecision, observationTarget, config.observation.lookDuration]);
|
||||
|
||||
// Start decision loop when active
|
||||
// Force idle when sleeping - stop all movement/decisions immediately
|
||||
useEffect(() => {
|
||||
if (isActive && !motionRef.current.isDragging) {
|
||||
if (isSleeping) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
setTargetX(null);
|
||||
setObservationTarget(null);
|
||||
}
|
||||
}, [isSleeping]);
|
||||
|
||||
// Start decision loop when active (and not sleeping)
|
||||
useEffect(() => {
|
||||
if (isActive && !isSleeping && !motionRef.current.isDragging) {
|
||||
// Clear any existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
@@ -239,7 +255,7 @@ export function useBlobbiCompanionState({
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
|
||||
// Pause decisions while dragging
|
||||
// We poll isDragging via interval since motionRef changes don't trigger re-renders
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type ToggleSleepFunction,
|
||||
type BlobbiActionsContextValue,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsProvider';
|
||||
@@ -28,6 +29,7 @@ export {
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type ToggleSleepFunction,
|
||||
type BlobbiActionsContextValue,
|
||||
type BlobbiActionsContextInternal,
|
||||
};
|
||||
@@ -99,6 +101,9 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
const registeredIsUsing = context?.isUsingItemRegisteredRef.current ?? false;
|
||||
const isUsingItem = registeredIsUsing || fallbackItemUse.isUsingItem;
|
||||
|
||||
// Expose toggleSleep from registered ref (or null if not registered)
|
||||
const toggleSleep = context?.toggleSleepRef.current ?? null;
|
||||
|
||||
// Return stable object
|
||||
return useMemo(() => ({
|
||||
useItem,
|
||||
@@ -106,7 +111,8 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
canUseItems,
|
||||
isItemOnCooldown: fallbackItemUse.isItemOnCooldown,
|
||||
clearItemCooldown: fallbackItemUse.clearItemCooldown,
|
||||
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown]);
|
||||
toggleSleep,
|
||||
}), [useItem, isUsingItem, canUseItems, fallbackItemUse.isItemOnCooldown, fallbackItemUse.clearItemCooldown, toggleSleep]);
|
||||
}
|
||||
|
||||
// ─── Registration Hook ────────────────────────────────────────────────────────
|
||||
@@ -124,7 +130,8 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
*/
|
||||
export function useBlobbiActionsRegistration(
|
||||
useItemFn: UseItemFunction | null,
|
||||
isUsingItem: boolean
|
||||
isUsingItem: boolean,
|
||||
toggleSleepFn?: ToggleSleepFunction | null,
|
||||
): void {
|
||||
const context = useContext(BlobbiActionsContext);
|
||||
|
||||
@@ -135,6 +142,10 @@ export function useBlobbiActionsRegistration(
|
||||
const useItemRef = useRef(useItemFn);
|
||||
useItemRef.current = useItemFn;
|
||||
|
||||
// Keep toggleSleepFn in a ref to avoid stale closures
|
||||
const toggleSleepInnerRef = useRef(toggleSleepFn);
|
||||
toggleSleepInnerRef.current = toggleSleepFn;
|
||||
|
||||
// Create a stable wrapper that delegates to the ref
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
if (!useItemRef.current) {
|
||||
@@ -146,6 +157,13 @@ export function useBlobbiActionsRegistration(
|
||||
return useItemRef.current(itemId, action, quantity);
|
||||
}, []);
|
||||
|
||||
// Create a stable wrapper for toggleSleep
|
||||
const stableToggleSleep = useCallback<ToggleSleepFunction>(async () => {
|
||||
if (toggleSleepInnerRef.current) {
|
||||
return toggleSleepInnerRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update refs and notify only when canUseItems actually changes
|
||||
useEffect(() => {
|
||||
if (!context) {
|
||||
@@ -161,6 +179,7 @@ export function useBlobbiActionsRegistration(
|
||||
context.registerRef.current = canUseItems ? stableUseItem : null;
|
||||
context.canUseItemsRegisteredRef.current = canUseItems;
|
||||
context.isUsingItemRegisteredRef.current = isUsingItem;
|
||||
context.toggleSleepRef.current = toggleSleepFn ? stableToggleSleep : null;
|
||||
|
||||
// Only notify consumers if canUseItems changed (major state change)
|
||||
if (prevCanUseRef.current !== canUseItems) {
|
||||
@@ -171,7 +190,7 @@ export function useBlobbiActionsRegistration(
|
||||
console.log('[BlobbiActions] Registration changed:', { canUseItems, isUsingItem });
|
||||
}
|
||||
}
|
||||
}, [context, useItemFn, stableUseItem, isUsingItem]);
|
||||
}, [context, useItemFn, stableUseItem, isUsingItem, toggleSleepFn, stableToggleSleep]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@@ -180,6 +199,7 @@ export function useBlobbiActionsRegistration(
|
||||
context.registerRef.current = null;
|
||||
context.canUseItemsRegisteredRef.current = false;
|
||||
context.isUsingItemRegisteredRef.current = false;
|
||||
context.toggleSleepRef.current = null;
|
||||
context.notifyUpdate();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -37,6 +37,12 @@ export type UseItemFunction = (
|
||||
quantity?: number
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Function signature for toggling sleep/wake state.
|
||||
* Returns a promise that resolves when the state change is published.
|
||||
*/
|
||||
export type ToggleSleepFunction = () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
@@ -58,6 +64,12 @@ export interface BlobbiActionsContextValue {
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
|
||||
/**
|
||||
* Toggle sleep/wake state on the current companion.
|
||||
* Only available when BlobbiPage has registered its handler.
|
||||
*/
|
||||
toggleSleep: ToggleSleepFunction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +82,8 @@ export interface BlobbiActionsContextInternal {
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Registered sleep/wake toggle function (from BlobbiPage) */
|
||||
toggleSleepRef: React.MutableRefObject<ToggleSleepFunction | null>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
@@ -100,6 +114,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
|
||||
const registerRef = useRef<UseItemFunction | null>(null);
|
||||
const canUseItemsRegisteredRef = useRef<boolean>(false);
|
||||
const isUsingItemRegisteredRef = useRef<boolean>(false);
|
||||
const toggleSleepRef = useRef<ToggleSleepFunction | null>(null);
|
||||
|
||||
// Subscribers for manual notification
|
||||
const subscribersRef = useRef<Set<() => void>>(new Set());
|
||||
@@ -120,6 +135,7 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
|
||||
registerRef,
|
||||
canUseItemsRegisteredRef,
|
||||
isUsingItemRegisteredRef,
|
||||
toggleSleepRef,
|
||||
notifyUpdate,
|
||||
subscribe,
|
||||
}), [notifyUpdate, subscribe]);
|
||||
|
||||
@@ -35,6 +35,8 @@ interface CompanionActionMenuProps {
|
||||
onActionClick: (action: CompanionMenuAction) => void;
|
||||
/** Callback for clicking outside the menu */
|
||||
onClickOutside?: () => void;
|
||||
/** Whether Blobbi is currently sleeping (affects sleep button label) */
|
||||
isSleeping?: boolean;
|
||||
}
|
||||
|
||||
// Layout configuration
|
||||
@@ -90,6 +92,7 @@ export function CompanionActionMenu({
|
||||
selectedAction,
|
||||
onActionClick,
|
||||
onClickOutside,
|
||||
isSleeping = false,
|
||||
}: CompanionActionMenuProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -122,6 +125,11 @@ export function CompanionActionMenu({
|
||||
const isSelected = selectedAction === action.id;
|
||||
const delay = index * MENU_CONFIG.staggerDelay;
|
||||
|
||||
// Sleep action toggles label/emoji based on sleeping state
|
||||
const isSleepAction = action.id === 'sleep';
|
||||
const displayEmoji = isSleepAction && isSleeping ? '\u2600\uFE0F' : action.emoji;
|
||||
const displayLabel = isSleepAction && isSleeping ? 'Wake up' : action.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
@@ -155,15 +163,15 @@ export function CompanionActionMenu({
|
||||
e.stopPropagation();
|
||||
onActionClick(action.id);
|
||||
}}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
title={displayLabel}
|
||||
aria-label={displayLabel}
|
||||
>
|
||||
<span
|
||||
className="text-xl select-none"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{action.emoji}
|
||||
{displayEmoji}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,7 @@ export {
|
||||
export type {
|
||||
UseItemResult as ContextUseItemResult,
|
||||
UseItemFunction,
|
||||
ToggleSleepFunction,
|
||||
BlobbiActionsContextValue,
|
||||
} from './BlobbiActionsContext';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - BlobbiCompanionVisual.tsx (toBlobiForVisual - note typo)
|
||||
*/
|
||||
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { Blobbi, BlobbiState } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { CompanionData } from '@/blobbi/companion/types/companion.types';
|
||||
|
||||
@@ -61,12 +61,13 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
* @returns Blobbi type for visual components
|
||||
*/
|
||||
export function companionDataToBlobbi(companion: CompanionData): Blobbi {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
return {
|
||||
id: companion.d,
|
||||
name: companion.name,
|
||||
lifeStage: companion.stage,
|
||||
state: 'active',
|
||||
isSleeping: false,
|
||||
state: (companion.state as BlobbiState) ?? 'active',
|
||||
isSleeping,
|
||||
stats: {
|
||||
hunger: 100,
|
||||
happiness: 100,
|
||||
|
||||
@@ -426,8 +426,8 @@ function BlobbiContent() {
|
||||
};
|
||||
}, [executeUseItem, companion, profile]);
|
||||
|
||||
// Register with the global BlobbiActionsContext
|
||||
useBlobbiActionsRegistration(useItemForContext, isUsingItem);
|
||||
// Register with the global BlobbiActionsContext (includes sleep toggle for companion layer)
|
||||
useBlobbiActionsRegistration(useItemForContext, isUsingItem, handleRest);
|
||||
|
||||
// ─── Stage Transition Hooks ───
|
||||
const { mutateAsync: executeHatch, isPending: isHatching } = useBlobbiHatch({
|
||||
|
||||
Reference in New Issue
Block a user